main.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  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
  107. import signal
  108. def timeout_handler(signum, frame):
  109. raise Exception("Notification timeout")
  110. signal.signal(signal.SIGALRM, timeout_handler)
  111. signal.alarm(15) # 15 second timeout
  112. try:
  113. # Send notification using the backend name from our config file
  114. if title:
  115. ret = ntfy.notify(message, title=title, config=ntfy_config)
  116. else:
  117. ret = ntfy.notify(message, config=ntfy_config, title="Note")
  118. finally:
  119. signal.alarm(0) # Cancel timeout
  120. if ret == 0:
  121. successful_backends.append(backend)
  122. logger.info(f"Notification sent successfully via {backend}")
  123. success = True
  124. break
  125. else:
  126. raise Exception(f"ntfy returned error code {ret}")
  127. except ImportError as e:
  128. logger.error(f"ntfy package not available for backend {backend}: {e}")
  129. last_error = e
  130. break # Don't retry import errors
  131. except Exception as e:
  132. last_error = e
  133. if attempt < max_retries - 1:
  134. logger.warning(f"Notification attempt {attempt + 1} failed for {backend}: {str(e)}, retrying...")
  135. time.sleep(retry_delay * (attempt + 1)) # Exponential backoff
  136. else:
  137. logger.error(f"All notification attempts failed for {backend}: {str(e)}")
  138. if not success and last_error:
  139. # Don't raise exception - just log failure and continue with other backends
  140. logger.error(f"Failed to send notification to {backend} after {max_retries} attempts: {str(last_error)}")
  141. success = len(successful_backends) > 0
  142. return success, successful_backends
  143. def read_file_safely(file_path: str) -> Tuple[bool, str]:
  144. """
  145. Safely read file content
  146. Returns: (success, content)
  147. """
  148. try:
  149. if not os.path.exists(file_path):
  150. logger.error(f"File not found: {file_path}")
  151. return False, f"File not found: {file_path}"
  152. with open(file_path, 'r') as f:
  153. content = f.read().strip()
  154. if not content:
  155. logger.error(f"File is empty: {file_path}")
  156. return False, f"File is empty: {file_path}"
  157. return True, content
  158. except PermissionError:
  159. logger.error(f"Permission denied reading file: {file_path}")
  160. return False, f"Permission denied: {file_path}"
  161. except Exception as e:
  162. logger.error(f"Failed to read file {file_path}: {str(e)}")
  163. return False, f"Failed to read file: {str(e)}"
  164. def create_key_handler(key_config):
  165. """Create a key access handler for a specific key configuration"""
  166. @require_auth(key_config=key_config)
  167. def get_key_part():
  168. """Emergency key access endpoint"""
  169. logger.warning(f"EMERGENCY: Key access attempt detected for key '{key_config.key_id}'")
  170. try:
  171. # Send notification first - fail-safe approach
  172. notification_success, successful_backends = send_ntfy_notification(
  173. key_config.backends,
  174. key_config.message,
  175. "EMERGENCY ACCESS ALERT"
  176. )
  177. if not notification_success:
  178. logger.error(f"CRITICAL: Failed to send notifications to any backend for key '{key_config.key_id}'")
  179. return jsonify({
  180. 'error': 'Notification system failure',
  181. 'message': 'Access denied for security reasons'
  182. }), 500
  183. logger.info(f"Notifications sent successfully to: {successful_backends} for key '{key_config.key_id}'")
  184. # Read key file
  185. file_success, content = read_file_safely(key_config.file_path)
  186. if not file_success:
  187. logger.error(f"CRITICAL: Failed to read key file for '{key_config.key_id}': {content}")
  188. return jsonify({
  189. 'error': 'File access failure',
  190. 'message': 'Unable to retrieve key part'
  191. }), 500
  192. logger.warning(f"EMERGENCY: Key part successfully retrieved and sent for key '{key_config.key_id}'")
  193. return jsonify({
  194. 'success': True,
  195. 'key_id': key_config.key_id,
  196. 'key_part': content,
  197. 'timestamp': time.time(),
  198. 'notified_backends': successful_backends
  199. })
  200. except Exception as e:
  201. logger.error(f"CRITICAL: Unexpected error in key access for '{key_config.key_id}': {str(e)}")
  202. return jsonify({
  203. 'error': 'System error',
  204. 'message': 'Internal server error'
  205. }), 500
  206. return get_key_part
  207. @require_auth(is_health=True)
  208. def health_check():
  209. """Health check endpoint that verifies both health monitoring and all key request functionality"""
  210. logger.info("Health check requested")
  211. try:
  212. # Test health notification system
  213. health_notification_success, health_backends = send_ntfy_notification(
  214. config.ntfy_backends_health,
  215. config.ntfy_health_message,
  216. "Health Check"
  217. )
  218. # Test dummy file access
  219. dummy_file_success, dummy_content = read_file_safely(config.dummy_file_path)
  220. # Test all key files access (without exposing content)
  221. key_files_status = {}
  222. all_key_files_ok = True
  223. for key_id, key_config in config.keys.items():
  224. key_file_success, key_content = read_file_safely(key_config.file_path)
  225. key_files_status[key_id] = key_file_success
  226. if not key_file_success:
  227. all_key_files_ok = False
  228. # Test all key notification backends
  229. key_backends_status = {}
  230. all_key_backends_ok = True
  231. for key_id, key_config in config.keys.items():
  232. try:
  233. # Test notification without actually sending
  234. backend_test_success = len(key_config.backends) > 0
  235. key_backends_status[key_id] = {
  236. 'backends': key_config.backends,
  237. 'success': backend_test_success
  238. }
  239. if not backend_test_success:
  240. all_key_backends_ok = False
  241. except Exception as e:
  242. key_backends_status[key_id] = {
  243. 'backends': key_config.backends,
  244. 'success': False,
  245. 'error': str(e)
  246. }
  247. all_key_backends_ok = False
  248. # Determine overall health status
  249. all_systems_ok = (health_notification_success and
  250. dummy_file_success and
  251. all_key_files_ok and
  252. all_key_backends_ok)
  253. if not all_systems_ok:
  254. error_details = []
  255. if not health_notification_success:
  256. error_details.append("health notifications failed")
  257. if not dummy_file_success:
  258. error_details.append(f"dummy file access failed: {dummy_content}")
  259. # Add key-specific errors
  260. for key_id, status in key_files_status.items():
  261. if not status:
  262. error_details.append(f"key file access failed for '{key_id}'")
  263. for key_id, status in key_backends_status.items():
  264. if not status['success']:
  265. error_msg = f"key backends failed for '{key_id}'"
  266. if 'error' in status:
  267. error_msg += f": {status['error']}"
  268. error_details.append(error_msg)
  269. logger.error(f"Health check failed: {', '.join(error_details)}")
  270. return jsonify({
  271. 'status': 'error',
  272. 'message': 'System components failed',
  273. 'details': error_details,
  274. 'health_notifications': health_notification_success,
  275. 'dummy_file_access': dummy_file_success,
  276. 'key_files_status': key_files_status,
  277. 'key_backends_status': key_backends_status
  278. }), 500
  279. logger.info("Health check completed successfully - all systems operational")
  280. return jsonify({
  281. 'status': 'ok',
  282. 'timestamp': time.time(),
  283. 'health_backends_notified': health_backends,
  284. 'dummy_content_length': len(dummy_content),
  285. 'keys_accessible': len(key_files_status),
  286. 'key_files_status': key_files_status,
  287. 'key_backends_status': key_backends_status,
  288. 'emergency_system_ready': True
  289. })
  290. except Exception as e:
  291. logger.error(f"Health check error: {str(e)}")
  292. return jsonify({
  293. 'status': 'error',
  294. 'message': 'System error',
  295. 'error': str(e)
  296. }), 500
  297. @app.errorhandler(404)
  298. def not_found(error):
  299. """Handle 404 errors silently for security"""
  300. logger.warning(f"404 attempt: {error}")
  301. return jsonify({'error': 'Not found'}), 404
  302. @app.errorhandler(500)
  303. def internal_error(error):
  304. """Handle internal server errors"""
  305. logger.error(f"Internal server error: {error}")
  306. return jsonify({'error': 'Internal server error'}), 500
  307. def validate_setup():
  308. """Validate system setup before starting"""
  309. logger.info("Validating system setup...")
  310. # Check dummy file exists
  311. if not os.path.exists(config.dummy_file_path):
  312. logger.error(f"Dummy file not found: {config.dummy_file_path}")
  313. return False
  314. # Test dummy file permissions
  315. try:
  316. with open(config.dummy_file_path, 'r') as f:
  317. f.read(1)
  318. except Exception as e:
  319. logger.error(f"Dummy file permission test failed: {str(e)}")
  320. return False
  321. # Validate all key configurations
  322. for key_id, key_config in config.keys.items():
  323. logger.info(f"Validating key '{key_id}'...")
  324. # Check key file exists
  325. if not os.path.exists(key_config.file_path):
  326. logger.error(f"Key file not found for '{key_id}': {key_config.file_path}")
  327. return False
  328. # Test key file permissions
  329. try:
  330. with open(key_config.file_path, 'r') as f:
  331. f.read(1)
  332. except Exception as e:
  333. logger.error(f"Key file permission test failed for '{key_id}': {str(e)}")
  334. return False
  335. # Validate backends are configured
  336. if not key_config.backends:
  337. logger.error(f"No notification backends configured for key '{key_id}'")
  338. return False
  339. # Test notification system
  340. logger.info("Testing notification system...")
  341. try:
  342. # Test health notification system
  343. health_success, _ = send_ntfy_notification(
  344. config.ntfy_backends_health[:1], # Test only first backend
  345. "System startup test - health notifications",
  346. "Emergency Access Startup Test"
  347. )
  348. if not health_success:
  349. logger.error("Health notification system test failed")
  350. return False
  351. # Test each key's notification backends
  352. for key_id, key_config in config.keys.items():
  353. key_success, _ = send_ntfy_notification(
  354. key_config.backends[:1], # Test only first backend
  355. f"System startup test - key '{key_id}' notifications",
  356. "Emergency Access Startup Test"
  357. )
  358. if not key_success:
  359. logger.error(f"Key notification system test failed for '{key_id}'")
  360. return False
  361. except Exception as e:
  362. logger.warning(f"Notification test failed, but continuing: {str(e)}")
  363. logger.info("System validation completed successfully")
  364. return True
  365. class ProductionRequestHandler(WSGIRequestHandler):
  366. """Custom request handler with improved error handling"""
  367. def log_error(self, format, *args):
  368. """Override to use our logger"""
  369. logger.error(f"HTTP Error: {format % args}")
  370. def log_message(self, format, *args):
  371. """Override to use our logger"""
  372. logger.info(f"HTTP: {format % args}")
  373. if __name__ == '__main__':
  374. config = None
  375. try:
  376. # Load configuration
  377. config = Config()
  378. logger.info("Configuration loaded successfully")
  379. # Add ntfy log handler after config is loaded
  380. if config.send_all_logs:
  381. ntfy_handler = NtfyLogHandler(config)
  382. min_level = getattr(logging, config.log_level.upper(), logging.WARNING)
  383. ntfy_handler.setLevel(min_level)
  384. # Add to root logger to catch all application logs
  385. logging.getLogger().addHandler(ntfy_handler)
  386. # Validate system setup
  387. if not validate_setup():
  388. logger.error("System validation failed, exiting")
  389. sys.exit(1)
  390. # Add Flask routes dynamically for each key
  391. for key_id, key_config in config.keys.items():
  392. handler = create_key_handler(key_config)
  393. endpoint_name = f'get_key_part_{key_id}'
  394. app.add_url_rule(key_config.route, endpoint_name, handler, methods=['GET'])
  395. logger.info(f"Registered authenticated key route for '{key_id}': {key_config.route}")
  396. # Add health check route
  397. app.add_url_rule(config.health_route, 'health_check', health_check, methods=['GET'])
  398. logger.info(f"Starting emergency access server on {config.server_host}:{config.server_port}")
  399. logger.info(f"Health route: {config.health_route}")
  400. logger.info(f"Configured {len(config.keys)} key(s)")
  401. # Use production-ready server with better error handling
  402. try:
  403. from waitress import serve
  404. logger.info("Using Waitress WSGI server for production")
  405. serve(
  406. app,
  407. host=config.server_host,
  408. port=config.server_port,
  409. threads=6,
  410. connection_limit=100,
  411. cleanup_interval=30,
  412. channel_timeout=120
  413. )
  414. except ImportError:
  415. logger.warning("Waitress not available, falling back to Flask dev server")
  416. app.run(
  417. host=config.server_host,
  418. port=config.server_port,
  419. debug=False,
  420. threaded=True,
  421. request_handler=ProductionRequestHandler
  422. )
  423. except KeyboardInterrupt:
  424. logger.info("Received keyboard interrupt, shutting down gracefully")
  425. except Exception as e:
  426. logger.error(f"Failed to start server: {str(e)}")
  427. if config and hasattr(config, 'ntfy_backends_health'):
  428. try:
  429. send_ntfy_notification(
  430. config.ntfy_backends_health,
  431. f"🚨 Emergency Access server failed to start: {str(e)}",
  432. "Emergency Access CRITICAL"
  433. )
  434. except Exception:
  435. pass
  436. sys.exit(1)
  437. finally:
  438. logger.info("Emergency Access server shutdown complete")