diff --git a/.env.cluster_example b/.env.cluster_example new file mode 100644 index 0000000..32031ce --- /dev/null +++ b/.env.cluster_example @@ -0,0 +1,21 @@ +# Example configuration for Cluster Node +# Rename this file to .env before starting + +# Master Server Address (where the API is hosting) +# Example: https://wireguard.example.com +MASTER_SERVER_ADDRESS=https://wireguard.example.com + +# Timezone +TZ=America/Sao_Paulo + +# Cluster Worker Token (Get this from the Master Server) +TOKEN= + +# Debug Mode (optional, default False) +DEBUG_MODE=False + +# Compose Version (Do not change) +COMPOSE_VERSION=03r + +# Timezone (duplicate for compatibility if needed, but TZ is standard) +TIMEZONE=America/Sao_Paulo diff --git a/cluster_node.yml b/cluster_node.yml new file mode 100644 index 0000000..dfd196c --- /dev/null +++ b/cluster_node.yml @@ -0,0 +1,28 @@ +version: '3' +services: + wireguard-webadmin-cluster-node: + container_name: wireguard-webadmin-cluster-node + restart: unless-stopped + build: + context: containers/cluster_node + dockerfile: Dockerfile-cluster_node + environment: + - MASTER_SERVER_ADDRESS=${MASTER_SERVER_ADDRESS} + - DEBUG_MODE=${DEBUG_MODE} + - COMPOSE_VERSION=03r + - TZ=${TIMEZONE} + - TOKEN=${TOKEN} + volumes: + - cluster_node_wireguard:/etc/wireguard + #ports: + # Ports for WireGuard instances. + #- "51820-51839:51820-51839/udp" + cap_add: + - NET_ADMIN + - SYS_MODULE + sysctls: + - net.ipv4.conf.all.src_valid_mark=1 + - net.ipv4.ip_forward=1 + +volumes: + cluster_node_wireguard: diff --git a/containers/cluster_node/Dockerfile-cluster_node b/containers/cluster_node/Dockerfile-cluster_node new file mode 100644 index 0000000..028d5c4 --- /dev/null +++ b/containers/cluster_node/Dockerfile-cluster_node @@ -0,0 +1,33 @@ +# Single stage build for Cluster Node +FROM python:3.12-slim +WORKDIR /app + +# Install necessary runtime packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + wireguard \ + iptables \ + iproute2 \ + net-tools \ + inetutils-ping \ + inetutils-traceroute \ + procps \ + curl \ + nano \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python packages +# Using the specific requirements.txt for this container +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the application code +COPY . /app/ + +# Set execution permissions on scripts +RUN chmod +x /app/entrypoint.sh + +ARG SERVER_ADDRESS +ARG DEBUG_MODE + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["python", "-u", "/app/cluster_worker.py"] diff --git a/containers/cluster_node/cluster_worker.py b/containers/cluster_node/cluster_worker.py new file mode 100644 index 0000000..fc223ee --- /dev/null +++ b/containers/cluster_node/cluster_worker.py @@ -0,0 +1,171 @@ +import glob +import logging +import os +import subprocess +import time + +import requests + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Constants +MASTER_SERVER_ADDRESS = os.environ.get('MASTER_SERVER_ADDRESS') +TOKEN = os.environ.get('TOKEN') +WIREGUARD_DIR = '/etc/wireguard' +WORKER_VERSION = 10 +REQUEST_TIMEOUT = 10 + +class ClusterWorker: + def __init__(self): + self.config_version = 0 + self.should_run = True + self.session = requests.Session() + + if not MASTER_SERVER_ADDRESS or not TOKEN: + logger.error("MASTER_SERVER_ADDRESS or TOKEN not set") + self.should_run = False + + self.base_url = f"https://{MASTER_SERVER_ADDRESS}/api/cluster" + # Validate URL scheme + if not MASTER_SERVER_ADDRESS.startswith(('http://', 'https://')): + self.base_url = f"https://{MASTER_SERVER_ADDRESS}/api/cluster" + else: + self.base_url = f"{MASTER_SERVER_ADDRESS}/api/cluster" + + def cleanup_wireguard(self): + logger.info("Cleaning up WireGuard configuration...") + # Stop all wireguard interfaces + try: + files = glob.glob(f"{WIREGUARD_DIR}/*.conf") + for f in files: + interface = os.path.basename(f).replace('.conf', '') + subprocess.run(['wg-quick', 'down', interface], capture_output=True) + except Exception as e: + logger.error(f"Error stopping wireguard interfaces: {e}") + + # Remove files + try: + for f in glob.glob(f"{WIREGUARD_DIR}/*"): + os.remove(f) + except Exception as e: + logger.error(f"Error cleaning directory: {e}") + + try: + subprocess.run(['iptables', '-t', 'nat', '-F', 'WGWADM_POSTROUTING'], capture_output=True) + subprocess.run(['iptables', '-t', 'nat', '-F', 'WGWADM_PREROUTING'], capture_output=True) + subprocess.run(['iptables', '-t', 'filter', '-F', 'WGWADM_FORWARD'], capture_output=True) + except Exception as e: + logger.error(f"Error flushing firewall: {e}") + + def get_status(self): + params = { + 'token': TOKEN, + 'worker_config_version': self.config_version, + 'worker_version': WORKER_VERSION + } + try: + response = self.session.get(f"{self.base_url}/status/", params=params, timeout=REQUEST_TIMEOUT) + return response + except requests.RequestException as e: + logger.error(f"Connection error: {e}") + return None + + def download_configs(self): + params = { + 'token': TOKEN, + 'worker_config_version': self.config_version, + 'worker_version': WORKER_VERSION + } + try: + response = self.session.get(f"{self.base_url}/worker/get_config_files/", params=params, timeout=REQUEST_TIMEOUT) + if response.status_code == 200: + data = response.json() + if data.get('status') == 'success': + return data + return None + except requests.RequestException as e: + logger.error(f"Error downloading configs: {e}") + return None + + def apply_configs(self, data): + logger.info("Applying new configurations...") + files = data.get('files', {}) + cluster_settings = data.get('cluster_settings', {}) + new_config_version = cluster_settings.get('config_version', 0) + + # 1. Stop existing interfaces + self.cleanup_wireguard() + + # 2. Write new files + for filename, content in files.items(): + filepath = os.path.join(WIREGUARD_DIR, filename) + with open(filepath, 'w') as f: + f.write(content) + + if filename == 'wg-firewall.sh': + os.chmod(filepath, 0o755) + + # Start interfaces + conf_files = glob.glob(f"{WIREGUARD_DIR}/*.conf") + for conf in conf_files: + interface = os.path.basename(conf).replace('.conf', '') + logger.info(f"Starting WireGuard interface: {interface}") + try: + subprocess.run(['wg-quick', 'up', interface], check=True, capture_output=True) + except subprocess.CalledProcessError as e: + logger.error(f"Failed to start {interface}: {e.stderr.decode()}") + + # 4. Update config version + self.config_version = new_config_version + logger.info(f"Configuration updated to version {self.config_version}") + + def run(self): + if not self.should_run: + return + + logger.info("Cluster Worker starting...") + + # Initial cleanup + self.cleanup_wireguard() + + while True: + try: + response = self.get_status() + + if response: + if response.status_code == 403: + logger.error("Received 403 Forbidden. Shutting down WireGuard and stopping requests.") + self.cleanup_wireguard() + self.should_run = False + break + + if response.status_code == 200: + data = response.json() + remote_config_version = data.get('cluster_settings', {}).get('config_version', 0) + + if remote_config_version != self.config_version: + logger.info(f"Config version mismatch (Local: {self.config_version}, Remote: {remote_config_version}). Updating...") + config_data = self.download_configs() + if config_data: + self.apply_configs(config_data) + else: + logger.error("Failed to download config files.") + + except Exception as e: + logger.error(f"Unexpected error in main loop: {e}") + + time.sleep(60) + + # Final loop state if 403 was received + while not self.should_run: + logger.info("Worker disabled due to 403 Forbidden. WireGuard is off.") + time.sleep(60) + +if __name__ == "__main__": + worker = ClusterWorker() + worker.run() diff --git a/containers/cluster_node/entrypoint.sh b/containers/cluster_node/entrypoint.sh new file mode 100644 index 0000000..3825e44 --- /dev/null +++ b/containers/cluster_node/entrypoint.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -e + +# Set Timezone +if [ -n "$TZ" ]; then + ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +fi + +# Enable IP forwarding +sysctl -w net.ipv4.ip_forward=1 > /dev/null 2>&1 || echo "Warning: Could not set net.ipv4.ip_forward" + +# Check required variables +if [ -z "$MASTER_SERVER_ADDRESS" ]; then + echo "ERROR: MASTER_SERVER_ADDRESS is not set." + exit 1 +fi + +if [ -z "$TOKEN" ]; then + echo "ERROR: TOKEN is not set." + exit 1 +fi + +# Check MASTER_SERVER_ADDRESS for HTTPS +if [[ "$MASTER_SERVER_ADDRESS" != https://* ]]; then + if [[ "$MASTER_SERVER_ADDRESS" == http://192.168.* ]]; then + echo "Warning: Using HTTP only for development." + else + echo "ERROR: MASTER_SERVER_ADDRESS must start with https://. Received: $MASTER_SERVER_ADDRESS" + exit 1 + fi +fi + +exec "$@" diff --git a/containers/cluster_node/requirements.txt b/containers/cluster_node/requirements.txt new file mode 100644 index 0000000..8416ee0 --- /dev/null +++ b/containers/cluster_node/requirements.txt @@ -0,0 +1,5 @@ +certifi==2025.11.12 +charset-normalizer==3.4.4 +idna==3.11 +requests==2.32.5 +urllib3==2.6.2 diff --git a/docker-compose-no-nginx-dev.yml b/docker-compose-no-nginx-dev.yml index 4a3b713..b8f39bf 100644 --- a/docker-compose-no-nginx-dev.yml +++ b/docker-compose-no-nginx-dev.yml @@ -20,7 +20,7 @@ services: - 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" + - "8000:8000" # Warning: Docker will have a hard time handling large amount of ports. Expose only the ports that you need. # Ports for multiple WireGuard instances. (Probably, you just need one) - "51820-51839:51820-51839/udp" @@ -76,4 +76,4 @@ volumes: wireguard: dnsmasq_conf: app_secrets: - rrd_data: \ No newline at end of file + rrd_data: diff --git a/wireguard_webadmin/urls.py b/wireguard_webadmin/urls.py index 1ad3895..7fc4ce7 100644 --- a/wireguard_webadmin/urls.py +++ b/wireguard_webadmin/urls.py @@ -14,7 +14,6 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.contrib import admin from django.urls import path from accounts.views import view_create_first_user, view_login, view_logout @@ -40,7 +39,7 @@ from wireguard_peer.views import view_manage_ip_address, view_wireguard_peer_lis from wireguard_tools.views import download_config_or_qrcode, export_wireguard_configs, restart_wireguard_interfaces urlpatterns = [ - path('admin/', admin.site.urls), + # path('admin/', admin.site.urls), path('', view_apply_db_patches, name='apply_db_patches'), path('status/', view_wireguard_status, name='wireguard_status'), path('dns/', view_static_host_list, name='static_host_list'),