from flask import Flask, render_template, request, jsonify, abort import requests import threading import os import jwt import logging import json from datetime import datetime, timedelta from dotenv import load_dotenv import re import signal import sys import time from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler # Load environment variables load_dotenv() # Configuration with fixed Caddyfile path API_KEY = os.getenv('API_KEY') DEBUG_MODE = os.getenv('DEBUG_MODE', 'false').lower() == 'true' CADDYFILE_PATH = "/app/Caddyfile" # Fixed internal path NGINX_CONFIG_PATH = os.getenv('NGINX_CONFIG_PATH', '/app/nginx/conf.d') # Path to nginx config directory # Get server name from env variable for local configurations only LOCAL_SERVER_NAME = os.getenv('SERVER_NAME', 'Local Server') # Setup logging logging.basicConfig( level=logging.DEBUG if DEBUG_MODE else logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger('caddy-dashboard') # Determine if we should use local configuration files USE_LOCAL_CADDYFILE = os.path.exists(CADDYFILE_PATH) USE_LOCAL_NGINX = os.path.exists(NGINX_CONFIG_PATH) and os.path.isdir(NGINX_CONFIG_PATH) if not USE_LOCAL_CADDYFILE: logger.warning(f"Caddyfile not found at the standard path: {CADDYFILE_PATH}") if not USE_LOCAL_NGINX: logger.warning(f"Nginx config directory not found at: {NGINX_CONFIG_PATH}") if not API_KEY: logger.warning("API_KEY not set - running without authentication! This is insecure.") app = Flask(__name__) app.config['SECRET_KEY'] = os.urandom(24) # Global data storage caddy_proxies = {} # Server name -> {domain: target} nginx_proxies = {} # Server name -> {domain: target} timestamps = {} # Server name -> timestamp # File change monitoring file_observer = None class ConfigFileHandler(FileSystemEventHandler): """Watch for changes to configuration files and update data""" def on_modified(self, event): if USE_LOCAL_CADDYFILE and event.src_path == CADDYFILE_PATH: logger.info(f"Local Caddyfile changed, updating entries") entries = parse_local_caddyfile() if entries: caddy_proxies[LOCAL_SERVER_NAME] = entries timestamps[LOCAL_SERVER_NAME] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") logger.info(f"Updated {len(entries)} entries from local Caddyfile") # For Nginx, we need to check if the modified file is in the nginx config directory if USE_LOCAL_NGINX and NGINX_CONFIG_PATH in event.src_path and event.src_path.endswith('.conf'): logger.info(f"Local Nginx config changed, updating entries") entries = parse_nginx_configs() if entries: nginx_proxies[f"{LOCAL_SERVER_NAME} Nginx"] = entries timestamps[f"{LOCAL_SERVER_NAME} Nginx"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") logger.info(f"Updated {len(entries)} entries from local Nginx configs") def verify_token(token): """Verify the JWT token from an agent""" if not API_KEY: logger.error("API_KEY is not configured - authentication disabled") return None try: return jwt.decode(token, API_KEY, algorithms=['HS256']) except jwt.ExpiredSignatureError: logger.warning("Authentication token has expired") return None except jwt.InvalidTokenError as e: logger.warning(f"Invalid authentication token: {e}") return None def parse_local_caddyfile(): """Parse a local Caddyfile to extract domains and their proxy targets""" entries = {} try: if not os.path.exists(CADDYFILE_PATH): logger.error(f"Local Caddyfile not found at {CADDYFILE_PATH}") return entries with open(CADDYFILE_PATH, "r") as file: content = file.read() # Revert to simpler pattern that was working previously pattern = re.compile(r"(?P[^\s{]+(?:,\s*[^\s{]+)*)\s*{.*?reverse_proxy\s+(?Phttps?:\/\/[\d\.]+:\d+|[\d\.]+:\d+).*?}", re.DOTALL) matches = pattern.findall(content) logger.info(f"Found {len(matches)} matches in local Caddyfile") for domains, target in matches: for domain in re.split(r',\s*', domains): domain = domain.strip() if domain and domain.lower() != "host": # Skip entries named "host" entries[domain] = target.strip() logger.info(f"Extracted {len(entries)} domain entries from local Caddyfile") # Debug output of parsed entries for domain, target in entries.items(): logger.debug(f"Domain: {domain} -> {target}") except Exception as e: logger.error(f"Error parsing local Caddyfile: {e}") import traceback logger.error(traceback.format_exc()) return entries def parse_nginx_configs(): """Parse nginx config files to extract server_name and proxy_pass directives""" entries = {} if not os.path.exists(NGINX_CONFIG_PATH) or not os.path.isdir(NGINX_CONFIG_PATH): logger.warning(f"Nginx config directory not found at: {NGINX_CONFIG_PATH}") return entries try: # Get all .conf files in the nginx config directory for filename in os.listdir(NGINX_CONFIG_PATH): if not filename.endswith('.conf'): continue filepath = os.path.join(NGINX_CONFIG_PATH, filename) with open(filepath, 'r') as file: content = file.read() # Pattern to extract server_name and proxy_pass server_blocks = re.findall(r'server\s*{(.*?)}', content, re.DOTALL) for block in server_blocks: # Extract server_name server_name_match = re.search(r'server_name\s+(.+?);', block) if not server_name_match: continue server_names = server_name_match.group(1).split() # Extract proxy_pass proxy_pass_match = re.search(r'proxy_pass\s+(https?://[^;]+);', block) if not proxy_pass_match: continue proxy_target = proxy_pass_match.group(1) # Add each server name with its proxy target for name in server_names: if name != '_' and name.lower() != 'host': # Skip default server and "host" entries[name] = proxy_target logger.info(f"Extracted {len(entries)} domain entries from Nginx configs") except Exception as e: logger.error(f"Error parsing Nginx configs: {e}") import traceback logger.error(traceback.format_exc()) return entries def validate_token(auth_header): """Validate the JWT token from the Authorization header""" if not auth_header or not auth_header.startswith("Bearer "): return False token = auth_header.split(" ")[1] try: payload = jwt.decode(token, API_KEY, algorithms=["HS256"]) return True except jwt.InvalidTokenError: return False def get_server_stats(): """Get basic server statistics""" stats = { 'caddy_servers': len(caddy_proxies), 'nginx_servers': len(nginx_proxies), 'total_domains': sum(len(entries) for entries in caddy_proxies.values()) + sum(len(entries) for entries in nginx_proxies.values()), 'last_update': max(timestamps.values(), default=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) } return stats @app.route('/') def index(): """Dashboard home page""" # Calculate stats caddy_servers = len(caddy_proxies) nginx_servers = len(nginx_proxies) # Calculate total domains total_domains = 0 domain_list = [] # Build the domain list for both Caddy and Nginx for server, domains in caddy_proxies.items(): for domain, target in domains.items(): domain_list.append({ "domain": domain, "target": target, "server": server, "server_type": "caddy" }) total_domains += 1 for server, domains in nginx_proxies.items(): for domain, target in domains.items(): domain_list.append({ "domain": domain, "target": target, "server": server, "server_type": "nginx" }) total_domains += 1 # Sort domains by name domain_list.sort(key=lambda x: x["domain"]) # Get the latest update time last_update = "Never" if timestamps: # Find the most recent timestamp latest_time = max(timestamps.values()) last_update = latest_time return render_template('dashboard.html', caddy_count=caddy_servers, nginx_count=nginx_servers, domain_count=total_domains, last_update=last_update, domain_list=domain_list, # Pass the pre-built list caddy_proxies=caddy_proxies, nginx_proxies=nginx_proxies) @app.route('/caddy') def caddy_view(): """View for Caddy servers only""" return render_template('index.html', proxies=caddy_proxies, timestamps=timestamps, server_type="Caddy") @app.route('/nginx') def nginx_view(): """View for Nginx servers only""" return render_template('index.html', proxies=nginx_proxies, timestamps=timestamps, server_type="Nginx") @app.route('/api/update', methods=['POST']) def update_proxies(): """API endpoint for agents to update proxy data""" # Verify token if API_KEY is set if API_KEY: auth_header = request.headers.get("Authorization") if not validate_token(auth_header): return jsonify({"status": "error", "message": "Invalid or missing token"}), 401 # Get data from request data = request.json if not data or not isinstance(data, dict): return jsonify({"status": "error", "message": "Invalid data format"}), 400 # Use the server name provided by the agent or a default agent name # Do NOT default to LOCAL_SERVER_NAME which is for local configs only server_name = data.get("server") if not server_name: # Generate a reasonable default name if agent doesn't provide one agent_ip = request.remote_addr server_name = f"Agent-{agent_ip}" logger.warning(f"Agent did not provide server name, using: {server_name}") entries = data.get("entries", {}) server_type = data.get("type", "caddy").lower() # Default to caddy if not specified # Update the appropriate store based on server type if server_type == "nginx": nginx_proxies[server_name] = entries else: caddy_proxies[server_name] = entries # Update timestamp timestamps[server_name] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Log the update logger.info(f"Updated {server_type} proxy data for {server_name}: {len(entries)} entries") return jsonify({"status": "success", "message": f"Updated {len(entries)} entries for {server_name}"}) @app.route('/delete', methods=['POST']) def delete_server(): """Delete a server from the dashboard""" data = request.json if not data or "server" not in data: return jsonify({"status": "error", "message": "Server name required"}), 400 server_name = data["server"] # Remove from both stores and timestamps caddy_removed = server_name in caddy_proxies nginx_removed = server_name in nginx_proxies if server_name in caddy_proxies: del caddy_proxies[server_name] if server_name in nginx_proxies: del nginx_proxies[server_name] if server_name in timestamps: del timestamps[server_name] if not caddy_removed and not nginx_removed: return jsonify({"status": "error", "message": f"Server {server_name} not found"}), 404 logger.info(f"Deleted server: {server_name}") return jsonify({"status": "success", "message": f"Server {server_name} deleted"}) def start_file_monitoring(): """Start monitoring configuration files for changes""" global file_observer if not (USE_LOCAL_CADDYFILE or USE_LOCAL_NGINX): logger.info("No local configuration files to monitor") return event_handler = ConfigFileHandler() file_observer = Observer() if USE_LOCAL_CADDYFILE: # Monitor the Caddyfile file_observer.schedule( event_handler, path=os.path.dirname(CADDYFILE_PATH), recursive=False ) logger.info(f"Monitoring local Caddyfile at {CADDYFILE_PATH}") if USE_LOCAL_NGINX: # Monitor Nginx config directory file_observer.schedule( event_handler, path=NGINX_CONFIG_PATH, recursive=True # Monitor all subdirectories too ) logger.info(f"Monitoring local Nginx configs at {NGINX_CONFIG_PATH}") file_observer.start() logger.info("File monitoring started") # Add cleanup function for graceful shutdown def stop_file_monitoring(): """Stop file monitoring""" if file_observer: file_observer.stop() file_observer.join() logger.info("File monitoring stopped") # Enhanced signal handler def signal_handler(sig, frame): logger.info("Shutdown signal received, exiting gracefully...") stop_file_monitoring() sys.exit(0) signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) # Initialize with local files and start monitoring if USE_LOCAL_CADDYFILE: entries = parse_local_caddyfile() if entries: caddy_proxies[LOCAL_SERVER_NAME] = entries timestamps[LOCAL_SERVER_NAME] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") logger.info(f"Loaded {len(entries)} entries from local Caddyfile") if USE_LOCAL_NGINX: entries = parse_nginx_configs() if entries: nginx_proxies[f"{LOCAL_SERVER_NAME} Nginx"] = entries timestamps[f"{LOCAL_SERVER_NAME} Nginx"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") logger.info(f"Loaded {len(entries)} entries from local Nginx configs") # Start file monitoring after initializing start_file_monitoring() if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=DEBUG_MODE)