diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d80363f --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Dashboard auth for external clients (non-RFC1918 source IPs) +DASHBOARD_AUTH_USERNAME=admin +DASHBOARD_AUTH_PASSWORD=change-me-now diff --git a/DASHBOARD.md b/DASHBOARD.md index b503994..bbad80b 100644 --- a/DASHBOARD.md +++ b/DASHBOARD.md @@ -154,8 +154,14 @@ curl http://localhost:8080/api/health | jq ## Security Note -The dashboard is exposed on `0.0.0.0:8080` making it accessible from your network. If you're running this on a public server, consider: +The dashboard is exposed on `0.0.0.0:8080`. +Requests from localhost/LAN private ranges are allowed directly. +Requests from public/external IPs require HTTP Basic Auth. -1. Using a reverse proxy (nginx) with authentication -2. Restricting access with firewall rules -3. Using HTTPS with SSL certificates +Set credentials with: + +```bash +# .env (copy from .env.example) +DASHBOARD_AUTH_USERNAME=admin +DASHBOARD_AUTH_PASSWORD=change-me-now +``` diff --git a/README.md b/README.md index 9f78b10..8fe5b3a 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,7 @@ For your ElectrumX server to be accessible from the internet, you **must** confi **Security Notes:** - Only forward port **8080** if you want the dashboard accessible from internet (not recommended without authentication) - Consider using a VPN for dashboard access instead +- External dashboard clients (public IPs) require Basic Auth. Configure `DASHBOARD_AUTH_USERNAME` and `DASHBOARD_AUTH_PASSWORD` in `.env` (see `.env.example`). - Ports **50001** and **50002** need to be public for Electrum wallets to connect - Port **2333** is required for the node to sync with the Palladium network diff --git a/docker-compose.yml b/docker-compose.yml index 5d2774d..ea9c269 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,6 +91,8 @@ services: PALLADIUM_RPC_PORT: "2332" ELECTRUMX_RPC_HOST: "electrumx" ELECTRUMX_RPC_PORT: "8000" + DASHBOARD_AUTH_USERNAME: "${DASHBOARD_AUTH_USERNAME:-admin}" + DASHBOARD_AUTH_PASSWORD: "${DASHBOARD_AUTH_PASSWORD:-change-me-now}" volumes: - ./.palladium/palladium.conf:/palladium-config/palladium.conf:ro diff --git a/web-dashboard/app.py b/web-dashboard/app.py index 3916694..0839c69 100644 --- a/web-dashboard/app.py +++ b/web-dashboard/app.py @@ -12,6 +12,9 @@ import time import copy import threading import ssl +import ipaddress +import base64 +import hmac from datetime import datetime import psutil import socket @@ -19,6 +22,66 @@ import socket app = Flask(__name__) CORS(app) +TRUSTED_CLIENT_NETWORKS = ( + ipaddress.ip_network('127.0.0.0/8'), + ipaddress.ip_network('10.0.0.0/8'), + ipaddress.ip_network('172.16.0.0/12'), + ipaddress.ip_network('192.168.0.0/16'), + ipaddress.ip_network('::1/128'), +) + + +def is_trusted_client_ip(ip_text): + """Allow direct access for localhost and private RFC1918 LAN clients.""" + if not ip_text: + return False + try: + ip_obj = ipaddress.ip_address(ip_text.strip()) + if getattr(ip_obj, 'ipv4_mapped', None): + ip_obj = ip_obj.ipv4_mapped + return any(ip_obj in network for network in TRUSTED_CLIENT_NETWORKS) + except ValueError: + return False + + +def unauthorized_response(): + response = jsonify({'error': 'Authentication required'}) + response.status_code = 401 + response.headers['WWW-Authenticate'] = 'Basic realm="Palladium Dashboard"' + return response + + +@app.before_request +def enforce_external_auth(): + """ + Localhost and LAN clients can access directly. + External clients must authenticate via Basic Auth credentials from env vars. + """ + client_ip = request.remote_addr or '' + if is_trusted_client_ip(client_ip): + return None + + expected_user = os.getenv('DASHBOARD_AUTH_USERNAME', '').strip() + expected_pass = os.getenv('DASHBOARD_AUTH_PASSWORD', '').strip() + if not expected_user or not expected_pass: + return jsonify({'error': 'Dashboard auth is not configured'}), 503 + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Basic '): + return unauthorized_response() + + try: + decoded = base64.b64decode(auth_header.split(' ', 1)[1]).decode('utf-8') + username, password = decoded.split(':', 1) + except Exception: + return unauthorized_response() + + user_ok = hmac.compare_digest(username, expected_user) + pass_ok = hmac.compare_digest(password, expected_pass) + if not (user_ok and pass_ok): + return unauthorized_response() + return None + @app.after_request def disable_api_cache(response):