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

View File

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

View File

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

View File

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