main.py 21 KB

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