Improve ElectrumX discovery consistency and reachability checks

Unify dashboard and servers page data source to keep active server counts synchronized.

Remove hardcoded port fallbacks and derive TCP/SSL ports from advertised services.

Harden TCP/SSL probing (self-signed SSL support, better timeouts, peer-port-aware checks).

Simplify Discovery Summary UI to show only total active servers.
This commit is contained in:
2026-02-13 14:22:07 +01:00
parent ed5a34438a
commit 8e0aaecaa9
4 changed files with 212 additions and 57 deletions

View File

@@ -11,6 +11,7 @@ import os
import time import time
import copy import copy
import threading import threading
import ssl
from datetime import datetime from datetime import datetime
import psutil import psutil
import socket import socket
@@ -64,7 +65,49 @@ def parse_addnode_hosts(conf_path='/palladium-config/palladium.conf'):
return hosts return hosts
def probe_electrum_server(host, port=50001, timeout=1.2): def parse_services_ports(services):
"""Extract TCP/SSL ports from SERVICES string (e.g. tcp://0.0.0.0:50001,ssl://0.0.0.0:50002)."""
tcp_port = None
ssl_port = None
for item in services.split(','):
item = item.strip()
if item.startswith('tcp://') and ':' in item:
try:
tcp_port = int(item.rsplit(':', 1)[1])
except ValueError:
pass
if item.startswith('ssl://') and ':' in item:
try:
ssl_port = int(item.rsplit(':', 1)[1])
except ValueError:
pass
return tcp_port, ssl_port
def get_electrumx_service_ports():
"""
Resolve ElectrumX service ports dynamically.
Priority:
1) SERVICES env from electrumx container
2) local SERVICES env (if provided)
"""
try:
import subprocess
result = subprocess.run(
['docker', 'exec', 'electrumx-server', 'sh', '-c', 'printf "%s" "${SERVICES:-}"'],
capture_output=True,
text=True,
timeout=2
)
if result.returncode == 0 and result.stdout.strip():
return parse_services_ports(result.stdout.strip())
except Exception:
pass
return parse_services_ports(os.getenv('SERVICES', ''))
def probe_electrum_server(host, port, timeout=2.0):
"""Check if an Electrum server is reachable and speaking protocol on host:port""" """Check if an Electrum server is reachable and speaking protocol on host:port"""
try: try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -76,11 +119,72 @@ def probe_electrum_server(host, port=50001, timeout=1.2):
"method": "server.version", "method": "server.version",
"params": ["palladium-dashboard", "1.4"] "params": ["palladium-dashboard", "1.4"]
} }
sock.send((json.dumps(request) + '\n').encode()) sock.sendall((json.dumps(request) + '\n').encode())
response = sock.recv(4096).decode()
data = b""
for _ in range(6):
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
candidate = data.decode(errors='ignore')
candidate = candidate.split("\n", 1)[0].strip()
if candidate:
try:
payload = json.loads(candidate)
sock.close()
return 'result' in payload
except Exception:
pass
sock.close() sock.close()
data = json.loads(response) line = data.split(b"\n", 1)[0].decode(errors='ignore').strip() if data else ""
if 'result' in data: payload = json.loads(line) if line else {}
if 'result' in payload:
return True
except Exception:
return False
return False
def probe_electrum_server_ssl(host, port, timeout=2.0):
"""Check if an Electrum SSL server is reachable on host:port (self-signed allowed)."""
try:
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
raw_sock.settimeout(timeout)
raw_sock.connect((host, port))
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
ssl_sock = context.wrap_socket(raw_sock, server_hostname=host)
ssl_sock.settimeout(timeout)
request = {
"jsonrpc": "2.0",
"id": 101,
"method": "server.version",
"params": ["palladium-dashboard", "1.4"]
}
ssl_sock.sendall((json.dumps(request) + '\n').encode())
data = b""
for _ in range(6):
chunk = ssl_sock.recv(4096)
if not chunk:
break
data += chunk
candidate = data.decode(errors='ignore')
candidate = candidate.split("\n", 1)[0].strip()
if candidate:
try:
payload = json.loads(candidate)
ssl_sock.close()
return 'result' in payload
except Exception:
pass
ssl_sock.close()
line = data.split(b"\n", 1)[0].decode(errors='ignore').strip() if data else ""
payload = json.loads(line) if line else {}
if 'result' in payload:
return True return True
except Exception: except Exception:
return False return False
@@ -89,10 +193,13 @@ def probe_electrum_server(host, port=50001, timeout=1.2):
def is_electrumx_reachable(timeout=1.0): def is_electrumx_reachable(timeout=1.0):
"""Fast ElectrumX liveness check used by /api/health""" """Fast ElectrumX liveness check used by /api/health"""
tcp_port, _ = get_electrumx_service_ports()
if not tcp_port:
return False
try: try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout) sock.settimeout(timeout)
sock.connect((ELECTRUMX_RPC_HOST, 50001)) sock.connect((ELECTRUMX_RPC_HOST, tcp_port))
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 999, "id": 999,
@@ -209,6 +316,8 @@ def get_electrumx_stats(include_addnode_probes=False):
import subprocess import subprocess
from datetime import datetime from datetime import datetime
local_tcp_port, local_ssl_port = get_electrumx_service_ports()
stats = { stats = {
'server_version': 'Unknown', 'server_version': 'Unknown',
'protocol_min': '', 'protocol_min': '',
@@ -225,8 +334,8 @@ def get_electrumx_stats(include_addnode_probes=False):
'subs': 0, 'subs': 0,
'uptime': 0, 'uptime': 0,
'db_size': 0, 'db_size': 0,
'tcp_port': 50001, 'tcp_port': str(local_tcp_port) if local_tcp_port else None,
'ssl_port': 50002, 'ssl_port': str(local_ssl_port) if local_ssl_port else None,
'server_ip': 'Unknown' 'server_ip': 'Unknown'
} }
@@ -252,9 +361,11 @@ def get_electrumx_stats(include_addnode_probes=False):
# Get server features via Electrum protocol # Get server features via Electrum protocol
try: try:
if not local_tcp_port:
raise RuntimeError("SERVICES tcp port not configured")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5) sock.settimeout(5)
sock.connect((ELECTRUMX_RPC_HOST, 50001)) sock.connect((ELECTRUMX_RPC_HOST, local_tcp_port))
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -281,9 +392,11 @@ def get_electrumx_stats(include_addnode_probes=False):
# Get peers discovered by ElectrumX # Get peers discovered by ElectrumX
try: try:
if not local_tcp_port:
raise RuntimeError("SERVICES tcp port not configured")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5) sock.settimeout(5)
sock.connect((ELECTRUMX_RPC_HOST, 50001)) sock.connect((ELECTRUMX_RPC_HOST, local_tcp_port))
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -315,7 +428,9 @@ def get_electrumx_stats(include_addnode_probes=False):
peers.append({ peers.append({
'host': host, 'host': host,
'tcp_port': tcp_port, 'tcp_port': tcp_port,
'ssl_port': ssl_port 'ssl_port': ssl_port,
'tcp_reachable': None,
'ssl_reachable': None
}) })
stats['active_servers'] = peers stats['active_servers'] = peers
@@ -325,25 +440,35 @@ def get_electrumx_stats(include_addnode_probes=False):
# Keep peers list without self for dashboard card count # Keep peers list without self for dashboard card count
try: try:
merged = [] merged_by_host = {}
seen = set()
self_host = (stats.get('server_ip') or '').strip() self_host = (stats.get('server_ip') or '').strip()
for peer in (stats.get('active_servers') or []): for peer in (stats.get('active_servers') or []):
host = (peer.get('host') or '').strip() host = (peer.get('host') or '').strip()
tcp_port = str(peer.get('tcp_port') or '50001') tcp_port = str(peer.get('tcp_port')) if peer.get('tcp_port') else None
ssl_port = str(peer.get('ssl_port')) if peer.get('ssl_port') else None
if not host: if not host:
continue continue
if self_host and host == self_host: if self_host and host == self_host:
continue continue
key = f"{host}:{tcp_port}" existing = merged_by_host.get(host)
if key in seen: if not existing:
continue merged_by_host[host] = {
seen.add(key) 'host': host,
merged.append({ 'tcp_port': tcp_port,
'host': host, 'ssl_port': ssl_port,
'tcp_port': tcp_port, 'tcp_reachable': peer.get('tcp_reachable'),
'ssl_port': peer.get('ssl_port') 'ssl_reachable': peer.get('ssl_reachable')
}) }
else:
if not existing.get('tcp_port') and tcp_port:
existing['tcp_port'] = tcp_port
if not existing.get('ssl_port') and ssl_port:
existing['ssl_port'] = ssl_port
if existing.get('tcp_reachable') is None and peer.get('tcp_reachable') is not None:
existing['tcp_reachable'] = peer.get('tcp_reachable')
if existing.get('ssl_reachable') is None and peer.get('ssl_reachable') is not None:
existing['ssl_reachable'] = peer.get('ssl_reachable')
merged = list(merged_by_host.values())
stats['active_servers'] = merged stats['active_servers'] = merged
stats['active_servers_count'] = len(merged) stats['active_servers_count'] = len(merged)
except Exception as e: except Exception as e:
@@ -355,32 +480,63 @@ def get_electrumx_stats(include_addnode_probes=False):
addnode_hosts = parse_addnode_hosts() addnode_hosts = parse_addnode_hosts()
extra_servers = [] extra_servers = []
for host in addnode_hosts: for host in addnode_hosts:
if probe_electrum_server(host, 50001, timeout=0.5): tcp_ok = probe_electrum_server(host, local_tcp_port, timeout=2.0) if local_tcp_port else False
ssl_ok = probe_electrum_server_ssl(host, local_ssl_port, timeout=2.0) if local_ssl_port else False
if tcp_ok or ssl_ok:
extra_servers.append({ extra_servers.append({
'host': host, 'host': host,
'tcp_port': '50001', 'tcp_port': str(local_tcp_port) if tcp_ok and local_tcp_port else None,
'ssl_port': None 'ssl_port': str(local_ssl_port) if ssl_ok and local_ssl_port else None,
'tcp_reachable': tcp_ok,
'ssl_reachable': ssl_ok
}) })
merged = [] merged_by_host = {}
seen = set()
self_host = (stats.get('server_ip') or '').strip() self_host = (stats.get('server_ip') or '').strip()
for peer in (stats.get('active_servers') or []) + extra_servers: for peer in (stats.get('active_servers') or []) + extra_servers:
host = (peer.get('host') or '').strip() host = (peer.get('host') or '').strip()
tcp_port = str(peer.get('tcp_port') or '50001') tcp_port = str(peer.get('tcp_port')) if peer.get('tcp_port') else None
ssl_port = str(peer.get('ssl_port')) if peer.get('ssl_port') else None
if not host: if not host:
continue continue
if self_host and host == self_host: if self_host and host == self_host:
continue continue
key = f"{host}:{tcp_port}" existing = merged_by_host.get(host)
if key in seen: if not existing:
merged_by_host[host] = {
'host': host,
'tcp_port': tcp_port,
'ssl_port': ssl_port,
'tcp_reachable': peer.get('tcp_reachable'),
'ssl_reachable': peer.get('ssl_reachable')
}
else:
if not existing.get('tcp_port') and tcp_port:
existing['tcp_port'] = tcp_port
if not existing.get('ssl_port') and ssl_port:
existing['ssl_port'] = ssl_port
if existing.get('tcp_reachable') is None and peer.get('tcp_reachable') is not None:
existing['tcp_reachable'] = peer.get('tcp_reachable')
if existing.get('ssl_reachable') is None and peer.get('ssl_reachable') is not None:
existing['ssl_reachable'] = peer.get('ssl_reachable')
merged = list(merged_by_host.values())
# Probe merged list so summary can report both TCP and SSL reachability
for peer in merged:
host = peer.get('host')
if not host:
continue continue
seen.add(key) peer_tcp_port = int(peer.get('tcp_port')) if str(peer.get('tcp_port', '')).isdigit() else local_tcp_port
merged.append({ peer_ssl_port = int(peer.get('ssl_port')) if str(peer.get('ssl_port', '')).isdigit() else local_ssl_port
'host': host,
'tcp_port': tcp_port, if peer.get('tcp_reachable') is None and peer_tcp_port:
'ssl_port': peer.get('ssl_port') peer['tcp_reachable'] = probe_electrum_server(host, peer_tcp_port, timeout=2.0)
}) if peer.get('ssl_reachable') is None and peer_ssl_port:
peer['ssl_reachable'] = probe_electrum_server_ssl(host, peer_ssl_port, timeout=2.0)
if peer.get('tcp_reachable') is True and not peer.get('tcp_port') and peer_tcp_port:
peer['tcp_port'] = str(peer_tcp_port)
if peer.get('ssl_reachable') is True and not peer.get('ssl_port') and peer_ssl_port:
peer['ssl_port'] = str(peer_ssl_port)
stats['active_servers'] = merged stats['active_servers'] = merged
stats['active_servers_count'] = len(merged) stats['active_servers_count'] = len(merged)
@@ -439,9 +595,11 @@ def get_electrumx_stats(include_addnode_probes=False):
# Count active connections (TCP sessions) # Count active connections (TCP sessions)
try: try:
if not local_tcp_port:
raise RuntimeError("SERVICES tcp port not configured")
result = subprocess.run( result = subprocess.run(
['docker', 'exec', 'electrumx-server', 'sh', '-c', ['docker', 'exec', 'electrumx-server', 'sh', '-c',
'netstat -an 2>/dev/null | grep ":50001.*ESTABLISHED" | wc -l'], f'netstat -an 2>/dev/null | grep ":{local_tcp_port}.*ESTABLISHED" | wc -l'],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=2 timeout=2
@@ -558,12 +716,12 @@ def electrumx_stats():
try: try:
stats = get_electrumx_stats_cached(include_addnode_probes=False) stats = get_electrumx_stats_cached(include_addnode_probes=False)
if stats: if stats:
# If fast path reports no servers, reuse full servers cache if available. # Keep dashboard card aligned with /api/electrumx/servers:
if (stats.get('active_servers_count') or 0) == 0: # always prefer the full discovery view for active servers.
heavy_stats = get_electrumx_stats_cached(include_addnode_probes=True) heavy_stats = get_electrumx_stats_cached(include_addnode_probes=True)
if heavy_stats and (heavy_stats.get('active_servers_count') or 0) > 0: if heavy_stats:
stats['active_servers'] = heavy_stats.get('active_servers', []) stats['active_servers'] = heavy_stats.get('active_servers', [])
stats['active_servers_count'] = heavy_stats.get('active_servers_count', 0) stats['active_servers_count'] = heavy_stats.get('active_servers_count', 0)
# Get additional info from logs if available # Get additional info from logs if available
try: try:

View File

@@ -217,10 +217,10 @@ async function updateElectrumXStats() {
document.getElementById('serverIP').textContent = data.stats.server_ip || '--'; document.getElementById('serverIP').textContent = data.stats.server_ip || '--';
// TCP Port // TCP Port
document.getElementById('tcpPort').textContent = data.stats.tcp_port || 50001; document.getElementById('tcpPort').textContent = data.stats.tcp_port || '--';
// SSL Port // SSL Port
document.getElementById('sslPort').textContent = data.stats.ssl_port || 50002; document.getElementById('sslPort').textContent = data.stats.ssl_port || '--';
// Active servers from peer discovery // Active servers from peer discovery
const activeServers = Array.isArray(data.stats.active_servers) ? data.stats.active_servers : []; const activeServers = Array.isArray(data.stats.active_servers) ? data.stats.active_servers : [];

View File

@@ -20,9 +20,8 @@ async function updateElectrumServers() {
tbody.innerHTML = ''; tbody.innerHTML = '';
if (servers.length === 0) { if (servers.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="loading">No active servers found</td></tr>'; tbody.innerHTML = '<tr><td colspan="5" class="loading">No active servers found</td></tr>';
document.getElementById('totalServers').textContent = '0'; document.getElementById('totalServers').textContent = '0';
document.getElementById('tcpReachable').textContent = '0';
return; return;
} }
@@ -32,17 +31,17 @@ async function updateElectrumServers() {
<td class="peer-addr">${server.host || '--'}</td> <td class="peer-addr">${server.host || '--'}</td>
<td>${server.tcp_port || '--'}</td> <td>${server.tcp_port || '--'}</td>
<td>${server.ssl_port || '--'}</td> <td>${server.ssl_port || '--'}</td>
<td>${server.tcp_reachable === true ? 'Yes' : 'No'}</td>
<td>${server.ssl_reachable === true ? 'Yes' : 'No'}</td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);
}); });
document.getElementById('totalServers').textContent = String(servers.length); document.getElementById('totalServers').textContent = String(servers.length);
const tcpCount = servers.filter(s => !!s.tcp_port).length;
document.getElementById('tcpReachable').textContent = String(tcpCount);
} catch (error) { } catch (error) {
console.error('Error fetching Electrum servers:', error); console.error('Error fetching Electrum servers:', error);
document.getElementById('electrumServersTable').innerHTML = document.getElementById('electrumServersTable').innerHTML =
'<tr><td colspan="3" class="loading">Error loading servers</td></tr>'; '<tr><td colspan="5" class="loading">Error loading servers</td></tr>';
} }
} }

View File

@@ -33,10 +33,6 @@
<div class="stat-label">Total Active Servers</div> <div class="stat-label">Total Active Servers</div>
<div class="stat-value" id="totalServers">--</div> <div class="stat-value" id="totalServers">--</div>
</div> </div>
<div class="stat-item">
<div class="stat-label">TCP 50001 Reachable</div>
<div class="stat-value" id="tcpReachable">--</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -54,10 +50,12 @@
<th>Host</th> <th>Host</th>
<th>TCP Port</th> <th>TCP Port</th>
<th>SSL Port</th> <th>SSL Port</th>
<th>TCP Reachable</th>
<th>SSL Reachable</th>
</tr> </tr>
</thead> </thead>
<tbody id="electrumServersTable"> <tbody id="electrumServersTable">
<tr><td colspan="3" class="loading">Loading servers...</td></tr> <tr><td colspan="5" class="loading">Loading servers...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -70,6 +68,6 @@
</footer> </footer>
</div> </div>
<script src="{{ url_for('static', filename='electrum_servers.js') }}?v=1"></script> <script src="{{ url_for('static', filename='electrum_servers.js') }}?v=3"></script>
</body> </body>
</html> </html>