diff --git a/.gitignore b/.gitignore index f37795b..11bf0a6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ *.pyd wireguard_webadmin/production_settings.py .idea/ +containers/*/.venv +.env \ No newline at end of file diff --git a/api/views.py b/api/views.py index 8abb975..fdb7aaa 100644 --- a/api/views.py +++ b/api/views.py @@ -25,6 +25,8 @@ def get_api_key(api_name): api_file_path = '/etc/wireguard/api_key' elif api_name == 'routerfleet': api_file_path = '/etc/wireguard/routerfleet_key' + elif api_name == 'rrdkey': + api_file_path = '/app_secrets/rrdtool_key' else: return api_key @@ -126,6 +128,12 @@ def wireguard_status(request): pass else: return HttpResponseForbidden() + elif request.GET.get('rrdkey'): + api_key = get_api_key('rrdkey') + if api_key and api_key == request.GET.get('rrdkey'): + pass + else: + return HttpResponseForbidden() else: return HttpResponseForbidden() diff --git a/containers/rrdtool/Dockerfile-rrdtool b/containers/rrdtool/Dockerfile-rrdtool new file mode 100644 index 0000000..2316430 --- /dev/null +++ b/containers/rrdtool/Dockerfile-rrdtool @@ -0,0 +1,17 @@ +FROM python:3.12-slim +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + net-tools \ + inetutils-ping \ + inetutils-traceroute \ + nano \ + vim-nox \ + rrdtool \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY wgrrd.py . + +CMD ["python", "-u", "wgrrd.py"] diff --git a/containers/rrdtool/requirements.txt b/containers/rrdtool/requirements.txt new file mode 100644 index 0000000..65b82c1 --- /dev/null +++ b/containers/rrdtool/requirements.txt @@ -0,0 +1,5 @@ +certifi==2025.1.31 +charset-normalizer==3.4.1 +idna==3.10 +requests==2.32.3 +urllib3==2.3.0 \ No newline at end of file diff --git a/containers/rrdtool/wgrrd.py b/containers/rrdtool/wgrrd.py new file mode 100755 index 0000000..ede62e1 --- /dev/null +++ b/containers/rrdtool/wgrrd.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +import os +import sys +import uuid +import base64 +import time +import requests +import subprocess + +# Global variables +DEBUG = True +API_ADDRESS = "http://127.0.0.1:8000" + +# Base directory for storing RRD files +RRD_DATA_PATH = "/rrd_data" +RRD_PEERS_PATH = os.path.join(RRD_DATA_PATH, "peers") +RRD_WGINSTANCES_PATH = os.path.join(RRD_DATA_PATH, "wginstances") + + +def debug_log(message): + """ + Prints a debug message with a timestamp if DEBUG is enabled. + Timestamp format is similar to syslog (e.g. "Mar 10 14:55:02"). + """ + if DEBUG: + timestamp = time.strftime("%b %d %H:%M:%S") + print(f"{timestamp} - DEBUG - {message}") + + +def get_api_key(): + """ + Reads the API key from a file and validates it as a UUID. + Returns the API key as a string if valid, otherwise returns None. + """ + api_key = None + api_file_path = "/app_secrets/rrdtool_key" + + if os.path.exists(api_file_path) and os.path.isfile(api_file_path): + with open(api_file_path, 'r') as file: + key_content = file.read().strip() + try: + uuid_obj = uuid.UUID(key_content) + if str(uuid_obj) == key_content: + api_key = str(uuid_obj) + except Exception as e: + debug_log(f"Error validating API key: {e}") + return api_key + + +def create_peer_rrd(rrd_file): + """ + Creates an RRD file for a peer with 3 data sources: + - tx: COUNTER + - rx: COUNTER + - status: GAUGE + """ + cmd = [ + "rrdtool", "create", rrd_file, + "--step", "300", + "DS:tx:COUNTER:600:0:U", + "DS:rx:COUNTER:600:0:U", + "DS:status:GAUGE:600:0:1", + "RRA:AVERAGE:0.5:1:1440", + "RRA:AVERAGE:0.5:6:700", + "RRA:AVERAGE:0.5:24:775", + "RRA:AVERAGE:0.5:288:797" + ] + try: + subprocess.run(cmd, check=True) + debug_log(f"Created peer RRD file: {rrd_file}") + except subprocess.CalledProcessError as e: + debug_log(f"Error creating peer RRD file: {rrd_file} {e}") + + +def create_instance_rrd(rrd_file): + """ + Creates an RRD file for a wireguard instance with 2 data sources: + - tx: COUNTER + - rx: COUNTER + """ + cmd = [ + "rrdtool", "create", rrd_file, + "--step", "300", + "DS:tx:COUNTER:600:0:U", + "DS:rx:COUNTER:600:0:U", + "RRA:AVERAGE:0.5:1:1440", + "RRA:AVERAGE:0.5:6:700", + "RRA:AVERAGE:0.5:24:775", + "RRA:AVERAGE:0.5:288:797" + ] + try: + subprocess.run(cmd, check=True) + debug_log(f"Created instance RRD file: {rrd_file}") + except subprocess.CalledProcessError as e: + debug_log(f"Error creating instance RRD file: {rrd_file} {e}") + + +def process_peer(peer_key, peer_data): + """ + Processes a single peer: + - Converts the peer key to a URL-safe base64 string (removing any '=' characters) + - Constructs the RRD file path for the peer (stored in RRD_PEERS_PATH) + - Extracts the transfer data (tx and rx) from the peer data + - Determines host status (1 for online, 0 for offline) based on the latest-handshakes value + - Creates the RRD file if it does not exist + - Executes the rrdtool command to update the RRD database with tx, rx, and status + """ + # Convert peer_key to URL-safe base64 and remove '=' characters + b64_peer = base64.urlsafe_b64encode(peer_key.encode()).decode().replace("=", "") + # Define the peer RRD file path + rrd_file = os.path.join(RRD_PEERS_PATH, f"{b64_peer}.rrd") + + # Create the RRD file if it doesn't exist + if not os.path.exists(rrd_file): + create_peer_rrd(rrd_file) + + # Extract transfer data (tx and rx) + tx = peer_data.get("transfer", {}).get("tx", 0) + rx = peer_data.get("transfer", {}).get("rx", 0) + + # Determine host status based on latest-handshakes. + # Host is offline (0) if latest-handshakes is "0" or if more than 5 minutes (300s) have passed. + latest_handshake = peer_data.get("latest-handshakes", "0") + try: + last_time = int(latest_handshake) + except ValueError: + last_time = 0 + + current_time = int(time.time()) + status = 0 if (last_time == 0 or (current_time - last_time) > 300) else 1 + + # Build the update command for rrdtool (syntax: "N:::") + update_str = f"N:{tx}:{rx}:{status}" + cmd = ["rrdtool", "update", rrd_file, update_str] + + debug_log("Executing peer update command: " + " ".join(cmd)) + + try: + subprocess.run(cmd, check=True) + except subprocess.CalledProcessError as e: + debug_log(f"Error updating RRD for peer {peer_key} (file {rrd_file}): {e}") + + +def update_instance(interface, total_tx, total_rx): + """ + Updates the RRD file for a wireguard instance corresponding to an interface. + The file is stored in RRD_WGINSTANCES_PATH with the name .rrd. + If the file does not exist, it will be created. + The update command is: "N::". + """ + instance_file = os.path.join(RRD_WGINSTANCES_PATH, f"{interface}.rrd") + + # Create the instance RRD file if it doesn't exist + if not os.path.exists(instance_file): + create_instance_rrd(instance_file) + + update_str = f"N:{total_tx}:{total_rx}" + cmd = ["rrdtool", "update", instance_file, update_str] + + debug_log("Executing instance update command: " + " ".join(cmd)) + + try: + subprocess.run(cmd, check=True) + except subprocess.CalledProcessError as e: + debug_log(f"Error updating RRD for instance {interface} (file {instance_file}): {e}") + + +def main_loop(): + """ + Main loop that: + - Ensures the necessary directories exist + - Retrieves the API key (terminating the script if invalid) + - Queries the wireguard status API every 5 minutes + - Processes each peer using process_peer() + - Aggregates total tx and rx per interface and updates the corresponding instance RRD + - Waits until 5 minutes have passed since the beginning of the loop before restarting + """ + # Ensure directories exist + os.makedirs(RRD_PEERS_PATH, exist_ok=True) + os.makedirs(RRD_WGINSTANCES_PATH, exist_ok=True) + + # Retrieve API key before entering the loop; exit if invalid. + api_key = get_api_key() + if not api_key: + print("API key not found or invalid. Exiting.") + sys.exit(1) + + while True: + loop_start = time.time() + # Refresh the API key on every iteration in case the file changes + api_key = get_api_key() + if not api_key: + print("API key not found or invalid. Exiting.") + sys.exit(1) + + # Build the URL for the API call + url = f"{API_ADDRESS}/api/wireguard_status/?rrdkey={api_key}" + debug_log("Querying API at: " + url) + try: + response = requests.get(url, timeout=10) + response.raise_for_status() + data = response.json() + except Exception as e: + debug_log("Error fetching or parsing API data: " + str(e)) + elapsed = time.time() - loop_start + time.sleep(max(300 - elapsed, 0)) + continue + + # Process each interface and its peers, aggregate tx and rx for the interface + for interface, peers in data.items(): + debug_log(f"Processing interface: {interface}") + total_tx = 0 + total_rx = 0 + for peer_key, peer_info in peers.items(): + process_peer(peer_key, peer_info) + # Sum the tx and rx values for this interface + tx = peer_info.get("transfer", {}).get("tx", 0) + rx = peer_info.get("transfer", {}).get("rx", 0) + total_tx += tx + total_rx += rx + + # Update the RRD for the wireguard instance with aggregated values + update_instance(interface, total_tx, total_rx) + + # Calculate elapsed time and wait the remaining time to complete 5 minutes + elapsed = time.time() - loop_start + sleep_time = max(300 - elapsed, 0) + debug_log(f"Waiting {sleep_time:.2f} seconds until next execution.") + time.sleep(sleep_time) + + +if __name__ == '__main__': + main_loop() diff --git a/docker-compose-no-nginx-dev.yml b/docker-compose-no-nginx-dev.yml index 1d3fc47..c6d53eb 100644 --- a/docker-compose-no-nginx-dev.yml +++ b/docker-compose-no-nginx-dev.yml @@ -8,12 +8,14 @@ services: environment: - SERVER_ADDRESS=127.0.0.1 - DEBUG_MODE=True - - COMPOSE_VERSION=02b + - COMPOSE_VERSION=02r volumes: - wireguard:/etc/wireguard - static_volume:/app_static_files/ - .:/app - dnsmasq_conf:/etc/dnsmasq + - app_secrets:/app_secrets/ + - rrd_data:/rrd_data/ ports: # Do not directly expose the Django port to the internet, use some kind of reverse proxy with SSL. - "127.0.0.1:8000:8000" @@ -40,6 +42,18 @@ services: depends_on: - wireguard-webadmin + wireguard-webadmin-rrdtool: + container_name: wireguard-webadmin-rrdtool + restart: unless-stopped + build: + context: ./containers/rrdtool + dockerfile: Dockerfile-rrdtool + volumes: + - app_secrets:/app_secrets/ + - rrd_data:/rrd_data/ + depends_on: + - wireguard-webadmin + wireguard-webadmin-dns: container_name: wireguard-webadmin-dns restart: unless-stopped @@ -52,4 +66,6 @@ services: volumes: static_volume: wireguard: - dnsmasq_conf: \ No newline at end of file + dnsmasq_conf: + app_secrets: + rrd_data: \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh index cdbd17b..3f5e0bd 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,7 +2,7 @@ set -e -if [[ "$COMPOSE_VERSION" != "02b" ]]; then +if [[ "$COMPOSE_VERSION" != "02r" ]]; then echo "ERROR: Please upgrade your docker compose file. Exiting." exit 1 fi @@ -17,6 +17,10 @@ if [[ "${DEBUG_MODE,,}" == "true" ]]; then DEBUG_VALUE="True" fi +if [ ! -f /app_secrets/rrdtool_key ]; then + cat /proc/sys/kernel/random/uuid > /app_secrets/rrdtool_key +fi + cat > /app/wireguard_webadmin/production_settings.py <