main.py 24 KB

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