main.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. #!/usr/bin/env python3
  2. import os
  3. import sys
  4. import logging
  5. import time
  6. import tempfile
  7. from flask import Flask, jsonify, request
  8. from werkzeug.security import check_password_hash
  9. from werkzeug.serving import WSGIRequestHandler
  10. from functools import wraps
  11. from config import Config
  12. from typing import List, Tuple
  13. import base64
  14. import signal
  15. # Configure logging with custom handler
  16. class NtfyLogHandler(logging.Handler):
  17. """Custom logging handler that sends logs to ntfy health backends"""
  18. def __init__(self, config_obj):
  19. super().__init__()
  20. self.config = config_obj
  21. def emit(self, record):
  22. """Send log record to health backends"""
  23. if hasattr(self.config, 'ntfy_backends_health') and self.config.send_all_logs:
  24. try:
  25. log_message = self.format(record)
  26. # Get configured log level or default to WARNING
  27. min_level = getattr(logging, self.config.log_level.upper(), logging.WARNING)
  28. if record.levelno >= min_level:
  29. # Format message with appropriate emoji based on log level
  30. emoji = "🚨" if record.levelno >= logging.ERROR else "⚠️" if record.levelno >= logging.WARNING else "ℹ️"
  31. title = f"Emergency Access {record.levelname}"
  32. message = f"{emoji} {record.name}: {record.getMessage()}"
  33. send_ntfy_notification(
  34. self.config.ntfy_backends_health,
  35. message,
  36. title
  37. )
  38. except Exception:
  39. # Don't fail the application if logging notification fails
  40. pass
  41. # Configure logging
  42. logging.basicConfig(
  43. level=logging.INFO,
  44. format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
  45. handlers=[
  46. logging.FileHandler('/var/log/emergency-access.log'),
  47. logging.StreamHandler()
  48. ]
  49. )
  50. logger = logging.getLogger(__name__)
  51. app = Flask(__name__)
  52. # Global config instance will be initialized in main
  53. config = None
  54. def require_auth(key_config=None, is_health=False):
  55. """Decorator for HTTP Basic Authentication"""
  56. def decorator(f):
  57. @wraps(f)
  58. def decorated_function(*args, **kwargs):
  59. auth = request.authorization
  60. if not auth:
  61. return jsonify({'error': 'Authentication required'}), 401, {
  62. 'WWW-Authenticate': 'Basic realm="Emergency Access"'
  63. }
  64. # Determine expected credentials
  65. if is_health:
  66. expected_username = config.health_username
  67. expected_password_hash = config.health_password_hash
  68. auth_type = "health check"
  69. else:
  70. expected_username = key_config.username
  71. expected_password_hash = key_config.password_hash
  72. auth_type = f"key '{key_config.key_id}'"
  73. # Verify credentials
  74. if (auth.username != expected_username or
  75. not Config.verify_password(auth.password, expected_password_hash)):
  76. logger.warning(f"Authentication failed for {auth_type}: invalid credentials from {request.remote_addr}")
  77. return jsonify({'error': 'Invalid credentials'}), 401, {
  78. 'WWW-Authenticate': 'Basic realm="Emergency Access"'
  79. }
  80. logger.info(f"Authentication successful for {auth_type}")
  81. return f(*args, **kwargs)
  82. return decorated_function
  83. return decorator
  84. def send_ntfy_notification(backends: List[str], message: str, title: str = None) -> Tuple[bool, List[str]]:
  85. """
  86. Send notification using dschep/ntfy with dedicated config file
  87. Returns: (success, successful_backends)
  88. """
  89. successful_backends = []
  90. max_retries = 3
  91. retry_delay = 1
  92. for backend in backends:
  93. success = False
  94. last_error = None
  95. for attempt in range(max_retries):
  96. try:
  97. # Import ntfy here to avoid import issues during startup
  98. try:
  99. import ntfy
  100. from ntfy.config import load_config
  101. except ImportError:
  102. raise Exception("ntfy package not available. Please install with: pip install ntfy")
  103. # Load the ntfy config file
  104. ntfy_config = load_config(config.ntfy_config_path)
  105. ntfy_config["backends"] = [backend]
  106. # Add timeout to prevent hanging using threading
  107. import threading
  108. result = [None]
  109. exception = [None]
  110. def notify_with_timeout():
  111. try:
  112. # Send notification using the backend name from our config file
  113. if title:
  114. result[0] = ntfy.notify(message, title=title, config=ntfy_config)
  115. else:
  116. result[0] = ntfy.notify(message, config=ntfy_config, title="Note")
  117. except Exception as e:
  118. exception[0] = e
  119. thread = threading.Thread(target=notify_with_timeout)
  120. thread.daemon = True
  121. thread.start()
  122. thread.join(timeout=15) # 15 second timeout
  123. if thread.is_alive():
  124. raise Exception("Notification timeout")
  125. if exception[0]:
  126. raise exception[0]
  127. ret = result[0]
  128. if ret == 0:
  129. successful_backends.append(backend)
  130. logger.info(f"Notification sent successfully via {backend}")
  131. success = True
  132. break
  133. else:
  134. raise Exception(f"ntfy returned error code {ret}")
  135. except ImportError as e:
  136. logger.error(f"ntfy package not available for backend {backend}: {e}")
  137. last_error = e
  138. break # Don't retry import errors
  139. except Exception as e:
  140. last_error = e
  141. if attempt < max_retries - 1:
  142. logger.warning(f"Notification attempt {attempt + 1} failed for {backend}: {str(e)}, retrying...")
  143. time.sleep(retry_delay * (attempt + 1)) # Exponential backoff
  144. else:
  145. logger.error(f"All notification attempts failed for {backend}: {str(e)}")
  146. if not success and last_error:
  147. # Don't raise exception - just log failure and continue with other backends
  148. logger.error(f"Failed to send notification to {backend} after {max_retries} attempts: {str(last_error)}")
  149. success = len(successful_backends) > 0
  150. return success, successful_backends
  151. def read_file_safely(file_path: str) -> Tuple[bool, str]:
  152. """
  153. Safely read file content
  154. Returns: (success, content)
  155. """
  156. try:
  157. if not os.path.exists(file_path):
  158. logger.error(f"File not found: {file_path}")
  159. return False, f"File not found: {file_path}"
  160. with open(file_path, 'r') as f:
  161. content = f.read().strip()
  162. if not content:
  163. logger.error(f"File is empty: {file_path}")
  164. return False, f"File is empty: {file_path}"
  165. return True, content
  166. except PermissionError:
  167. logger.error(f"Permission denied reading file: {file_path}")
  168. return False, f"Permission denied: {file_path}"
  169. except Exception as e:
  170. logger.error(f"Failed to read file {file_path}: {str(e)}")
  171. return False, f"Failed to read file: {str(e)}"
  172. def create_key_handler(key_config):
  173. """Create a key access handler for a specific key configuration"""
  174. @require_auth(key_config=key_config)
  175. def get_key_part():
  176. """Emergency key access endpoint"""
  177. logger.warning(f"EMERGENCY: Key access attempt detected for key '{key_config.key_id}'")
  178. try:
  179. # Send notification first - fail-safe approach
  180. notification_success, successful_backends = send_ntfy_notification(
  181. key_config.backends,
  182. key_config.message,
  183. "EMERGENCY ACCESS ALERT"
  184. )
  185. if not notification_success:
  186. logger.error(f"CRITICAL: Failed to send notifications to any backend for key '{key_config.key_id}'")
  187. return jsonify({
  188. 'error': 'Notification system failure',
  189. 'message': 'Access denied for security reasons'
  190. }), 500
  191. logger.info(f"Notifications sent successfully to: {successful_backends} for key '{key_config.key_id}'")
  192. # Read key file
  193. file_success, content = read_file_safely(key_config.file_path)
  194. if not file_success:
  195. logger.error(f"CRITICAL: Failed to read key file for '{key_config.key_id}': {content}")
  196. return jsonify({
  197. 'error': 'File access failure',
  198. 'message': 'Unable to retrieve key part'
  199. }), 500
  200. logger.warning(f"EMERGENCY: Key part successfully retrieved and sent for key '{key_config.key_id}'")
  201. return jsonify({
  202. 'success': True,
  203. 'key_id': key_config.key_id,
  204. 'key_part': content,
  205. 'timestamp': time.time(),
  206. 'notified_backends': successful_backends
  207. })
  208. except Exception as e:
  209. logger.error(f"CRITICAL: Unexpected error in key access for '{key_config.key_id}': {str(e)}")
  210. return jsonify({
  211. 'error': 'System error',
  212. 'message': 'Internal server error'
  213. }), 500
  214. return get_key_part
  215. @require_auth(is_health=True)
  216. def health_check():
  217. """Health check endpoint that verifies both health monitoring and all key request functionality"""
  218. logger.info("Health check requested")
  219. try:
  220. # Test health notification system
  221. health_notification_success, health_backends = send_ntfy_notification(
  222. config.ntfy_backends_health,
  223. config.ntfy_health_message,
  224. "Health Check"
  225. )
  226. # Test dummy file access
  227. dummy_file_success, dummy_content = read_file_safely(config.dummy_file_path)
  228. # Test all key files access (without exposing content)
  229. key_files_status = {}
  230. all_key_files_ok = True
  231. for key_id, key_config in config.keys.items():
  232. key_file_success, key_content = read_file_safely(key_config.file_path)
  233. key_files_status[key_id] = key_file_success
  234. if not key_file_success:
  235. all_key_files_ok = False
  236. # Test all key notification backends
  237. key_backends_status = {}
  238. all_key_backends_ok = True
  239. for key_id, key_config in config.keys.items():
  240. try:
  241. # Test notification without actually sending
  242. backend_test_success = len(key_config.backends) > 0
  243. key_backends_status[key_id] = {
  244. 'backends': key_config.backends,
  245. 'success': backend_test_success
  246. }
  247. if not backend_test_success:
  248. all_key_backends_ok = False
  249. except Exception as e:
  250. key_backends_status[key_id] = {
  251. 'backends': key_config.backends,
  252. 'success': False,
  253. 'error': str(e)
  254. }
  255. all_key_backends_ok = False
  256. # Determine overall health status
  257. all_systems_ok = (health_notification_success and
  258. dummy_file_success and
  259. all_key_files_ok and
  260. all_key_backends_ok)
  261. if not all_systems_ok:
  262. error_details = []
  263. if not health_notification_success:
  264. error_details.append("health notifications failed")
  265. if not dummy_file_success:
  266. error_details.append(f"dummy file access failed: {dummy_content}")
  267. # Add key-specific errors
  268. for key_id, status in key_files_status.items():
  269. if not status:
  270. error_details.append(f"key file access failed for '{key_id}'")
  271. for key_id, status in key_backends_status.items():
  272. if not status['success']:
  273. error_msg = f"key backends failed for '{key_id}'"
  274. if 'error' in status:
  275. error_msg += f": {status['error']}"
  276. error_details.append(error_msg)
  277. logger.error(f"Health check failed: {', '.join(error_details)}")
  278. return jsonify({
  279. 'status': 'error',
  280. 'message': 'System components failed',
  281. 'details': error_details,
  282. 'health_notifications': health_notification_success,
  283. 'dummy_file_access': dummy_file_success,
  284. 'key_files_status': key_files_status,
  285. 'key_backends_status': key_backends_status
  286. }), 500
  287. logger.info("Health check completed successfully - all systems operational")
  288. return jsonify({
  289. 'status': 'ok',
  290. 'timestamp': time.time(),
  291. 'health_backends_notified': health_backends,
  292. 'dummy_content_length': len(dummy_content),
  293. 'keys_accessible': len(key_files_status),
  294. 'key_files_status': key_files_status,
  295. 'key_backends_status': key_backends_status,
  296. 'emergency_system_ready': True
  297. })
  298. except Exception as e:
  299. logger.error(f"Health check error: {str(e)}")
  300. return jsonify({
  301. 'status': 'error',
  302. 'message': 'System error',
  303. 'error': str(e)
  304. }), 500
  305. @app.errorhandler(404)
  306. def not_found(error):
  307. """Handle 404 errors silently for security"""
  308. logger.warning(f"404 attempt: {error}")
  309. return jsonify({'error': 'Not found'}), 404
  310. @app.errorhandler(500)
  311. def internal_error(error):
  312. """Handle internal server errors"""
  313. logger.error(f"Internal server error: {error}")
  314. return jsonify({'error': 'Internal server error'}), 500
  315. def validate_setup():
  316. """Validate system setup before starting"""
  317. logger.info("Validating system setup...")
  318. # Check dummy file exists
  319. if not os.path.exists(config.dummy_file_path):
  320. logger.error(f"Dummy file not found: {config.dummy_file_path}")
  321. return False
  322. # Test dummy file permissions
  323. try:
  324. with open(config.dummy_file_path, 'r') as f:
  325. f.read(1)
  326. except Exception as e:
  327. logger.error(f"Dummy file permission test failed: {str(e)}")
  328. return False
  329. # Validate all key configurations
  330. for key_id, key_config in config.keys.items():
  331. logger.info(f"Validating key '{key_id}'...")
  332. # Check key file exists
  333. if not os.path.exists(key_config.file_path):
  334. logger.error(f"Key file not found for '{key_id}': {key_config.file_path}")
  335. return False
  336. # Test key file permissions
  337. try:
  338. with open(key_config.file_path, 'r') as f:
  339. f.read(1)
  340. except Exception as e:
  341. logger.error(f"Key file permission test failed for '{key_id}': {str(e)}")
  342. return False
  343. # Validate backends are configured
  344. if not key_config.backends:
  345. logger.error(f"No notification backends configured for key '{key_id}'")
  346. return False
  347. # Test notification system
  348. logger.info("Testing notification system...")
  349. try:
  350. # Test health notification system
  351. health_success, _ = send_ntfy_notification(
  352. config.ntfy_backends_health[:1], # Test only first backend
  353. "System startup test - health notifications",
  354. "Emergency Access Startup Test"
  355. )
  356. if not health_success:
  357. logger.error("Health notification system test failed")
  358. return False
  359. # Test each key's notification backends
  360. for key_id, key_config in config.keys.items():
  361. key_success, _ = send_ntfy_notification(
  362. key_config.backends[:1], # Test only first backend
  363. f"System startup test - key '{key_id}' notifications",
  364. "Emergency Access Startup Test"
  365. )
  366. if not key_success:
  367. logger.error(f"Key notification system test failed for '{key_id}'")
  368. return False
  369. except Exception as e:
  370. logger.warning(f"Notification test failed, but continuing: {str(e)}")
  371. logger.info("System validation completed successfully")
  372. return True
  373. class ProductionRequestHandler(WSGIRequestHandler):
  374. """Custom request handler with improved error handling"""
  375. def log_error(self, format, *args):
  376. """Override to use our logger"""
  377. logger.error(f"HTTP Error: {format % args}")
  378. def log_message(self, format, *args):
  379. """Override to use our logger"""
  380. logger.info(f"HTTP: {format % args}")
  381. if __name__ == '__main__':
  382. config = None
  383. try:
  384. # Load configuration
  385. config = Config()
  386. logger.info("Configuration loaded successfully")
  387. # Add ntfy log handler after config is loaded
  388. if config.send_all_logs:
  389. ntfy_handler = NtfyLogHandler(config)
  390. min_level = getattr(logging, config.log_level.upper(), logging.WARNING)
  391. ntfy_handler.setLevel(min_level)
  392. # Add to root logger to catch all application logs
  393. logging.getLogger().addHandler(ntfy_handler)
  394. # Validate system setup
  395. if not validate_setup():
  396. logger.error("System validation failed, exiting")
  397. sys.exit(1)
  398. # Add Flask routes dynamically for each key
  399. for key_id, key_config in config.keys.items():
  400. handler = create_key_handler(key_config)
  401. endpoint_name = f'get_key_part_{key_id}'
  402. app.add_url_rule(key_config.route, endpoint_name, handler, methods=['GET'])
  403. logger.info(f"Registered authenticated key route for '{key_id}': {key_config.route}")
  404. # Add health check route
  405. app.add_url_rule(config.health_route, 'health_check', health_check, methods=['GET'])
  406. logger.info(f"Starting emergency access server on {config.server_host}:{config.server_port}")
  407. logger.info(f"Health route: {config.health_route}")
  408. logger.info(f"Configured {len(config.keys)} key(s)")
  409. # Use production-ready server with better error handling
  410. try:
  411. from waitress import serve
  412. logger.info("Using Waitress WSGI server for production")
  413. serve(
  414. app,
  415. host=config.server_host,
  416. port=config.server_port,
  417. threads=6,
  418. connection_limit=100,
  419. cleanup_interval=30,
  420. channel_timeout=120
  421. )
  422. except ImportError:
  423. logger.warning("Waitress not available, falling back to Flask dev server")
  424. app.run(
  425. host=config.server_host,
  426. port=config.server_port,
  427. debug=False,
  428. threaded=True,
  429. request_handler=ProductionRequestHandler
  430. )
  431. except KeyboardInterrupt:
  432. logger.info("Received keyboard interrupt, shutting down gracefully")
  433. except Exception as e:
  434. logger.error(f"Failed to start server: {str(e)}")
  435. if config and hasattr(config, 'ntfy_backends_health'):
  436. try:
  437. send_ntfy_notification(
  438. config.ntfy_backends_health,
  439. f"🚨 Emergency Access server failed to start: {str(e)}",
  440. "Emergency Access CRITICAL"
  441. )
  442. except Exception:
  443. pass
  444. sys.exit(1)
  445. finally:
  446. logger.info("Emergency Access server shutdown complete")