From 950d72aba1557bddad8c69fc3351abcc27bd2d70 Mon Sep 17 00:00:00 2001 From: pika Date: Mon, 24 Mar 2025 17:51:57 +0100 Subject: [PATCH] wip --- .env.example | 13 ++++ Dockerfile | 6 +- agent.py | 166 +++++++++++++++++++++++++++++++++++++++++++ app.py | 138 ++++++++++++++++++++++++++++++++--- compose.yml | 25 ++++++- entrypoint.sh | 13 ++++ requirements.txt | 4 ++ templates/index.html | 32 ++++++--- 8 files changed, 375 insertions(+), 22 deletions(-) create mode 100644 .env.example create mode 100644 agent.py create mode 100644 entrypoint.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dd1462d --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# API key for authentication between server and agents +API_KEY=your_secure_random_key_here + +# Server mode configurations +DEBUG_MODE=false +CADDYFILE_PATH=/path/to/local/Caddyfile # Only needed in LOCAL_MODE + +# Agent mode configurations +CADDYFILE_PATH=/opt/docker/caddy/conf/Caddyfile +DASHBOARD_URL=https://dashboard.example.com/api/update +# or DASHBOARD_URL=http://caddydb-server:5000/api/update +# SERVER_NAME=my-caddy-server # Optional - defaults to hostname +CHECK_INTERVAL=60 # Seconds between checks \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 190e922..16d9671 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,10 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . /app/ +# Make entrypoint script executable +RUN chmod +x /app/entrypoint.sh + EXPOSE 5000 -CMD ["python", "app.py"] +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["server"] diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..d48e130 --- /dev/null +++ b/agent.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 + +import os +import time +import json +import jwt +import requests +import socket +import re +import logging +from datetime import datetime, timedelta +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Configuration (with environment variable support) +CADDYFILE_PATH = os.getenv('CADDYFILE_PATH', '/opt/docker/caddy/conf/Caddyfile') +DASHBOARD_URL = os.getenv('DASHBOARD_URL', 'https://dashboard.example.com/api/update') +SERVER_NAME = os.getenv('SERVER_NAME', socket.gethostname()) +API_KEY = os.getenv('API_KEY') # Required for authentication +CHECK_INTERVAL = int(os.getenv('CHECK_INTERVAL', '60')) # Seconds between checks even if no file change + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('caddy-agent') + +# Last data sent to avoid unnecessary updates +last_data_sent = None +last_send_time = datetime.min + +def parse_caddyfile(): + """Parse the Caddyfile to extract domains and their proxy targets""" + entries = {} + try: + if not os.path.exists(CADDYFILE_PATH): + logger.error(f"Caddyfile not found at {CADDYFILE_PATH}") + return entries + + with open(CADDYFILE_PATH, "r") as file: + content = file.read() + + pattern = re.compile(r"(?P[^\s{]+(?:,\s*[^\s{]+)*)\s*{.*?reverse_proxy\s+(?Phttps?:\/\/[\d\.]+:\d+|[\d\.]+:\d+).*?}", re.DOTALL) + matches = pattern.findall(content) + + for domains, target in matches: + for domain in domains.split(", "): + domain = domain.strip() + if domain: # Only add non-empty domains + entries[domain] = target.strip() + + logger.info(f"Found {len(entries)} domain entries in Caddyfile") + except Exception as e: + logger.error(f"Error parsing Caddyfile: {e}") + + return entries + +def create_auth_token(): + """Create a JWT token for authentication""" + if not API_KEY: + logger.error("API_KEY is not set. Authentication will fail.") + return None + + # Create a token that expires in 5 minutes + payload = { + 'server': SERVER_NAME, + 'exp': datetime.utcnow() + timedelta(minutes=5) + } + + try: + return jwt.encode(payload, API_KEY, algorithm='HS256') + except Exception as e: + logger.error(f"Error creating authentication token: {e}") + return None + +def send_update(force=False): + """Send Caddyfile data to the dashboard server""" + global last_data_sent, last_send_time + + # Parse the Caddyfile + current_data = parse_caddyfile() + current_time = datetime.now() + + # Only send if data changed or enough time passed since last update + if (not force and + current_data == last_data_sent and + (current_time - last_send_time).total_seconds() < CHECK_INTERVAL): + return + + # Create the data payload + data = { + "server": SERVER_NAME, + "entries": current_data, + "timestamp": current_time.isoformat() + } + + # Create authentication token + token = create_auth_token() + if not token: + return + + # Prepare headers with authentication + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {token}' + } + + try: + response = requests.post( + DASHBOARD_URL, + json=data, + headers=headers, + timeout=10 # Set a reasonable timeout + ) + + if response.status_code == 200: + logger.info(f"Update sent successfully: {response.json()}") + last_data_sent = current_data + last_send_time = current_time + else: + logger.error(f"Error sending update: {response.status_code} - {response.text}") + except requests.exceptions.RequestException as e: + logger.error(f"Connection error sending update: {e}") + +class CaddyfileHandler(FileSystemEventHandler): + """Watch for changes to the Caddyfile""" + + def on_modified(self, event): + if event.src_path == CADDYFILE_PATH: + logger.info(f"Caddyfile modified: {event.src_path}") + send_update() + +def main(): + """Main function to run the agent""" + logger.info(f"Starting Caddy agent for {SERVER_NAME}") + logger.info(f"Monitoring Caddyfile at: {CADDYFILE_PATH}") + logger.info(f"Dashboard URL: {DASHBOARD_URL}") + + # Initial update + send_update(force=True) + + # Setup file watching + observer = Observer() + event_handler = CaddyfileHandler() + + # Watch the directory containing the Caddyfile + caddyfile_dir = os.path.dirname(CADDYFILE_PATH) + observer.schedule(event_handler, caddyfile_dir, recursive=False) + observer.start() + + try: + while True: + # Periodic check even if file doesn't change + time.sleep(CHECK_INTERVAL) + send_update() + except KeyboardInterrupt: + observer.stop() + observer.join() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/app.py b/app.py index a911786..59c3277 100644 --- a/app.py +++ b/app.py @@ -1,39 +1,144 @@ -from flask import Flask, render_template, request, jsonify +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 +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Configuration +API_KEY = os.getenv('API_KEY') # Must match agent configuration +DEBUG_MODE = os.getenv('DEBUG_MODE', 'false').lower() == 'true' +CADDYFILE_PATH = os.getenv('CADDYFILE_PATH') # Optional - for direct file reading + +# 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 Caddyfile reading +USE_LOCAL_CADDYFILE = CADDYFILE_PATH and os.path.exists(CADDYFILE_PATH) app = Flask(__name__) +app.config['SECRET_KEY'] = os.urandom(24) +# Global data storage proxy_data = {} deleted_servers = set() +server_last_seen = {} # Track when servers were last updated + +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 if LOCAL_MODE is enabled""" + if not CADDYFILE_PATH or not os.path.exists(CADDYFILE_PATH): + logger.error(f"Local Caddyfile not found at {CADDYFILE_PATH}") + return {} + + # Import here to avoid circular imports + import re + + entries = {} + try: + with open(CADDYFILE_PATH, "r") as file: + content = file.read() + + pattern = re.compile(r"(?P[^\s{]+(?:,\s*[^\s{]+)*)\s*{.*?reverse_proxy\s+(?Phttps?:\/\/[\d\.]+:\d+|[\d\.]+:\d+).*?}", re.DOTALL) + matches = pattern.findall(content) + + for domains, target in matches: + for domain in domains.split(", "): + domain = domain.strip() + if domain: + entries[domain] = target.strip() + + logger.info(f"Found {len(entries)} domain entries in local Caddyfile") + except Exception as e: + logger.error(f"Error parsing local Caddyfile: {e}") + + return entries @app.route('/') def index(): - filtered_data = {k: v for k, v in proxy_data.items() if k not in deleted_servers} - return render_template('index.html', proxies=filtered_data) + """Render the dashboard homepage""" + data_to_display = {k: v for k, v in proxy_data.items() if k not in deleted_servers} + + # Add local Caddyfile data if available + if USE_LOCAL_CADDYFILE: + local_data = parse_local_caddyfile() + if local_data: + data_to_display["Local Server"] = local_data + + # Add last seen timestamps + timestamps = {server: server_last_seen.get(server, "Never") for server in data_to_display} + + return render_template('index.html', proxies=data_to_display, timestamps=timestamps) -@app.route('/update', methods=['POST']) +@app.route('/api/update', methods=['POST']) def update(): + """Secure API endpoint for agents to update their data""" + # Verify authentication + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + logger.warning("Missing or invalid Authorization header") + return jsonify({"error": "Authentication required"}), 401 + + token = auth_header.split(' ')[1] + payload = verify_token(token) + + if not payload: + return jsonify({"error": "Invalid authentication token"}), 401 + + # Verify the server in the token matches the data data = request.json if not data or "server" not in data or "entries" not in data: - return jsonify({"error": "Invalid data"}), 400 - + return jsonify({"error": "Invalid data format"}), 400 + + if payload.get('server') != data["server"]: + logger.warning(f"Server mismatch: {payload.get('server')} vs {data['server']}") + return jsonify({"error": "Server authentication mismatch"}), 403 + + # Update the data server_name = data["server"] proxy_data[server_name] = data["entries"] - + server_last_seen[server_name] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if server_name in deleted_servers: deleted_servers.remove(server_name) - + + logger.info(f"Updated data for server: {server_name} with {len(data['entries'])} entries") return jsonify({"message": "Updated successfully"}), 200 @app.route('/delete', methods=['POST']) def delete_entry(): + """Delete a server from the dashboard""" data = request.json server_name = data.get("server") if not server_name or server_name not in proxy_data: return jsonify({"error": "Server not found"}), 400 deleted_servers.add(server_name) + logger.info(f"Server {server_name} marked as deleted") return jsonify({"message": f"Server {server_name} deleted"}), 200 @app.route('/status/') @@ -46,4 +151,19 @@ def check_status(domain): return jsonify({"status": "offline"}) if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000) + if USE_LOCAL_CADDYFILE: + logger.info(f"Local Caddyfile found at {CADDYFILE_PATH} - will display its data") + # Load it initially + local_data = parse_local_caddyfile() + if local_data: + proxy_data["Local Server"] = local_data + server_last_seen["Local Server"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + if not API_KEY: + logger.warning("API_KEY not set - running without authentication!") + + # Use HTTPS in production + if DEBUG_MODE: + app.run(host='0.0.0.0', port=5000, debug=True) + else: + app.run(host='0.0.0.0', port=5000) diff --git a/compose.yml b/compose.yml index fef4c42..5fed1c0 100644 --- a/compose.yml +++ b/compose.yml @@ -1,5 +1,26 @@ services: - caddydb: + # Server mode + caddydb-server: image: caddydb:latest ports: - - 5000:5000 + - "5000:5000" + environment: + - API_KEY=${API_KEY} + - DEBUG_MODE=false + volumes: + - ./.env:/app/.env:ro # Mount .env file + command: server + restart: unless-stopped + + # Agent mode (example) + caddydb-agent: + image: caddydb:latest + volumes: + - /opt/docker/caddy/conf/Caddyfile:/opt/docker/caddy/conf/Caddyfile:ro + - ./.env:/app/.env:ro # Mount .env file + environment: + - API_KEY=${API_KEY} + - DASHBOARD_URL=http://caddydb-server:5000/api/update + - SERVER_NAME=caddy-server-1 + command: agent + restart: unless-stopped diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..e4615b3 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +if [ "$1" = "server" ]; then + echo "Starting in server mode..." + exec python app.py +elif [ "$1" = "agent" ]; then + echo "Starting in agent mode..." + exec python agent.py +else + echo "Unknown mode: $1" + echo "Usage: $0 [server|agent]" + exit 1 +fi \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 30692b7..8d6c76e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,6 @@ flask requests +pyjwt +cryptography +watchdog +python-dotenv \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 2671a91..0e8d802 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,5 +1,6 @@ + @@ -26,8 +27,8 @@ function deleteServer(serverName) { fetch('/delete', { method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({"server": serverName}) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ "server": serverName }) }).then(response => { if (response.ok) { document.getElementById("server-box-" + serverName).remove(); @@ -35,7 +36,7 @@ }); } - document.addEventListener("keydown", function(event) { + document.addEventListener("keydown", function (event) { if (event.key === "/") { event.preventDefault(); toggleSearch(); @@ -43,29 +44,39 @@ }); +

Caddy Dashboard

Übersicht über aller aktiven Proxy-Server

- +
- + {% for server, entries in proxies.items() %}
-

{{ server }}

- +
+

{{ server }}

+

Zuletzt aktualisiert: {{ timestamps[server] }}

+
+
{% for domain, target in entries.items() %} -
+

{{ target }}

@@ -77,4 +88,5 @@
- + + \ No newline at end of file