408 lines
15 KiB
Python
408 lines
15 KiB
Python
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<domains>[^\s{]+(?:,\s*[^\s{]+)*)\s*{.*?reverse_proxy\s+(?P<target>https?:\/\/[\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)
|