main.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  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
  8. from config import Config
  9. from typing import List, Tuple
  10. # Configure logging with custom handler
  11. class NtfyLogHandler(logging.Handler):
  12. """Custom logging handler that sends logs to ntfy health backends"""
  13. def __init__(self, config_obj):
  14. super().__init__()
  15. self.config = config_obj
  16. def emit(self, record):
  17. """Send log record to health backends"""
  18. if hasattr(self.config, 'ntfy_backends_health') and self.config.send_all_logs:
  19. try:
  20. log_message = self.format(record)
  21. # Get configured log level or default to WARNING
  22. min_level = getattr(logging, self.config.log_level.upper(), logging.WARNING)
  23. if record.levelno >= min_level:
  24. # Format message with appropriate emoji based on log level
  25. emoji = "🚨" if record.levelno >= logging.ERROR else "⚠️" if record.levelno >= logging.WARNING else "ℹ️"
  26. title = f"Emergency Access {record.levelname}"
  27. message = f"{emoji} {record.name}: {record.getMessage()}"
  28. send_ntfy_notification(
  29. self.config.ntfy_backends_health,
  30. message,
  31. title
  32. )
  33. except Exception:
  34. # Don't fail the application if logging notification fails
  35. pass
  36. # Configure logging
  37. logging.basicConfig(
  38. level=logging.INFO,
  39. format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
  40. handlers=[
  41. logging.FileHandler('/var/log/emergency-access.log'),
  42. logging.StreamHandler()
  43. ]
  44. )
  45. logger = logging.getLogger(__name__)
  46. app = Flask(__name__)
  47. # Global config instance will be initialized in main
  48. config = None
  49. def send_ntfy_notification(backends: List[str], message: str, title: str = None) -> Tuple[bool, List[str]]:
  50. """
  51. Send notification using dschep/ntfy with dedicated config file
  52. Returns: (success, successful_backends)
  53. """
  54. successful_backends = []
  55. for backend in backends:
  56. try:
  57. # Import ntfy here to avoid import issues during startup
  58. try:
  59. import ntfy
  60. from ntfy.config import load_config
  61. except ImportError:
  62. raise Exception("ntfy package not available. Please install with: pip install ntfy")
  63. # Load the ntfy config file
  64. ntfy_config = load_config(config.ntfy_config_path)
  65. ntfy_config["backends"] = [backend]
  66. # Send notification using the backend name from our config file
  67. if title:
  68. ret = ntfy.notify(message, title=title, config=ntfy_config)
  69. else:
  70. ret = ntfy.notify(message, config=ntfy_config, title="Note")
  71. if ret == 0:
  72. successful_backends.append(backend)
  73. logger.info(f"Notification sent successfully via {backend}")
  74. else:
  75. logger.error(f"Failed to send notification via {backend}")
  76. raise Exception(f"Failed to send notification via {backend}")
  77. except ImportError:
  78. logger.error(f"ntfy package not available for backend {backend}")
  79. except Exception as e:
  80. logger.error(f"Failed to send notification to {backend}: {str(e)}")
  81. raise
  82. success = len(successful_backends) > 0
  83. return success, successful_backends
  84. def read_file_safely(file_path: str) -> Tuple[bool, str]:
  85. """
  86. Safely read file content
  87. Returns: (success, content)
  88. """
  89. try:
  90. if not os.path.exists(file_path):
  91. logger.error(f"File not found: {file_path}")
  92. return False, f"File not found: {file_path}"
  93. with open(file_path, 'r') as f:
  94. content = f.read().strip()
  95. if not content:
  96. logger.error(f"File is empty: {file_path}")
  97. return False, f"File is empty: {file_path}"
  98. return True, content
  99. except PermissionError:
  100. logger.error(f"Permission denied reading file: {file_path}")
  101. return False, f"Permission denied: {file_path}"
  102. except Exception as e:
  103. logger.error(f"Failed to read file {file_path}: {str(e)}")
  104. return False, f"Failed to read file: {str(e)}"
  105. def create_key_handler(key_config):
  106. """Create a key access handler for a specific key configuration"""
  107. def get_key_part():
  108. """Emergency key access endpoint"""
  109. logger.warning(f"EMERGENCY: Key access attempt detected for key '{key_config.key_id}'")
  110. try:
  111. # Send notification first - fail-safe approach
  112. notification_success, successful_backends = send_ntfy_notification(
  113. key_config.backends,
  114. key_config.message,
  115. "EMERGENCY ACCESS ALERT"
  116. )
  117. if not notification_success:
  118. logger.error(f"CRITICAL: Failed to send notifications to any backend for key '{key_config.key_id}'")
  119. return jsonify({
  120. 'error': 'Notification system failure',
  121. 'message': 'Access denied for security reasons'
  122. }), 500
  123. logger.info(f"Notifications sent successfully to: {successful_backends} for key '{key_config.key_id}'")
  124. # Read key file
  125. file_success, content = read_file_safely(key_config.file_path)
  126. if not file_success:
  127. logger.error(f"CRITICAL: Failed to read key file for '{key_config.key_id}': {content}")
  128. return jsonify({
  129. 'error': 'File access failure',
  130. 'message': 'Unable to retrieve key part'
  131. }), 500
  132. logger.warning(f"EMERGENCY: Key part successfully retrieved and sent for key '{key_config.key_id}'")
  133. return jsonify({
  134. 'success': True,
  135. 'key_id': key_config.key_id,
  136. 'key_part': content,
  137. 'timestamp': time.time(),
  138. 'notified_backends': successful_backends
  139. })
  140. except Exception as e:
  141. logger.error(f"CRITICAL: Unexpected error in key access for '{key_config.key_id}': {str(e)}")
  142. return jsonify({
  143. 'error': 'System error',
  144. 'message': 'Internal server error'
  145. }), 500
  146. return get_key_part
  147. def health_check():
  148. """Health check endpoint that verifies both health monitoring and all key request functionality"""
  149. logger.info("Health check requested")
  150. try:
  151. # Test health notification system
  152. health_notification_success, health_backends = send_ntfy_notification(
  153. config.ntfy_backends_health,
  154. config.ntfy_health_message,
  155. "Health Check"
  156. )
  157. # Test dummy file access
  158. dummy_file_success, dummy_content = read_file_safely(config.dummy_file_path)
  159. # Test all key files access (without exposing content)
  160. key_files_status = {}
  161. all_key_files_ok = True
  162. for key_id, key_config in config.keys.items():
  163. key_file_success, key_content = read_file_safely(key_config.file_path)
  164. key_files_status[key_id] = key_file_success
  165. if not key_file_success:
  166. all_key_files_ok = False
  167. # Test all key notification backends
  168. key_backends_status = {}
  169. all_key_backends_ok = True
  170. for key_id, key_config in config.keys.items():
  171. try:
  172. # Test notification without actually sending
  173. backend_test_success = len(key_config.backends) > 0
  174. key_backends_status[key_id] = {
  175. 'backends': key_config.backends,
  176. 'success': backend_test_success
  177. }
  178. if not backend_test_success:
  179. all_key_backends_ok = False
  180. except Exception as e:
  181. key_backends_status[key_id] = {
  182. 'backends': key_config.backends,
  183. 'success': False,
  184. 'error': str(e)
  185. }
  186. all_key_backends_ok = False
  187. # Determine overall health status
  188. all_systems_ok = (health_notification_success and
  189. dummy_file_success and
  190. all_key_files_ok and
  191. all_key_backends_ok)
  192. if not all_systems_ok:
  193. error_details = []
  194. if not health_notification_success:
  195. error_details.append("health notifications failed")
  196. if not dummy_file_success:
  197. error_details.append(f"dummy file access failed: {dummy_content}")
  198. # Add key-specific errors
  199. for key_id, status in key_files_status.items():
  200. if not status:
  201. error_details.append(f"key file access failed for '{key_id}'")
  202. for key_id, status in key_backends_status.items():
  203. if not status['success']:
  204. error_msg = f"key backends failed for '{key_id}'"
  205. if 'error' in status:
  206. error_msg += f": {status['error']}"
  207. error_details.append(error_msg)
  208. logger.error(f"Health check failed: {', '.join(error_details)}")
  209. return jsonify({
  210. 'status': 'error',
  211. 'message': 'System components failed',
  212. 'details': error_details,
  213. 'health_notifications': health_notification_success,
  214. 'dummy_file_access': dummy_file_success,
  215. 'key_files_status': key_files_status,
  216. 'key_backends_status': key_backends_status
  217. }), 500
  218. logger.info("Health check completed successfully - all systems operational")
  219. return jsonify({
  220. 'status': 'ok',
  221. 'timestamp': time.time(),
  222. 'health_backends_notified': health_backends,
  223. 'dummy_content_length': len(dummy_content),
  224. 'keys_accessible': len(key_files_status),
  225. 'key_files_status': key_files_status,
  226. 'key_backends_status': key_backends_status,
  227. 'emergency_system_ready': True
  228. })
  229. except Exception as e:
  230. logger.error(f"Health check error: {str(e)}")
  231. return jsonify({
  232. 'status': 'error',
  233. 'message': 'System error',
  234. 'error': str(e)
  235. }), 500
  236. @app.errorhandler(404)
  237. def not_found(error):
  238. """Handle 404 errors silently for security"""
  239. logger.warning(f"404 attempt: {error}")
  240. return jsonify({'error': 'Not found'}), 404
  241. @app.errorhandler(500)
  242. def internal_error(error):
  243. """Handle internal server errors"""
  244. logger.error(f"Internal server error: {error}")
  245. return jsonify({'error': 'Internal server error'}), 500
  246. def validate_setup():
  247. """Validate system setup before starting"""
  248. logger.info("Validating system setup...")
  249. # Check dummy file exists
  250. if not os.path.exists(config.dummy_file_path):
  251. logger.error(f"Dummy file not found: {config.dummy_file_path}")
  252. return False
  253. # Test dummy file permissions
  254. try:
  255. with open(config.dummy_file_path, 'r') as f:
  256. f.read(1)
  257. except Exception as e:
  258. logger.error(f"Dummy file permission test failed: {str(e)}")
  259. return False
  260. # Validate all key configurations
  261. for key_id, key_config in config.keys.items():
  262. logger.info(f"Validating key '{key_id}'...")
  263. # Check key file exists
  264. if not os.path.exists(key_config.file_path):
  265. logger.error(f"Key file not found for '{key_id}': {key_config.file_path}")
  266. return False
  267. # Test key file permissions
  268. try:
  269. with open(key_config.file_path, 'r') as f:
  270. f.read(1)
  271. except Exception as e:
  272. logger.error(f"Key file permission test failed for '{key_id}': {str(e)}")
  273. return False
  274. # Validate backends are configured
  275. if not key_config.backends:
  276. logger.error(f"No notification backends configured for key '{key_id}'")
  277. return False
  278. # Test notification system
  279. logger.info("Testing notification system...")
  280. try:
  281. # Test health notification system
  282. health_success, _ = send_ntfy_notification(
  283. config.ntfy_backends_health[:1], # Test only first backend
  284. "System startup test - health notifications",
  285. "Emergency Access Startup Test"
  286. )
  287. if not health_success:
  288. logger.error("Health notification system test failed")
  289. return False
  290. # Test each key's notification backends
  291. for key_id, key_config in config.keys.items():
  292. key_success, _ = send_ntfy_notification(
  293. key_config.backends[:1], # Test only first backend
  294. f"System startup test - key '{key_id}' notifications",
  295. "Emergency Access Startup Test"
  296. )
  297. if not key_success:
  298. logger.error(f"Key notification system test failed for '{key_id}'")
  299. return False
  300. except Exception as e:
  301. logger.warning(f"Notification test failed, but continuing: {str(e)}")
  302. logger.info("System validation completed successfully")
  303. return True
  304. if __name__ == '__main__':
  305. try:
  306. # Load configuration
  307. config = Config()
  308. logger.info("Configuration loaded successfully")
  309. # Add ntfy log handler after config is loaded
  310. if config.send_all_logs:
  311. ntfy_handler = NtfyLogHandler(config)
  312. min_level = getattr(logging, config.log_level.upper(), logging.WARNING)
  313. ntfy_handler.setLevel(min_level)
  314. # Add to root logger to catch all application logs
  315. logging.getLogger().addHandler(ntfy_handler)
  316. # Validate system setup
  317. if not validate_setup():
  318. logger.error("System validation failed, exiting")
  319. sys.exit(1)
  320. # Add Flask routes dynamically for each key
  321. for key_id, key_config in config.keys.items():
  322. handler = create_key_handler(key_config)
  323. endpoint_name = f'get_key_part_{key_id}'
  324. app.add_url_rule(key_config.route, endpoint_name, handler, methods=['GET'])
  325. logger.info(f"Registered key route for '{key_id}': {key_config.route}")
  326. # Add health check route
  327. app.add_url_rule(config.health_route, 'health_check', health_check, methods=['GET'])
  328. logger.info(f"Starting emergency access server on {config.server_host}:{config.server_port}")
  329. logger.info(f"Health route: {config.health_route}")
  330. logger.info(f"Configured {len(config.keys)} key(s)")
  331. # Run the server on local port for Caddy reverse proxy
  332. app.run(
  333. host=config.server_host,
  334. port=config.server_port,
  335. debug=False,
  336. threaded=True
  337. )
  338. except Exception as e:
  339. logger.error(f"Failed to start server: {str(e)}")
  340. sys.exit(1)