Authentication and Odata compliance
This commit is contained in:
+316
-185
@@ -1,12 +1,16 @@
|
|||||||
import os
|
import os
|
||||||
import asyncio
|
|
||||||
import base64
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
from flask import Flask, request, jsonify, current_app
|
import uuid
|
||||||
from pymongo import MongoClient
|
import base64
|
||||||
|
import asyncio
|
||||||
|
import fcntl
|
||||||
|
import atexit
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from flask import Flask, Response, request, jsonify, current_app, abort, g
|
||||||
|
from pymongo import MongoClient, ASCENDING, DESCENDING
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
from APIs import generate
|
from APIs import generate
|
||||||
from APIs.mqtt import send_command
|
from APIs.mqtt import send_command
|
||||||
from APIs.webex import WebexManager
|
from APIs.webex import WebexManager
|
||||||
@@ -14,8 +18,6 @@ from APIs.shodan import ShodanAuditor
|
|||||||
from APIs.twilio import send_alert_sms
|
from APIs.twilio import send_alert_sms
|
||||||
from microwaveCookPlanner import MicrowaveCookPlanner
|
from microwaveCookPlanner import MicrowaveCookPlanner
|
||||||
import safety_checker
|
import safety_checker
|
||||||
import pymongo
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
|
||||||
from jobs import job_server_cve_audit, job_client_ip_audit, job_request_telemetry
|
from jobs import job_server_cve_audit, job_client_ip_audit, job_request_telemetry
|
||||||
|
|
||||||
sys.path.insert(0, '..')
|
sys.path.insert(0, '..')
|
||||||
@@ -35,16 +37,25 @@ MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/")
|
|||||||
client = MongoClient(MONGO_URI)
|
client = MongoClient(MONGO_URI)
|
||||||
db = client["microwave_network_db"]
|
db = client["microwave_network_db"]
|
||||||
|
|
||||||
|
# Database Collections
|
||||||
client_collection = db["client_data"]
|
client_collection = db["client_data"]
|
||||||
cooking_collection = db["cooking_parameters"]
|
cooking_collection = db["cooking_parameters"]
|
||||||
telemetry_collection = db["telemetry_data"]
|
telemetry_collection = db["telemetry_data"]
|
||||||
alert_collection = db["alert_data"]
|
alert_collection = db["alert_data"]
|
||||||
webex_tokens_collection = db["webex_tokens"] # Collection for Webex OAuth tokens
|
webex_tokens_collection = db["webex_tokens"]
|
||||||
device_ip_collection = db["device_ips"] # Collection for device IPs
|
device_ip_collection = db["device_ips"]
|
||||||
cve_audit_results = db["cve_audit_results"] # Collection for CVE audit results
|
cve_audit_results = db["cve_audit_results"]
|
||||||
|
|
||||||
|
# Initialize Database Indexes
|
||||||
|
def init_db_indexes():
|
||||||
|
device_ip_collection.create_index([("received_at", DESCENDING)])
|
||||||
|
device_ip_collection.create_index([("client_ip", ASCENDING)])
|
||||||
|
alert_collection.create_index([("orchestrator_id", ASCENDING), ("created_at", DESCENDING)])
|
||||||
|
client_collection.create_index([("devices.device_id", ASCENDING)])
|
||||||
|
|
||||||
# Webex Credentials & Configuration from Environment Variables
|
init_db_indexes()
|
||||||
|
|
||||||
|
# Environment Configurations
|
||||||
WEBEX_CLIENT_ID = os.getenv("WEBEX_CLIENT_ID", "YOUR_WEBEX_CLIENT_ID")
|
WEBEX_CLIENT_ID = os.getenv("WEBEX_CLIENT_ID", "YOUR_WEBEX_CLIENT_ID")
|
||||||
WEBEX_CLIENT_SECRET = os.getenv("WEBEX_CLIENT_SECRET", "YOUR_WEBEX_CLIENT_SECRET")
|
WEBEX_CLIENT_SECRET = os.getenv("WEBEX_CLIENT_SECRET", "YOUR_WEBEX_CLIENT_SECRET")
|
||||||
WEBEX_REDIRECT_URI = os.getenv("WEBEX_REDIRECT_URI", "https://smartwave.matthiasg.dev/oauth/callback")
|
WEBEX_REDIRECT_URI = os.getenv("WEBEX_REDIRECT_URI", "https://smartwave.matthiasg.dev/oauth/callback")
|
||||||
@@ -57,10 +68,9 @@ CAMERA_IMAGE_DIR = "storage/dishPhotos"
|
|||||||
os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True)
|
os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True)
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# Classes
|
# Integrations Setup
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
microwave_cook_planner = MicrowaveCookPlanner()
|
microwave_cook_planner = MicrowaveCookPlanner()
|
||||||
# Instantiate Webex Manager
|
|
||||||
webex_manager = WebexManager(
|
webex_manager = WebexManager(
|
||||||
db_collection=webex_tokens_collection,
|
db_collection=webex_tokens_collection,
|
||||||
client_id=WEBEX_CLIENT_ID,
|
client_id=WEBEX_CLIENT_ID,
|
||||||
@@ -71,60 +81,110 @@ webex_manager = WebexManager(
|
|||||||
)
|
)
|
||||||
shodan_auditor = ShodanAuditor()
|
shodan_auditor = ShodanAuditor()
|
||||||
|
|
||||||
# -------------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# Cron Scheduler Setup
|
# Cron Scheduler (Multi-Worker Safe)
|
||||||
# -------------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
scheduler = BackgroundScheduler(daemon=True)
|
scheduler = BackgroundScheduler(daemon=True)
|
||||||
|
|
||||||
# Job 1: Server CVE audit every night at 00:00 UTC
|
def start_scheduler_once():
|
||||||
scheduler.add_job(
|
"""Ensures only ONE Gunicorn worker process runs the cron scheduler."""
|
||||||
func=job_server_cve_audit,
|
lock_file_path = "/tmp/scheduler.lock"
|
||||||
args=[shodan_auditor, db],
|
|
||||||
trigger="cron",
|
# Open or create a lock file
|
||||||
hour=0,
|
lock_file = open(lock_file_path, "wb")
|
||||||
minute=0,
|
|
||||||
id="server_cve_audit_job"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Job 2: Client IP Shodan audit every 6 hours
|
|
||||||
scheduler.add_job(
|
|
||||||
func=job_client_ip_audit,
|
|
||||||
args=[shodan_auditor, db],
|
|
||||||
trigger="cron",
|
|
||||||
hour="*/6",
|
|
||||||
minute=15,
|
|
||||||
id="client_ip_audit_job"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Job 3: Request telemetry runs AFTER the client audit (e.g., every 12 hours)
|
|
||||||
scheduler.add_job(
|
|
||||||
func=job_request_telemetry,
|
|
||||||
args=[send_command],
|
|
||||||
trigger="cron",
|
|
||||||
hour="1,13",
|
|
||||||
minute=0,
|
|
||||||
id="request_telemetry_job"
|
|
||||||
)
|
|
||||||
|
|
||||||
scheduler.start()
|
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
# OAuth Routes
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
|
|
||||||
@app.route("/oauth/callback")
|
|
||||||
def oauth_callback():
|
|
||||||
"""OAuth redirect endpoint that receives the authorization code."""
|
|
||||||
code = request.args.get("code")
|
|
||||||
if not code:
|
|
||||||
return jsonify({"error": "Missing code parameter"}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
webex_manager.exchange_code(code)
|
# Request a non-blocking exclusive lock
|
||||||
return jsonify({"status": "success", "message": "Webex tokens stored successfully in MongoDB!"}), 200
|
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
except Exception as e:
|
|
||||||
current_app.logger.exception("Failed to exchange OAuth code")
|
# Register job schedules
|
||||||
return jsonify({"error": f"OAuth exchange failed: {str(e)}"}), 500
|
scheduler.add_job(
|
||||||
|
func=job_server_cve_audit,
|
||||||
|
args=[shodan_auditor, db],
|
||||||
|
trigger="cron",
|
||||||
|
hour=0,
|
||||||
|
minute=0,
|
||||||
|
id="server_cve_audit_job",
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.add_job(
|
||||||
|
func=job_client_ip_audit,
|
||||||
|
args=[shodan_auditor, db],
|
||||||
|
trigger="cron",
|
||||||
|
hour="*/6",
|
||||||
|
minute=15,
|
||||||
|
id="client_ip_audit_job",
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.add_job(
|
||||||
|
func=job_request_telemetry,
|
||||||
|
args=[send_command],
|
||||||
|
trigger="cron",
|
||||||
|
hour="1,13",
|
||||||
|
minute=0,
|
||||||
|
id="request_telemetry_job",
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
print(f"[Cron] Scheduler started successfully in Worker PID {os.getpid()}")
|
||||||
|
|
||||||
|
# Ensure lock release on shutdown
|
||||||
|
def cleanup():
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||||
|
lock_file.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
atexit.register(cleanup)
|
||||||
|
|
||||||
|
except (IOError, OSError):
|
||||||
|
# Lock acquired by another worker - skip starting scheduler
|
||||||
|
print(f"[Cron] Worker PID {os.getpid()} skipped scheduler (already running in another worker).")
|
||||||
|
|
||||||
|
# Initialize single-instance scheduler
|
||||||
|
start_scheduler_once()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Authentication Middleware
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
EXEMPT_ROUTES = {'hello_world', 'oauth_callback', 'odata_metadata'}
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def authenticate_request():
|
||||||
|
if request.endpoint in EXEMPT_ROUTES or request.method == 'OPTIONS':
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract API Key from Header (X-API-Key or Bearer token)
|
||||||
|
api_key = request.headers.get("X-API-Key")
|
||||||
|
if not api_key:
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
api_key = auth_header.split(" ")[1]
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
return jsonify({
|
||||||
|
"@odata.error": {
|
||||||
|
"code": "401",
|
||||||
|
"message": "Unauthorized: Missing API Key header"
|
||||||
|
}
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
# Validate key against 'devices_authentication' collection
|
||||||
|
device_auth = db.devices_authentication.find_one({"api_key": api_key})
|
||||||
|
if not device_auth:
|
||||||
|
return jsonify({
|
||||||
|
"@odata.error": {
|
||||||
|
"code": "401",
|
||||||
|
"message": "Unauthorized: Invalid API Key"
|
||||||
|
}
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
# Attach device context (works for both Microwaves and Node-RED)
|
||||||
|
g.device_id = device_auth.get("device_id")
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# Application Routes
|
# Application Routes
|
||||||
@@ -133,114 +193,130 @@ def oauth_callback():
|
|||||||
@app.route("/")
|
@app.route("/")
|
||||||
def hello_world():
|
def hello_world():
|
||||||
gen = generate(prompt="Say Hello, to the user !")
|
gen = generate(prompt="Say Hello, to the user !")
|
||||||
print(gen)
|
|
||||||
return f"<p>{gen}</p>"
|
return f"<p>{gen}</p>"
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/oauth/callback")
|
||||||
|
def oauth_callback():
|
||||||
|
code = request.args.get("code")
|
||||||
|
if not code:
|
||||||
|
return jsonify({"error": "Missing code parameter"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
webex_manager.exchange_code(code)
|
||||||
|
return jsonify({"status": "success", "message": "Webex tokens stored successfully!"}), 200
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.exception("Failed to exchange OAuth code")
|
||||||
|
return jsonify({"error": "OAuth exchange failed"}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route("/cooking-params", methods=["POST"])
|
@app.route("/cooking-params", methods=["POST"])
|
||||||
async def cooking_params():
|
async def cooking_params():
|
||||||
try:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
microwave_id = data.get("microwave_id", "unknown")
|
if not data or not isinstance(data, dict):
|
||||||
|
return jsonify({"@odata.error": {"code": "400", "message": "Invalid payload"}}), 400
|
||||||
|
|
||||||
|
data["microwave_id"] = g.device_id
|
||||||
|
|
||||||
|
forwarded_for = request.headers.get('X-Forwarded-For')
|
||||||
|
client_ip = forwarded_for.split(',')[0].strip() if forwarded_for else request.remote_addr
|
||||||
|
|
||||||
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr).split(',')[0].strip()
|
|
||||||
device_ip_collection.insert_one({
|
device_ip_collection.insert_one({
|
||||||
"microwave_id": microwave_id,
|
"microwave_id": g.device_id,
|
||||||
"client_ip": client_ip,
|
"client_ip": client_ip,
|
||||||
"received_at": datetime.now(timezone.utc)
|
"received_at": datetime.now(timezone.utc)
|
||||||
})
|
})
|
||||||
|
|
||||||
if not data:
|
|
||||||
return jsonify({"error": "Invalid or missing JSON payload"}), 400
|
|
||||||
|
|
||||||
# Extract user or device parameters
|
|
||||||
height_cm = float(data.get("dish_height", 4.0))
|
height_cm = float(data.get("dish_height", 4.0))
|
||||||
initial_temp_c = float(data.get("ir_initial_temp", 20.0))
|
initial_temp_c = float(data.get("ir_initial_temp", 20.0))
|
||||||
microwave_wattage = int(data.get("microwave_wattage", 900))
|
microwave_wattage = int(data.get("microwave_wattage", 900))
|
||||||
defrost_mode = bool(data.get("defrost_mode", False))
|
defrost_mode = bool(data.get("defrost_mode", False))
|
||||||
|
|
||||||
# 1. Save Camera Image
|
|
||||||
camera_image_b64 = data.get("camera_image")
|
camera_image_b64 = data.get("camera_image")
|
||||||
if not camera_image_b64:
|
if not camera_image_b64:
|
||||||
return jsonify({"error": "Missing required field 'camera_image'"}), 400
|
return jsonify({"@odata.error": {"code": "400", "message": "Missing camera_image"}}), 400
|
||||||
|
|
||||||
filename = f"dish_{uuid.uuid4().hex}.jpg"
|
filename = f"dish_{uuid.uuid4().hex}.jpg"
|
||||||
filepath = os.path.join(CAMERA_IMAGE_DIR, filename)
|
filepath = os.path.join(CAMERA_IMAGE_DIR, filename)
|
||||||
|
|
||||||
try:
|
with open(filepath, "wb") as f:
|
||||||
with open(filepath, "wb") as f:
|
f.write(base64.b64decode(camera_image_b64))
|
||||||
f.write(base64.b64decode(camera_image_b64))
|
data["camera_image"] = filepath
|
||||||
data["camera_image"] = filepath
|
|
||||||
except Exception as e:
|
|
||||||
current_app.logger.exception("Failed to save camera image")
|
|
||||||
return jsonify({"error": f"Failed to save camera image: {str(e)}"}), 500
|
|
||||||
|
|
||||||
# 2. Run Vision Safety Check & Cook Planner Concurrently
|
# Concurrent AI Execution
|
||||||
try:
|
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, filepath)
|
||||||
if getattr(config, "DEBUG_DANGEROUS_AREA", False):
|
planner_task = asyncio.to_thread(
|
||||||
print(f"[Debug] Running safety check on predefined image")
|
microwave_cook_planner.generate_plan,
|
||||||
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, os.path.join(CAMERA_IMAGE_DIR, "dish_0de0ee1dab8949fc8e78796947e24ed3.jpg"))
|
image_path=filepath,
|
||||||
elif getattr(config, "DEBUG", False):
|
height_cm=height_cm,
|
||||||
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, os.path.join("microwaveDish.jpg"))
|
initial_temp_c=initial_temp_c,
|
||||||
else:
|
microwave_wattage=microwave_wattage,
|
||||||
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, filepath)
|
defrost_mode=defrost_mode
|
||||||
planner_task = asyncio.to_thread(
|
)
|
||||||
microwave_cook_planner.generate_plan,
|
|
||||||
image_path=filepath,
|
|
||||||
height_cm=height_cm,
|
|
||||||
initial_temp_c=initial_temp_c,
|
|
||||||
microwave_wattage=microwave_wattage,
|
|
||||||
defrost_mode=defrost_mode
|
|
||||||
)
|
|
||||||
|
|
||||||
# Execute both concurrently and await results
|
safety_result, cook_plan = await asyncio.gather(safety_task, planner_task)
|
||||||
safety_result, cook_plan = await asyncio.gather(safety_task, planner_task)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
current_app.logger.exception("Exception during safety check or cook planning")
|
|
||||||
return jsonify({"error": f"Task execution failed: {str(e)}"}), 500
|
|
||||||
|
|
||||||
|
|
||||||
# 4. Attach Cooking Plan & safety check and Save to MongoDB
|
|
||||||
data["safety_check"] = safety_result
|
data["safety_check"] = safety_result
|
||||||
data["analysis_results"] = cook_plan
|
data["analysis_results"] = cook_plan
|
||||||
|
|
||||||
try:
|
inserted = cooking_collection.insert_one(data)
|
||||||
cooking_collection.insert_one(data)
|
doc_id = str(inserted.inserted_id)
|
||||||
data.pop("_id", None)
|
|
||||||
except Exception as e:
|
# Base OData v4 response wrapper
|
||||||
return jsonify({"error": f"Database error: {str(e)}"}), 500
|
odata_response = {
|
||||||
|
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#CookingParams/$entity",
|
||||||
|
"@odata.id": f"{request.host_url.rstrip('/')}/cooking-params('{doc_id}')",
|
||||||
|
"id": doc_id,
|
||||||
|
"plan": cook_plan
|
||||||
|
}
|
||||||
|
|
||||||
# 3. Evaluate Safety Result
|
|
||||||
if not safety_result.get("is_safe", True):
|
if not safety_result.get("is_safe", True):
|
||||||
current_app.logger.warning(f"Unsafe dish detected: {safety_result}")
|
odata_response.update({
|
||||||
return jsonify({
|
|
||||||
"error": "Safety hazard detected in microwave area",
|
|
||||||
"is_safe": False,
|
"is_safe": False,
|
||||||
"warning_message": safety_result.get("warning_message", "Unsafe materials detected."),
|
"warning_message": safety_result.get("warning_message", "Unsafe materials detected."),
|
||||||
"detected_hazards": safety_result.get("detected_hazards", [])
|
"detected_hazards": safety_result.get("detected_hazards", [])
|
||||||
}), 200
|
})
|
||||||
|
return jsonify(odata_response), 200
|
||||||
|
|
||||||
|
return jsonify(odata_response), 201
|
||||||
|
|
||||||
return jsonify(cook_plan), 201
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.exception("Exception in /cooking-params")
|
current_app.logger.exception("Error in /cooking-params")
|
||||||
|
return jsonify({"@odata.error": {"code": "500", "message": "Internal processing error"}}), 500
|
||||||
|
|
||||||
@app.route("/alert", methods=["POST"])
|
@app.route("/alert", methods=["POST"])
|
||||||
def alert():
|
def alert():
|
||||||
data = request.get_json() or {}
|
data = request.get_json()
|
||||||
|
if data is None or not isinstance(data, dict):
|
||||||
# 1. Inject UTC timestamp
|
return jsonify({
|
||||||
|
"@odata.error": {
|
||||||
|
"code": "400",
|
||||||
|
"message": "Invalid or missing JSON payload"
|
||||||
|
}
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Auto-populate or override orchestrator_id using authenticated device context
|
||||||
|
orchestrator_id = data.get("orchestrator_id") or getattr(g, "device_id", None)
|
||||||
|
data["orchestrator_id"] = orchestrator_id
|
||||||
data["created_at"] = datetime.now(timezone.utc)
|
data["created_at"] = datetime.now(timezone.utc)
|
||||||
saved_alert = alert_collection.insert_one(data)
|
|
||||||
|
try:
|
||||||
orchestrator_id = data.get("orchestrator_id")
|
saved_alert = alert_collection.insert_one(data)
|
||||||
|
doc_id = str(saved_alert.inserted_id)
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.exception("Failed to insert alert into database")
|
||||||
|
return jsonify({
|
||||||
|
"@odata.error": {
|
||||||
|
"code": "500",
|
||||||
|
"message": "Database write failed"
|
||||||
|
}
|
||||||
|
}), 500
|
||||||
|
|
||||||
alert_info = data.get("alert", {})
|
alert_info = data.get("alert", {})
|
||||||
alert_type = alert_info.get("type", "Unknown")
|
alert_type = alert_info.get("type", "Unknown")
|
||||||
alert_message = alert_info.get("message", "No message provided")
|
alert_message = alert_info.get("message", "No message provided")
|
||||||
|
|
||||||
# --- GET CLIENT METADATA ---
|
|
||||||
client_name = f"Unknown Client (Orchestrator {orchestrator_id})" if orchestrator_id else "Unknown Client"
|
client_name = f"Unknown Client (Orchestrator {orchestrator_id})" if orchestrator_id else "Unknown Client"
|
||||||
client_email = None
|
client_email = None
|
||||||
client_phone = None
|
client_phone = None
|
||||||
@@ -259,14 +335,12 @@ def alert():
|
|||||||
client_name = client_doc.get("client_name", client_name)
|
client_name = client_doc.get("client_name", client_name)
|
||||||
contact_info = client_doc.get("contact_info", {})
|
contact_info = client_doc.get("contact_info", {})
|
||||||
client_email = contact_info.get("email")
|
client_email = contact_info.get("email")
|
||||||
client_phone = contact_info.get("phone") # Extract phone number for Twilio
|
client_phone = contact_info.get("phone")
|
||||||
|
|
||||||
# --- WEBEX ROOM LOGIC ---
|
|
||||||
webex_room_id = None
|
webex_room_id = None
|
||||||
room_title = f"Support - {client_name}"
|
room_title = f"Support - {client_name}"
|
||||||
room_link = None
|
room_link = None
|
||||||
webex_status = "skipped"
|
webex_status = "skipped"
|
||||||
# --- TWILIO SMS LOGIC ---
|
|
||||||
sms_sent = False
|
sms_sent = False
|
||||||
|
|
||||||
three_minutes_ago = datetime.now(timezone.utc) - timedelta(minutes=3)
|
three_minutes_ago = datetime.now(timezone.utc) - timedelta(minutes=3)
|
||||||
@@ -276,7 +350,7 @@ def alert():
|
|||||||
"webex_room_id": {"$exists": True, "$ne": None},
|
"webex_room_id": {"$exists": True, "$ne": None},
|
||||||
"created_at": {"$gte": three_minutes_ago},
|
"created_at": {"$gte": three_minutes_ago},
|
||||||
"_id": {"$ne": saved_alert.inserted_id}
|
"_id": {"$ne": saved_alert.inserted_id}
|
||||||
}, sort=[("created_at", pymongo.DESCENDING)])
|
}, sort=[("created_at", DESCENDING)])
|
||||||
|
|
||||||
if recent_alert:
|
if recent_alert:
|
||||||
webex_room_id = recent_alert["webex_room_id"]
|
webex_room_id = recent_alert["webex_room_id"]
|
||||||
@@ -286,15 +360,14 @@ def alert():
|
|||||||
room_id=webex_room_id,
|
room_id=webex_room_id,
|
||||||
alert_type=alert_type,
|
alert_type=alert_type,
|
||||||
alert_message=alert_message,
|
alert_message=alert_message,
|
||||||
alert_id=str(saved_alert.inserted_id),
|
alert_id=doc_id,
|
||||||
added_alert_info=True
|
added_alert_info=True
|
||||||
)
|
)
|
||||||
webex_status = "reused_room"
|
webex_status = "reused_room"
|
||||||
room_link = f"https://web.webex.com/spaces/{webex_room_id}"
|
room_link = recent_alert.get("webex_room_link") or f"https://web.webex.com/spaces/{webex_room_id}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.exception("Failed to send follow-up message to existing Webex room")
|
current_app.logger.exception("Failed to send follow-up message to Webex room")
|
||||||
webex_status = f"message_failed: {str(e)}"
|
webex_status = f"message_failed: {str(e)}"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
room_details = webex_manager.create_support_room(
|
room_details = webex_manager.create_support_room(
|
||||||
@@ -302,25 +375,22 @@ def alert():
|
|||||||
client_email,
|
client_email,
|
||||||
alert_type,
|
alert_type,
|
||||||
alert_message,
|
alert_message,
|
||||||
str(saved_alert.inserted_id)
|
doc_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(room_details, dict):
|
if isinstance(room_details, dict):
|
||||||
webex_room_id = room_details.get("id")
|
webex_room_id = room_details.get("id")
|
||||||
room_title = room_details.get("title", room_title)
|
room_title = room_details.get("title", room_title)
|
||||||
# Set room_link if your webex_manager returns meetingLink or custom URL
|
|
||||||
room_link = room_details.get("meetingLink") or f"https://web.webex.com/spaces/{webex_room_id}"
|
room_link = room_details.get("meetingLink") or f"https://web.webex.com/spaces/{webex_room_id}"
|
||||||
else:
|
else:
|
||||||
webex_room_id = room_details
|
webex_room_id = room_details
|
||||||
room_link = f"https://web.webex.com/spaces/{webex_room_id}"
|
room_link = f"https://web.webex.com/spaces/{webex_room_id}"
|
||||||
|
|
||||||
webex_status = "created_new_room"
|
webex_status = "created_new_room"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.exception("Failed to create Webex support room")
|
current_app.logger.exception("Failed to create Webex support room")
|
||||||
webex_status = f"creation_failed: {str(e)}"
|
webex_status = f"creation_failed: {str(e)}"
|
||||||
|
|
||||||
# --- SEND SMS ---
|
|
||||||
if webex_room_id and client_phone and not SAVEUP_TWILIO_API_TOKEN:
|
if webex_room_id and client_phone and not SAVEUP_TWILIO_API_TOKEN:
|
||||||
sms_sent = send_alert_sms(
|
sms_sent = send_alert_sms(
|
||||||
to_phone=client_phone,
|
to_phone=client_phone,
|
||||||
@@ -328,72 +398,92 @@ def alert():
|
|||||||
room_link=room_link,
|
room_link=room_link,
|
||||||
client_email=client_email
|
client_email=client_email
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update document with room and sms details
|
|
||||||
if webex_room_id:
|
|
||||||
alert_collection.update_one(
|
|
||||||
{"_id": saved_alert.inserted_id},
|
|
||||||
{"$set": {"webex_room_id": webex_room_id, "webex_room_title": room_title, "webex_room_link": room_link, "sms_sent": sms_sent}}
|
|
||||||
)
|
|
||||||
else: # Just save the sms
|
|
||||||
alert_collection.update_one(
|
|
||||||
{"_id": saved_alert.inserted_id},
|
|
||||||
{"$set": {"sms_sent": sms_sent}}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
update_fields = {"sms_sent": sms_sent}
|
||||||
|
if webex_room_id:
|
||||||
|
update_fields.update({
|
||||||
|
"webex_room_id": webex_room_id,
|
||||||
|
"webex_room_title": room_title,
|
||||||
|
"webex_room_link": room_link
|
||||||
|
})
|
||||||
|
|
||||||
|
alert_collection.update_one({"_id": saved_alert.inserted_id}, {"$set": update_fields})
|
||||||
|
|
||||||
|
# OData v4 Created Response Payload
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"status": "success",
|
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#Alerts/$entity",
|
||||||
"message": "Alert saved",
|
"@odata.id": f"{request.host_url.rstrip('/')}/alert('{doc_id}')",
|
||||||
|
"id": doc_id,
|
||||||
|
"status": "success",
|
||||||
|
"message": "Alert saved",
|
||||||
|
"orchestrator_id": orchestrator_id,
|
||||||
"webex_room_id": webex_room_id,
|
"webex_room_id": webex_room_id,
|
||||||
"webex_room_url": room_link,
|
"webex_room_url": room_link,
|
||||||
"webex_status": webex_status,
|
"webex_status": webex_status,
|
||||||
"sms_sent": sms_sent
|
"sms_sent": sms_sent
|
||||||
}), 200
|
}), 201
|
||||||
|
|
||||||
|
|
||||||
@app.route("/telemetry", methods=["POST"])
|
@app.route("/telemetry", methods=["POST"])
|
||||||
def telemetry():
|
def telemetry():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
if data is None:
|
if data is None:
|
||||||
return jsonify({"error": "Invalid or missing JSON payload"}), 400
|
return jsonify({"@odata.error": {"code": "400", "message": "Invalid JSON payload"}}), 400
|
||||||
|
|
||||||
# If the payload was double-encoded as a string, deserialize it
|
|
||||||
if isinstance(data, str):
|
if isinstance(data, str):
|
||||||
try:
|
try:
|
||||||
data = json.loads(data)
|
data = json.loads(data)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
return jsonify({"error": "String payload could not be parsed as JSON"}), 400
|
return jsonify({"@odata.error": {"code": "400", "message": "String payload invalid"}}), 400
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
data["device_id"] = g.device_id
|
||||||
return jsonify({"error": "Expected a JSON object/dictionary"}), 400
|
|
||||||
|
|
||||||
# Stamp UTC timestamp for Node-RED queries
|
|
||||||
data["received_at"] = datetime.now(timezone.utc).isoformat()
|
data["received_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
telemetry_collection.insert_one(data)
|
inserted = telemetry_collection.insert_one(data)
|
||||||
return jsonify({"status": "success", "message": "Telemetry saved"}), 200
|
doc_id = str(inserted.inserted_id)
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": f"Database error: {str(e)}"}), 500
|
|
||||||
|
|
||||||
# -------------------------------------------------------------
|
# OData v4 Created Response Envelope
|
||||||
# Server CVE & SSL Audit Endpoint
|
return jsonify({
|
||||||
# -------------------------------------------------------------
|
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#Telemetry/$entity",
|
||||||
@app.route('/api/security/cve-audit', methods=['GET'])
|
"@odata.id": f"{request.host_url.rstrip('/')}/telemetry('{doc_id}')",
|
||||||
|
"id": doc_id,
|
||||||
|
"status": "success",
|
||||||
|
"message": "Telemetry saved",
|
||||||
|
"device_id": g.device_id,
|
||||||
|
"received_at": data["received_at"]
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.exception("Database write failed")
|
||||||
|
return jsonify({"@odata.error": {"code": "500", "message": "Database write failed"}}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/security/cve-audit', methods=['POST', 'GET'])
|
||||||
def run_cve_audit():
|
def run_cve_audit():
|
||||||
"""Runs a vulnerability and SSL audit against this server."""
|
"""Protected endpoint triggered by Node-RED using REST API + API Key."""
|
||||||
audit_results = shodan_auditor.audit_server_vulnerabilities()
|
audit_results = shodan_auditor.audit_server_vulnerabilities()
|
||||||
|
|
||||||
cve_audit_results.insert_one({
|
inserted = cve_audit_results.insert_one({
|
||||||
|
"triggered_by": g.device_id,
|
||||||
"audit_results": audit_results,
|
"audit_results": audit_results,
|
||||||
"timestamp": datetime.now(timezone.utc)
|
"timestamp": datetime.now(timezone.utc)
|
||||||
})
|
})
|
||||||
|
doc_id = str(inserted.inserted_id)
|
||||||
|
|
||||||
status_code = 200 if audit_results["status"] == "PASS" else 409
|
status_code = 200 if audit_results.get("status") == "PASS" else 409
|
||||||
return jsonify(audit_results), status_code
|
|
||||||
|
return jsonify({
|
||||||
|
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#CveAudit/$entity",
|
||||||
|
"@odata.id": f"{request.host_url.rstrip('/')}/api/security/cve-audit('{doc_id}')",
|
||||||
|
"id": doc_id,
|
||||||
|
"status": audit_results.get("status"),
|
||||||
|
"audit_results": audit_results,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||||
|
}), status_code
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Debug Endpoints (Protected)
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
@app.route("/debug/telemetryrequest", methods=["GET"])
|
@app.route("/debug/telemetryrequest", methods=["GET"])
|
||||||
def debug_telemetryrequest():
|
def debug_telemetryrequest():
|
||||||
@@ -413,7 +503,8 @@ def debug_telemetryrequest():
|
|||||||
}), 200
|
}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": f"Failed to publish MQTT command: {str(e)}"}), 500
|
return jsonify({"error": f"Failed to publish MQTT command: {str(e)}"}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/debug/run-job/<job_id>', methods=['POST', 'GET'])
|
@app.route('/debug/run-job/<job_id>', methods=['POST', 'GET'])
|
||||||
def trigger_job_manually(job_id):
|
def trigger_job_manually(job_id):
|
||||||
"""Manually triggers any scheduled job immediately by its ID."""
|
"""Manually triggers any scheduled job immediately by its ID."""
|
||||||
@@ -424,7 +515,6 @@ def trigger_job_manually(job_id):
|
|||||||
"available_jobs": [j.id for j in scheduler.get_jobs()]
|
"available_jobs": [j.id for j in scheduler.get_jobs()]
|
||||||
}), 404
|
}), 404
|
||||||
|
|
||||||
# Execute the underlying function with its configured arguments
|
|
||||||
try:
|
try:
|
||||||
job.func(*job.args)
|
job.func(*job.args)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -436,10 +526,51 @@ def trigger_job_manually(job_id):
|
|||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"Job execution failed: {str(e)}"
|
"message": f"Job execution failed: {str(e)}"
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
@app.route("/debug", methods=["GET"])
|
# ---------------------------------------------------------
|
||||||
def debug():
|
# OData Metadata Definition
|
||||||
return safety_checker.check_dish_safety(os.path.join(CAMERA_IMAGE_DIR, "dish_0de0ee1dab8949fc8e78796947e24ed3.jpg"))
|
# ---------------------------------------------------------
|
||||||
|
@app.route("/$metadata", methods=["GET"])
|
||||||
|
def odata_metadata():
|
||||||
|
"""Provides mandatory OData schema definition for validation."""
|
||||||
|
xml_metadata = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
|
||||||
|
<edmx:DataServices>
|
||||||
|
<Schema Namespace="MicrowaveNetwork" xmlns="http://docs.oasis-open.org/odata/ns/edm">
|
||||||
|
<EntityType Name="Telemetry">
|
||||||
|
<Key><PropertyRef Name="id"/></Key>
|
||||||
|
<Property Name="id" Type="Edm.String" Nullable="false"/>
|
||||||
|
<Property Name="device_id" Type="Edm.String"/>
|
||||||
|
<Property Name="received_at" Type="Edm.DateTimeOffset"/>
|
||||||
|
</EntityType>
|
||||||
|
<EntityType Name="CookingParams">
|
||||||
|
<Key><PropertyRef Name="id"/></Key>
|
||||||
|
<Property Name="id" Type="Edm.String" Nullable="false"/>
|
||||||
|
<Property Name="microwave_id" Type="Edm.String"/>
|
||||||
|
</EntityType>
|
||||||
|
<EntityType Name="CveAudit">
|
||||||
|
<Key><PropertyRef Name="id"/></Key>
|
||||||
|
<Property Name="id" Type="Edm.String" Nullable="false"/>
|
||||||
|
<Property Name="status" Type="Edm.String"/>
|
||||||
|
</EntityType>
|
||||||
|
<EntityType Name="Alert">
|
||||||
|
<Key><PropertyRef Name="id"/></Key>
|
||||||
|
<Property Name="id" Type="Edm.String" Nullable="false"/>
|
||||||
|
<Property Name="orchestrator_id" Type="Edm.String"/>
|
||||||
|
<Property Name="webex_room_id" Type="Edm.String"/>
|
||||||
|
<Property Name="webex_status" Type="Edm.String"/>
|
||||||
|
<Property Name="sms_sent" Type="Edm.Boolean"/>
|
||||||
|
</EntityType>
|
||||||
|
<EntityContainer Name="Container">
|
||||||
|
<EntitySet Name="Telemetry" EntityType="MicrowaveNetwork.Telemetry"/>
|
||||||
|
<EntitySet Name="CookingParams" EntityType="MicrowaveNetwork.CookingParams"/>
|
||||||
|
<EntitySet Name="CveAudit" EntityType="MicrowaveNetwork.CveAudit"/>
|
||||||
|
<EntitySet Name="Alerts" EntityType="MicrowaveNetwork.Alert"/>
|
||||||
|
</EntityContainer>
|
||||||
|
</Schema>
|
||||||
|
</edmx:DataServices>
|
||||||
|
</edmx:Edmx>"""
|
||||||
|
return Response(xml_metadata, mimetype="application/xml")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
def parse_odata_query(request_args):
|
||||||
|
"""
|
||||||
|
Translates standard OData query parameters ($top, $skip, $orderby, $filter)
|
||||||
|
into MongoDB query parameters.
|
||||||
|
"""
|
||||||
|
limit = int(request_args.get('$top', 50))
|
||||||
|
skip = int(request_args.get('$skip', 0))
|
||||||
|
|
||||||
|
# Parse $orderby (e.g., $orderby=received_at desc)
|
||||||
|
sort = []
|
||||||
|
orderby_str = request_args.get('$orderby')
|
||||||
|
if orderby_str:
|
||||||
|
parts = orderby_str.split()
|
||||||
|
field = parts[0]
|
||||||
|
direction = -1 if len(parts) > 1 and parts[1].lower() == 'desc' else 1
|
||||||
|
sort.append((field, direction))
|
||||||
|
|
||||||
|
# Parse basic $filter (e.g., $filter=device_id eq '1')
|
||||||
|
mongo_filter = {}
|
||||||
|
filter_str = request_args.get('$filter')
|
||||||
|
if filter_str and ' eq ' in filter_str:
|
||||||
|
field, val = filter_str.split(' eq ')
|
||||||
|
field = field.strip()
|
||||||
|
val = val.strip().strip("'").strip('"')
|
||||||
|
|
||||||
|
# Cast data types
|
||||||
|
if val.isdigit():
|
||||||
|
val = int(val)
|
||||||
|
elif val.lower() == 'true':
|
||||||
|
val = True
|
||||||
|
elif val.lower() == 'false':
|
||||||
|
val = False
|
||||||
|
|
||||||
|
mongo_filter[field] = val
|
||||||
|
|
||||||
|
return {
|
||||||
|
"filter": mongo_filter,
|
||||||
|
"limit": limit,
|
||||||
|
"skip": skip,
|
||||||
|
"sort": sort or [("_id", -1)]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user