main.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795
  1. #!/usr/bin/env python3
  2. import os
  3. import sys
  4. import logging
  5. import time
  6. import tempfile
  7. import threading
  8. import subprocess
  9. from flask import Flask, jsonify, request
  10. from werkzeug.security import check_password_hash
  11. from werkzeug.serving import WSGIRequestHandler
  12. from functools import wraps
  13. from config import Config
  14. from typing import List, Tuple
  15. import base64
  16. import signal
  17. # Configure logging with custom handler
  18. class NtfyLogHandler(logging.Handler):
  19. """Custom logging handler that sends logs to ntfy health backends"""
  20. def __init__(self, config_obj):
  21. super().__init__()
  22. self.config = config_obj
  23. def emit(self, record):
  24. """Send log record to health backends"""
  25. if hasattr(self.config, "ntfy_backends_health") and self.config.send_all_logs:
  26. try:
  27. log_message = self.format(record)
  28. # Get configured log level or default to WARNING
  29. min_level = getattr(
  30. logging, self.config.log_level.upper(), logging.WARNING
  31. )
  32. if record.levelno >= min_level:
  33. # Format message with appropriate emoji based on log level
  34. emoji = (
  35. "🚨"
  36. if record.levelno >= logging.ERROR
  37. else "⚠️"
  38. if record.levelno >= logging.WARNING
  39. else "ℹ️"
  40. )
  41. title = f"Emergency Access {record.levelname}"
  42. message = f"{emoji} {record.name}: {record.getMessage()}"
  43. send_ntfy_notification(
  44. self.config.ntfy_backends_health, message, title
  45. )
  46. except Exception:
  47. # Don't fail the application if logging notification fails
  48. pass
  49. # Configure logging with secure-file fallback
  50. log_path = "/var/log/emergency-access.log"
  51. try:
  52. os.makedirs(os.path.dirname(log_path), exist_ok=True)
  53. # Ensure the log file exists with restrictive permissions
  54. if not os.path.exists(log_path):
  55. open(log_path, "a").close()
  56. try:
  57. os.chmod(log_path, 0o600)
  58. except Exception:
  59. # Best-effort; if this fails we continue with stream logging
  60. pass
  61. log_handlers = [logging.FileHandler(log_path), logging.StreamHandler()]
  62. except Exception:
  63. # If any issue creating the file (permissions, etc.), fall back to stream-only logging
  64. log_handlers = [logging.StreamHandler()]
  65. logging.basicConfig(
  66. level=logging.INFO,
  67. format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
  68. handlers=log_handlers,
  69. )
  70. logger = logging.getLogger(__name__)
  71. app = Flask(__name__)
  72. # Global config instance - initialized during main execution
  73. config = None
  74. def require_auth(key_config=None, is_health=False):
  75. """Decorator for HTTP Basic Authentication with simple in-memory rate limiting.
  76. This implements a small, best-effort per-(IP,username) rate limiter and lockout
  77. to mitigate brute-force attempts. It is intentionally simple and in-memory:
  78. - MAX_ATTEMPTS: number of failed attempts within WINDOW before lockout
  79. - WINDOW: window (seconds) to count attempts
  80. - LOCKOUT: lockout duration (seconds) after exceeding MAX_ATTEMPTS
  81. Notes:
  82. - Because this is in-memory, it resets on process restart.
  83. - For multi-process deployments, offload rate limiting to the reverse proxy (Caddy)
  84. or a shared store (redis).
  85. - On repeated lockouts a notification is sent (best-effort) to configured health backends
  86. and, when applicable, to the key's backends.
  87. """
  88. # Initialize shared state on the require_auth function object (module-global alternative)
  89. if not hasattr(require_auth, "_state"):
  90. require_auth._state = {
  91. "attempts": {}, # (ip, username) -> list[timestamps]
  92. "blocked": {}, # (ip, username) -> blocked_until_timestamp
  93. "lock": threading.Lock(),
  94. }
  95. MAX_ATTEMPTS = 5
  96. WINDOW = 300 # seconds
  97. LOCKOUT = 900 # seconds
  98. def decorator(f):
  99. @wraps(f)
  100. def decorated_function(*args, **kwargs):
  101. if config is None:
  102. logger.error("Configuration not loaded")
  103. return jsonify({"error": "Server configuration error"}), 500
  104. auth = request.authorization
  105. client_ip = request.remote_addr or "unknown"
  106. # determine username for rate limit key; use placeholder if missing
  107. username_for_key = auth.username if auth and auth.username else "<no_auth>"
  108. key = (client_ip, username_for_key)
  109. now_ts = time.time()
  110. state = require_auth._state
  111. # Check block list
  112. with state["lock"]:
  113. blocked_until = state["blocked"].get(key)
  114. if blocked_until and now_ts < blocked_until:
  115. retry_after = int(blocked_until - now_ts)
  116. logger.warning(
  117. f"Blocked auth attempt from {client_ip} for user '{username_for_key}' (locked until {blocked_until})"
  118. )
  119. # Return 429 Too Many Requests
  120. return (
  121. jsonify(
  122. {
  123. "error": "Too many authentication attempts, temporarily locked"
  124. }
  125. ),
  126. 429,
  127. {"Retry-After": str(retry_after)},
  128. )
  129. if not auth:
  130. # For missing auth, still account for attempts keyed by ip and '<no_auth>'
  131. # Increment attempt count and possibly lock
  132. with state["lock"]:
  133. attempts = state["attempts"].setdefault(key, [])
  134. # prune old timestamps
  135. attempts = [t for t in attempts if now_ts - t <= WINDOW]
  136. attempts.append(now_ts)
  137. state["attempts"][key] = attempts
  138. if len(attempts) > MAX_ATTEMPTS:
  139. # Lock out
  140. state["blocked"][key] = now_ts + LOCKOUT
  141. logger.error(
  142. f"Locking out {client_ip} for missing/invalid auth (no credentials) after repeated attempts"
  143. )
  144. # Try to notify about brute force (best-effort)
  145. try:
  146. if config and hasattr(config, "ntfy_backends_health"):
  147. send_ntfy_notification(
  148. config.ntfy_backends_health,
  149. f"🚨 Brute-force lockout: {client_ip} (no credentials) on server",
  150. "Emergency Access ALERT",
  151. )
  152. except Exception:
  153. pass
  154. return (
  155. jsonify(
  156. {
  157. "error": "Too many authentication attempts, temporarily locked"
  158. }
  159. ),
  160. 429,
  161. {"Retry-After": str(LOCKOUT)},
  162. )
  163. return (
  164. jsonify({"error": "Authentication required"}),
  165. 401,
  166. {"WWW-Authenticate": 'Basic realm="Emergency Access"'},
  167. )
  168. # Determine expected credentials
  169. if is_health:
  170. expected_username = config.health_username
  171. expected_password_hash = config.health_password_hash
  172. auth_type = "health check"
  173. else:
  174. if key_config is None:
  175. logger.error("Key configuration not provided for authentication")
  176. return jsonify({"error": "Server configuration error"}), 500
  177. expected_username = key_config.username
  178. expected_password_hash = key_config.password_hash
  179. auth_type = f"key '{key_config.key_id}'"
  180. # Verify credentials
  181. credential_ok = (
  182. auth.username == expected_username
  183. and Config.verify_password(auth.password, expected_password_hash)
  184. )
  185. if not credential_ok:
  186. # record failed attempt and check for lockout
  187. with state["lock"]:
  188. attempts = state["attempts"].setdefault(key, [])
  189. attempts = [t for t in attempts if now_ts - t <= WINDOW]
  190. attempts.append(now_ts)
  191. state["attempts"][key] = attempts
  192. if len(attempts) > MAX_ATTEMPTS:
  193. state["blocked"][key] = now_ts + LOCKOUT
  194. logger.error(
  195. f"Locking out {client_ip} for user '{username_for_key}' after repeated failed attempts"
  196. )
  197. # Notify about brute-force lockout (best-effort)
  198. try:
  199. notify_backends = []
  200. if config and hasattr(config, "ntfy_backends_health"):
  201. notify_backends.extend(config.ntfy_backends_health)
  202. # Include key-specific backends when available
  203. if key_config and getattr(key_config, "backends", None):
  204. notify_backends.extend(key_config.backends)
  205. if notify_backends:
  206. send_ntfy_notification(
  207. list(dict.fromkeys(notify_backends)), # deduplicate
  208. f"🚨 Brute-force lockout: {client_ip} user='{username_for_key}' on {auth_type}",
  209. "Emergency Access ALERT",
  210. )
  211. except Exception:
  212. pass
  213. return (
  214. jsonify(
  215. {
  216. "error": "Too many authentication attempts, temporarily locked"
  217. }
  218. ),
  219. 429,
  220. {"Retry-After": str(LOCKOUT)},
  221. )
  222. logger.warning(
  223. f"Authentication failed for {auth_type}: invalid credentials from {client_ip}"
  224. )
  225. return (
  226. jsonify({"error": "Invalid credentials"}),
  227. 401,
  228. {"WWW-Authenticate": 'Basic realm="Emergency Access"'},
  229. )
  230. # On success, clear recorded failed attempts for this key
  231. with state["lock"]:
  232. if key in state["attempts"]:
  233. try:
  234. del state["attempts"][key]
  235. except KeyError:
  236. pass
  237. if key in state["blocked"]:
  238. try:
  239. del state["blocked"][key]
  240. except KeyError:
  241. pass
  242. logger.info(f"Authentication successful for {auth_type}")
  243. return f(*args, **kwargs)
  244. return decorated_function
  245. return decorator
  246. def send_ntfy_notification(
  247. backends: List[str], message: str, title: str = None
  248. ) -> Tuple[bool, List[str]]:
  249. """
  250. Send notification using dschep/ntfy with dedicated config file
  251. Returns: (success, successful_backends)
  252. """
  253. successful_backends = []
  254. max_retries = 3
  255. retry_delay = 1
  256. for backend in backends:
  257. success = False
  258. last_error = None
  259. for attempt in range(max_retries):
  260. try:
  261. # Import ntfy here to avoid import issues during startup
  262. try:
  263. import ntfy
  264. from ntfy.config import load_config
  265. except ImportError:
  266. raise Exception(
  267. "ntfy package not available. Please install with: pip install ntfy"
  268. )
  269. if config is None:
  270. raise Exception("Configuration not loaded")
  271. # Load the ntfy config file
  272. ntfy_config = load_config(config.ntfy_config_path)
  273. ntfy_config["backends"] = [backend]
  274. # Add timeout to prevent hanging using threading
  275. result = [None]
  276. exception = [None]
  277. def notify_with_timeout():
  278. try:
  279. # Send notification using the backend name from our config file
  280. if title:
  281. result[0] = ntfy.notify(
  282. message, title=title, config=ntfy_config
  283. )
  284. else:
  285. result[0] = ntfy.notify(
  286. message, config=ntfy_config, title="Note"
  287. )
  288. except Exception as e:
  289. exception[0] = e
  290. thread = threading.Thread(target=notify_with_timeout)
  291. thread.daemon = True
  292. thread.start()
  293. thread.join(timeout=15) # 15 second timeout
  294. if thread.is_alive():
  295. raise Exception("Notification timeout")
  296. if exception[0]:
  297. raise exception[0]
  298. ret = result[0]
  299. if ret == 0:
  300. successful_backends.append(backend)
  301. logger.info(f"Notification sent successfully via {backend}")
  302. success = True
  303. break
  304. else:
  305. raise Exception(f"ntfy returned error code {ret}")
  306. except ImportError as e:
  307. logger.error(f"ntfy package not available for backend {backend}: {e}")
  308. last_error = e
  309. break # Don't retry import errors
  310. except Exception as e:
  311. last_error = e
  312. if attempt < max_retries - 1:
  313. logger.warning(
  314. f"Notification attempt {attempt + 1} failed for {backend}: {str(e)}, retrying..."
  315. )
  316. time.sleep(retry_delay * (attempt + 1)) # Exponential backoff
  317. else:
  318. logger.error(
  319. f"All notification attempts failed for {backend}: {str(e)}"
  320. )
  321. if not success and last_error:
  322. # Don't raise exception - just log failure and continue with other backends
  323. logger.error(
  324. f"Failed to send notification to {backend} after {max_retries} attempts: {str(last_error)}"
  325. )
  326. success = len(successful_backends) > 0
  327. return success, successful_backends
  328. def read_file_safely(file_path: str) -> Tuple[bool, str]:
  329. """
  330. Safely read file content
  331. Returns: (success, content)
  332. """
  333. try:
  334. if not os.path.exists(file_path):
  335. logger.error(f"File not found: {file_path}")
  336. return False, f"File not found: {file_path}"
  337. with open(file_path, "r") as f:
  338. content = f.read().strip()
  339. if not content:
  340. logger.error(f"File is empty: {file_path}")
  341. return False, f"File is empty: {file_path}"
  342. return True, content
  343. except PermissionError:
  344. logger.error(f"Permission denied reading file: {file_path}")
  345. return False, f"Permission denied: {file_path}"
  346. except Exception as e:
  347. logger.error(f"Failed to read file {file_path}: {str(e)}")
  348. return False, f"Failed to read file: {str(e)}"
  349. def create_key_handler(key_config):
  350. """Create a key access handler for a specific key configuration"""
  351. @require_auth(key_config=key_config)
  352. def get_key_part():
  353. """Emergency key access endpoint"""
  354. if key_config is None:
  355. logger.error("Key configuration is None in get_key_part")
  356. return jsonify({"error": "Server configuration error"}), 500
  357. logger.warning(
  358. f"EMERGENCY: Key access attempt detected for key '{key_config.key_id}'"
  359. )
  360. try:
  361. # Send notification first - fail-safe approach
  362. notification_success, successful_backends = send_ntfy_notification(
  363. key_config.backends, key_config.message, "EMERGENCY ACCESS ALERT"
  364. )
  365. if not notification_success:
  366. logger.error(
  367. f"CRITICAL: Failed to send notifications to any backend for key '{key_config.key_id}'"
  368. )
  369. return jsonify(
  370. {
  371. "error": "Notification system failure",
  372. "message": "Access denied for security reasons",
  373. }
  374. ), 500
  375. logger.info(
  376. f"Notifications sent successfully to: {successful_backends} for key '{key_config.key_id}'"
  377. )
  378. # Read key file
  379. file_success, content = read_file_safely(key_config.file_path)
  380. if not file_success:
  381. logger.error(
  382. f"CRITICAL: Failed to read key file for '{key_config.key_id}': {content}"
  383. )
  384. return jsonify(
  385. {
  386. "error": "File access failure",
  387. "message": "Unable to retrieve key part",
  388. }
  389. ), 500
  390. logger.warning(
  391. f"EMERGENCY: Key part successfully retrieved and sent for key '{key_config.key_id}'"
  392. )
  393. resp = jsonify(
  394. {
  395. "success": True,
  396. "key_id": key_config.key_id,
  397. "key_part": content,
  398. "timestamp": time.time(),
  399. "notified_backends": successful_backends,
  400. }
  401. )
  402. # Ensure responses with secret material are never cached by clients or intermediaries
  403. resp.headers["Cache-Control"] = (
  404. "no-store, no-cache, must-revalidate, private"
  405. )
  406. resp.headers["Pragma"] = "no-cache"
  407. resp.headers["Expires"] = "0"
  408. # Additional defensive headers
  409. resp.headers["X-Content-Type-Options"] = "nosniff"
  410. resp.headers["Content-Security-Policy"] = (
  411. "default-src 'none'; frame-ancestors 'none'; sandbox"
  412. )
  413. return resp
  414. except Exception as e:
  415. logger.error(
  416. f"CRITICAL: Unexpected error in key access for '{key_config.key_id}': {str(e)}"
  417. )
  418. return jsonify(
  419. {"error": "System error", "message": "Internal server error"}
  420. ), 500
  421. return get_key_part
  422. @require_auth(is_health=True)
  423. def health_check():
  424. """Health check endpoint that verifies both health monitoring and all key request functionality"""
  425. logger.info("Health check requested")
  426. if config is None:
  427. logger.error("Configuration not loaded during health check")
  428. return jsonify({"status": "error", "message": "Configuration not loaded"}), 500
  429. try:
  430. # Test health notification system
  431. health_notification_success, health_backends = send_ntfy_notification(
  432. config.ntfy_backends_health, config.ntfy_health_message, "Health Check"
  433. )
  434. # Test dummy file access
  435. dummy_file_success, dummy_content = read_file_safely(config.dummy_file_path)
  436. # Test all key files access (without exposing content)
  437. key_files_status = {}
  438. all_key_files_ok = True
  439. for key_id, key_config in config.keys.items():
  440. key_file_success, key_content = read_file_safely(key_config.file_path)
  441. key_files_status[key_id] = key_file_success
  442. if not key_file_success:
  443. all_key_files_ok = False
  444. # Test all key notification backends
  445. key_backends_status = {}
  446. all_key_backends_ok = True
  447. for key_id, key_config in config.keys.items():
  448. try:
  449. # Test notification without actually sending
  450. backend_test_success = len(key_config.backends) > 0
  451. key_backends_status[key_id] = {
  452. "backends": key_config.backends,
  453. "success": backend_test_success,
  454. }
  455. if not backend_test_success:
  456. all_key_backends_ok = False
  457. except Exception as e:
  458. key_backends_status[key_id] = {
  459. "backends": key_config.backends,
  460. "success": False,
  461. "error": str(e),
  462. }
  463. all_key_backends_ok = False
  464. # Determine overall health status
  465. all_systems_ok = (
  466. health_notification_success
  467. and dummy_file_success
  468. and all_key_files_ok
  469. and all_key_backends_ok
  470. )
  471. if not all_systems_ok:
  472. error_details = []
  473. if not health_notification_success:
  474. error_details.append("health notifications failed")
  475. if not dummy_file_success:
  476. error_details.append(f"dummy file access failed: {dummy_content}")
  477. # Add key-specific errors
  478. for key_id, status in key_files_status.items():
  479. if not status:
  480. error_details.append(f"key file access failed for '{key_id}'")
  481. for key_id, status in key_backends_status.items():
  482. if not status["success"]:
  483. error_msg = f"key backends failed for '{key_id}'"
  484. if "error" in status:
  485. error_msg += f": {status['error']}"
  486. error_details.append(error_msg)
  487. logger.error(f"Health check failed: {', '.join(error_details)}")
  488. return jsonify(
  489. {
  490. "status": "error",
  491. "message": "System components failed",
  492. "details": error_details,
  493. "health_notifications": health_notification_success,
  494. "dummy_file_access": dummy_file_success,
  495. "key_files_status": key_files_status,
  496. "key_backends_status": key_backends_status,
  497. }
  498. ), 500
  499. logger.info("Health check completed successfully - all systems operational")
  500. return jsonify(
  501. {
  502. "status": "ok",
  503. "timestamp": time.time(),
  504. "health_backends_notified": health_backends,
  505. "dummy_content_length": len(dummy_content),
  506. "keys_accessible": len(key_files_status),
  507. "key_files_status": key_files_status,
  508. "key_backends_status": key_backends_status,
  509. "emergency_system_ready": True,
  510. }
  511. )
  512. except Exception as e:
  513. logger.error(f"Health check error: {str(e)}")
  514. return jsonify(
  515. {"status": "error", "message": "System error", "error": str(e)}
  516. ), 500
  517. @app.errorhandler(404)
  518. def not_found(error):
  519. """Handle 404 errors silently for security"""
  520. logger.info(f"404 attempt: {error}")
  521. return jsonify({"error": "Not found"}), 404
  522. @app.errorhandler(500)
  523. def internal_error(error):
  524. """Handle internal server errors"""
  525. logger.error(f"Internal server error: {error}")
  526. return jsonify({"error": "Internal server error"}), 500
  527. def validate_setup():
  528. """Validate system setup before starting"""
  529. logger.info("Validating system setup...")
  530. if config is None:
  531. logger.error("Configuration not loaded")
  532. return False
  533. # Check dummy file exists
  534. if not os.path.exists(config.dummy_file_path):
  535. logger.error(f"Dummy file not found: {config.dummy_file_path}")
  536. return False
  537. # Test dummy file permissions
  538. try:
  539. with open(config.dummy_file_path, "r") as f:
  540. f.read(1)
  541. except Exception as e:
  542. logger.error(f"Dummy file permission test failed: {str(e)}")
  543. return False
  544. # Validate all key configurations
  545. for key_id, key_config in config.keys.items():
  546. logger.info(f"Validating key '{key_id}'...")
  547. # Check key file exists
  548. if not os.path.exists(key_config.file_path):
  549. logger.error(f"Key file not found for '{key_id}': {key_config.file_path}")
  550. return False
  551. # Test key file permissions
  552. try:
  553. with open(key_config.file_path, "r") as f:
  554. f.read(1)
  555. except Exception as e:
  556. logger.error(f"Key file permission test failed for '{key_id}': {str(e)}")
  557. return False
  558. # Validate backends are configured
  559. if not key_config.backends:
  560. logger.error(f"No notification backends configured for key '{key_id}'")
  561. return False
  562. # Test notification system
  563. logger.info("Testing notification system...")
  564. try:
  565. # Test health notification system
  566. health_success, _ = send_ntfy_notification(
  567. config.ntfy_backends_health[:1], # Test only first backend
  568. "System startup test - health notifications",
  569. "Emergency Access Startup Test",
  570. )
  571. if not health_success:
  572. logger.error("Health notification system test failed")
  573. return False
  574. # Test each key's notification backends
  575. for key_id, key_config in config.keys.items():
  576. key_success, _ = send_ntfy_notification(
  577. key_config.backends[:1], # Test only first backend
  578. f"System startup test - key '{key_id}' notifications",
  579. "Emergency Access Startup Test",
  580. )
  581. if not key_success:
  582. logger.error(f"Key notification system test failed for '{key_id}'")
  583. return False
  584. except Exception as e:
  585. logger.warning(f"Notification test failed, but continuing: {str(e)}")
  586. logger.info("System validation completed successfully")
  587. return True
  588. class ProductionRequestHandler(WSGIRequestHandler):
  589. """Custom request handler with improved error handling"""
  590. def log_error(self, format, *args):
  591. """Override to use our logger"""
  592. logger.error(f"HTTP Error: {format % args}")
  593. def log_message(self, format, *args):
  594. """Override to use our logger"""
  595. logger.info(f"HTTP: {format % args}")
  596. def main():
  597. global config
  598. try:
  599. # Load configuration
  600. config = Config()
  601. logger.info("Configuration loaded successfully")
  602. # Add ntfy log handler after config is loaded
  603. if config.send_all_logs:
  604. ntfy_handler = NtfyLogHandler(config)
  605. min_level = getattr(logging, config.log_level.upper(), logging.WARNING)
  606. ntfy_handler.setLevel(min_level)
  607. # Add to root logger to catch all application logs
  608. logging.getLogger().addHandler(ntfy_handler)
  609. # Validate system setup
  610. if not validate_setup():
  611. logger.error("System validation failed, exiting")
  612. sys.exit(1)
  613. # Add Flask routes dynamically for each key
  614. for key_id, key_config in config.keys.items():
  615. handler = create_key_handler(key_config)
  616. endpoint_name = f"get_key_part_{key_id}"
  617. app.add_url_rule(key_config.route, endpoint_name, handler, methods=["GET"])
  618. logger.info(
  619. f"Registered authenticated key route for '{key_id}': {key_config.route}"
  620. )
  621. # Add health check route
  622. app.add_url_rule(
  623. config.health_route, "health_check", health_check, methods=["GET"]
  624. )
  625. logger.info(
  626. f"Starting emergency access server on {config.server_host}:{config.server_port}"
  627. )
  628. logger.info(f"Health route: {config.health_route}")
  629. logger.info(f"Configured {len(config.keys)} key(s)")
  630. # Use production-ready server with better error handling
  631. try:
  632. from waitress import serve
  633. logger.info("Using Waitress WSGI server for production")
  634. serve(
  635. app,
  636. host=config.server_host,
  637. port=config.server_port,
  638. threads=6,
  639. connection_limit=100,
  640. cleanup_interval=30,
  641. channel_timeout=120,
  642. )
  643. except ImportError:
  644. logger.critical(
  645. "Waitress WSGI server not installed. This application must run under a production WSGI server such as 'waitress' or 'gunicorn'. Aborting startup."
  646. )
  647. # Attempt to notify configured health backends about startup failure (best-effort)
  648. try:
  649. if config and hasattr(config, "ntfy_backends_health"):
  650. send_ntfy_notification(
  651. config.ntfy_backends_health,
  652. "🚨 Emergency Access server failed to start: waitress not installed",
  653. "Emergency Access CRITICAL",
  654. )
  655. except Exception:
  656. # Do not raise further - we are already aborting startup
  657. pass
  658. sys.exit(1)
  659. except KeyboardInterrupt:
  660. logger.info("Received keyboard interrupt, shutting down gracefully")
  661. except Exception as e:
  662. logger.error(f"Failed to start server: {str(e)}")
  663. if config and hasattr(config, "ntfy_backends_health"):
  664. try:
  665. send_ntfy_notification(
  666. config.ntfy_backends_health,
  667. f"🚨 Emergency Access server failed to start: {str(e)}",
  668. "Emergency Access CRITICAL",
  669. )
  670. except Exception:
  671. pass
  672. sys.exit(1)
  673. finally:
  674. logger.info("Emergency Access server shutdown complete")
  675. if __name__ == "__main__":
  676. main()