This commit is contained in:
pika 2025-03-24 17:51:57 +01:00
parent a4ce8a291d
commit 950d72aba1
8 changed files with 375 additions and 22 deletions

13
.env.example Normal file
View file

@ -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

View file

@ -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"]

166
agent.py Normal file
View file

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

138
app.py
View file

@ -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<domains>[^\s{]+(?:,\s*[^\s{]+)*)\s*{.*?reverse_proxy\s+(?P<target>https?:\/\/[\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/<domain>')
@ -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)

View file

@ -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

13
entrypoint.sh Normal file
View file

@ -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

View file

@ -1,2 +1,6 @@
flask
requests
pyjwt
cryptography
watchdog
python-dotenv

View file

@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@ -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 @@
});
</script>
</head>
<body class="bg-gray-900 text-gray-100">
<header class="bg-indigo-700 text-white p-6 text-center">
<h1 class="text-3xl font-bold">Caddy Dashboard</h1>
<p class="text-cyan-300 text-lg">Übersicht über aller aktiven Proxy-Server</p>
<button onclick="toggleSearch()" class="bg-cyan-500 hover:bg-cyan-400 text-white text-right px-6 py-3 rounded-lg mt-4 text-lg">🔍 Suche</button>
<button onclick="toggleSearch()"
class="bg-cyan-500 hover:bg-cyan-400 text-white text-right px-6 py-3 rounded-lg mt-4 text-lg">🔍
Suche</button>
</header>
<div class="container mx-auto p-4 md:p-6">
<input type="text" id="search-box" class="hidden w-full p-4 mb-4 text-gray-900 text-lg rounded-md" placeholder="🔍 Suche nach Subdomains.." onkeyup="filterEntries()">
<input type="text" id="search-box" class="hidden w-full p-4 mb-4 text-gray-900 text-lg rounded-md"
placeholder="🔍 Suche nach Subdomains.." onkeyup="filterEntries()">
{% for server, entries in proxies.items() %}
<div id="server-box-{{ server }}" class="bg-gray-800 p-6 rounded-lg shadow-lg mb-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center">
<h2 class="text-2xl font-semibold text-indigo-400">{{ server }}</h2>
<button onclick="deleteServer('{{ server }}')" class="mt-3 md:mt-0 bg-red-500 text-white px-6 py-2 rounded-lg text-lg">🗑️ Löschen</button>
<div>
<h2 class="text-2xl font-semibold text-indigo-400">{{ server }}</h2>
<p class="text-sm text-gray-400">Zuletzt aktualisiert: {{ timestamps[server] }}</p>
</div>
<button onclick="deleteServer('{{ server }}')"
class="mt-3 md:mt-0 bg-red-500 text-white px-6 py-2 rounded-lg text-lg">🗑️ Löschen</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
{% for domain, target in entries.items() %}
<div class="domain-card bg-gray-700 p-4 rounded-lg shadow-md flex flex-col space-y-2" data-domain="{{ domain }}">
<div class="domain-card bg-gray-700 p-4 rounded-lg shadow-md flex flex-col space-y-2"
data-domain="{{ domain }}">
<div class="flex justify-between">
<a href="https://{{ domain }}" target="_blank" class="text-indigo-400 hover:text-indigo-300 text-lg break-words">{{ domain }}</a>
<a href="https://{{ domain }}" target="_blank"
class="text-indigo-400 hover:text-indigo-300 text-lg break-words">{{ domain }}</a>
<!-- <button onclick="checkStatus('{{ domain }}', this)" class="bg-cyan-500 text-white px-4 py-2 rounded-lg text-sm">🔄 Prüfen</button> -->
</div>
<p class="text-gray-300 break-words text-lg">{{ target }}</p>
@ -77,4 +88,5 @@
</div>
</body>
</html>
</html>