feat(dashboard): require basic auth for external clients only

- Allow direct access from localhost and private RFC1918 networks
- Enforce HTTP Basic Auth for non-private/external source IPs
- Read dashboard credentials from compose env vars
- Add .env.example entries for DASHBOARD_AUTH_USERNAME/PASSWORD
- Update README and DASHBOARD docs
This commit is contained in:
2026-02-16 09:41:44 +01:00
parent ae91163168
commit 742b0662a7
5 changed files with 79 additions and 4 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Dashboard auth for external clients (non-RFC1918 source IPs)
DASHBOARD_AUTH_USERNAME=admin
DASHBOARD_AUTH_PASSWORD=change-me-now

View File

@@ -154,8 +154,14 @@ curl http://localhost:8080/api/health | jq
## Security Note ## 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 Set credentials with:
2. Restricting access with firewall rules
3. Using HTTPS with SSL certificates ```bash
# .env (copy from .env.example)
DASHBOARD_AUTH_USERNAME=admin
DASHBOARD_AUTH_PASSWORD=change-me-now
```

View File

@@ -242,6 +242,7 @@ For your ElectrumX server to be accessible from the internet, you **must** confi
**Security Notes:** **Security Notes:**
- Only forward port **8080** if you want the dashboard accessible from internet (not recommended without authentication) - Only forward port **8080** if you want the dashboard accessible from internet (not recommended without authentication)
- Consider using a VPN for dashboard access instead - 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 - 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 - Port **2333** is required for the node to sync with the Palladium network

View File

@@ -91,6 +91,8 @@ services:
PALLADIUM_RPC_PORT: "2332" PALLADIUM_RPC_PORT: "2332"
ELECTRUMX_RPC_HOST: "electrumx" ELECTRUMX_RPC_HOST: "electrumx"
ELECTRUMX_RPC_PORT: "8000" ELECTRUMX_RPC_PORT: "8000"
DASHBOARD_AUTH_USERNAME: "${DASHBOARD_AUTH_USERNAME:-admin}"
DASHBOARD_AUTH_PASSWORD: "${DASHBOARD_AUTH_PASSWORD:-change-me-now}"
volumes: volumes:
- ./.palladium/palladium.conf:/palladium-config/palladium.conf:ro - ./.palladium/palladium.conf:/palladium-config/palladium.conf:ro

View File

@@ -12,6 +12,9 @@ import time
import copy import copy
import threading import threading
import ssl import ssl
import ipaddress
import base64
import hmac
from datetime import datetime from datetime import datetime
import psutil import psutil
import socket import socket
@@ -19,6 +22,66 @@ import socket
app = Flask(__name__) app = Flask(__name__)
CORS(app) 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 @app.after_request
def disable_api_cache(response): def disable_api_cache(response):