Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77c74098a3 | |||
| aa52047016 | |||
| e166e3fcb4 | |||
| d4d4208145 | |||
| 61408645a5 | |||
| 25baec67ac | |||
| c71d0da97d | |||
| 38297e616f | |||
| ca41486c45 | |||
| 0f2506d5a4 | |||
| 678ede4e76 | |||
| b51b39c3a7 | |||
| 41695a3faa |
@@ -5,6 +5,7 @@ management UI. Authentication via Authelia forward-auth headers.
|
|||||||
All monitoring and alerting is handled by the separate monitor.py daemon.
|
All monitoring and alerting is handled by the separate monitor.py daemon.
|
||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import html
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -58,6 +59,8 @@ def inject_config():
|
|||||||
# In-memory diagnostic job store { job_id: { status, result, created_at } }
|
# In-memory diagnostic job store { job_id: { status, result, created_at } }
|
||||||
_diag_jobs: dict = {}
|
_diag_jobs: dict = {}
|
||||||
_diag_lock = threading.Lock()
|
_diag_lock = threading.Lock()
|
||||||
|
# Per-user rate-limit: { username: [epoch_float, ...] } — cleaned inside _diag_lock
|
||||||
|
_diag_rate: dict = {}
|
||||||
|
|
||||||
|
|
||||||
def _purge_old_jobs_loop():
|
def _purge_old_jobs_loop():
|
||||||
@@ -91,6 +94,14 @@ def _config() -> dict:
|
|||||||
return _cfg
|
return _cfg
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def add_security_headers(response):
|
||||||
|
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
||||||
|
response.headers.setdefault('X-Frame-Options', 'DENY')
|
||||||
|
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _daemon_ok(last_check: str) -> bool:
|
def _daemon_ok(last_check: str) -> bool:
|
||||||
"""Return True if monitor last checked within 20 minutes."""
|
"""Return True if monitor last checked within 20 minutes."""
|
||||||
if not last_check or last_check == 'Never':
|
if not last_check or last_check == 'Never':
|
||||||
@@ -132,10 +143,12 @@ def require_auth(f):
|
|||||||
)
|
)
|
||||||
allowed = _config().get('auth', {}).get('allowed_groups', ['admin'])
|
allowed = _config().get('auth', {}).get('allowed_groups', ['admin'])
|
||||||
if not any(g in allowed for g in user['groups']):
|
if not any(g in allowed for g in user['groups']):
|
||||||
|
safe_user = html.escape(user['username'])
|
||||||
|
safe_groups = html.escape(', '.join(allowed))
|
||||||
return (
|
return (
|
||||||
f'<h1>403 – Access denied</h1>'
|
f'<h1>403 – Access denied</h1>'
|
||||||
f'<p>Your account ({user["username"]}) is not in an allowed group '
|
f'<p>Your account ({safe_user}) is not in an allowed group '
|
||||||
f'({", ".join(allowed)}).</p>',
|
f'({safe_groups}).</p>',
|
||||||
403,
|
403,
|
||||||
)
|
)
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
@@ -177,7 +190,11 @@ def index():
|
|||||||
summary = db.get_status_summary()
|
summary = db.get_status_summary()
|
||||||
snapshot_raw = db.get_state('network_snapshot')
|
snapshot_raw = db.get_state('network_snapshot')
|
||||||
last_check = db.get_state('last_check', 'Never')
|
last_check = db.get_state('last_check', 'Never')
|
||||||
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
|
try:
|
||||||
|
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to parse network_snapshot JSON: {e}')
|
||||||
|
snapshot = {}
|
||||||
suppressions = db.get_active_suppressions()
|
suppressions = db.get_active_suppressions()
|
||||||
_annotate_suppressions(events, suppressions)
|
_annotate_suppressions(events, suppressions)
|
||||||
recent_resolved = db.get_recent_resolved(hours=24, limit=10)
|
recent_resolved = db.get_recent_resolved(hours=24, limit=10)
|
||||||
@@ -216,7 +233,11 @@ def suppressions_page():
|
|||||||
active = db.get_active_suppressions()
|
active = db.get_active_suppressions()
|
||||||
history = db.get_suppression_history(limit=50)
|
history = db.get_suppression_history(limit=50)
|
||||||
snapshot_raw = db.get_state('network_snapshot')
|
snapshot_raw = db.get_state('network_snapshot')
|
||||||
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
|
try:
|
||||||
|
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to parse network_snapshot JSON: {e}')
|
||||||
|
snapshot = {}
|
||||||
return render_template(
|
return render_template(
|
||||||
'suppressions.html',
|
'suppressions.html',
|
||||||
user=user,
|
user=user,
|
||||||
@@ -263,10 +284,13 @@ def api_network():
|
|||||||
def api_links():
|
def api_links():
|
||||||
raw = db.get_state('link_stats')
|
raw = db.get_state('link_stats')
|
||||||
if raw:
|
if raw:
|
||||||
|
if len(raw) > 10_000_000:
|
||||||
|
logger.error(f'link_stats exceeds 10 MB ({len(raw)} bytes); possible corruption')
|
||||||
|
return jsonify({'error': 'Invalid cached data'}), 503
|
||||||
try:
|
try:
|
||||||
return jsonify(json.loads(raw))
|
return jsonify(json.loads(raw))
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.error('Failed to parse link_stats JSON')
|
logger.error(f'Failed to parse link_stats JSON: {e}')
|
||||||
return jsonify({'hosts': {}, 'updated': None})
|
return jsonify({'hosts': {}, 'updated': None})
|
||||||
|
|
||||||
|
|
||||||
@@ -322,13 +346,21 @@ def api_create_suppression():
|
|||||||
if len(target_detail) > 255:
|
if len(target_detail) > 255:
|
||||||
return jsonify({'error': 'target_detail must be 255 characters or fewer'}), 400
|
return jsonify({'error': 'target_detail must be 255 characters or fewer'}), 400
|
||||||
|
|
||||||
|
if expires_minutes is not None:
|
||||||
|
try:
|
||||||
|
expires_minutes = int(expires_minutes)
|
||||||
|
if expires_minutes <= 0 or expires_minutes > 43200:
|
||||||
|
return jsonify({'error': 'expires_minutes must be between 1 and 43200 (30 days)'}), 400
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'error': 'expires_minutes must be a valid integer'}), 400
|
||||||
|
|
||||||
sup_id = db.create_suppression(
|
sup_id = db.create_suppression(
|
||||||
target_type=target_type,
|
target_type=target_type,
|
||||||
target_name=target_name,
|
target_name=target_name,
|
||||||
target_detail=target_detail,
|
target_detail=target_detail,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
suppressed_by=user['username'],
|
suppressed_by=user['username'],
|
||||||
expires_minutes=int(expires_minutes) if expires_minutes else None,
|
expires_minutes=expires_minutes,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f'Suppression #{sup_id} created by {user["username"]}: '
|
f'Suppression #{sup_id} created by {user["username"]}: '
|
||||||
@@ -366,8 +398,8 @@ def api_diagnose_start():
|
|||||||
return jsonify({'error': 'No link_stats data available'}), 503
|
return jsonify({'error': 'No link_stats data available'}), 503
|
||||||
try:
|
try:
|
||||||
link_data = json.loads(raw)
|
link_data = json.loads(raw)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.error('Failed to parse link_stats JSON in /api/diagnose')
|
logger.error(f'Failed to parse link_stats JSON in /api/diagnose: {e}')
|
||||||
return jsonify({'error': 'Internal data error'}), 500
|
return jsonify({'error': 'Internal data error'}), 500
|
||||||
|
|
||||||
switches = link_data.get('unifi_switches', {})
|
switches = link_data.get('unifi_switches', {})
|
||||||
@@ -391,6 +423,9 @@ def api_diagnose_start():
|
|||||||
return jsonify({'error': 'No LLDP neighbor data for this port'}), 400
|
return jsonify({'error': 'No LLDP neighbor data for this port'}), 400
|
||||||
|
|
||||||
server_name = lldp['system_name']
|
server_name = lldp['system_name']
|
||||||
|
if not re.fullmatch(r'[a-zA-Z0-9._-]+', server_name):
|
||||||
|
logger.error(f'Refusing diagnostic: invalid server_name from LLDP: {server_name!r}')
|
||||||
|
return jsonify({'error': 'LLDP neighbor name contains invalid characters'}), 400
|
||||||
lldp_port_id = lldp.get('port_id', '')
|
lldp_port_id = lldp.get('port_id', '')
|
||||||
|
|
||||||
# Find matching host + interface in link_stats hosts
|
# Find matching host + interface in link_stats hosts
|
||||||
@@ -416,9 +451,14 @@ def api_diagnose_start():
|
|||||||
# Resolve host IP from link_stats host data
|
# Resolve host IP from link_stats host data
|
||||||
host_ip = (server_ifaces.get(matched_iface) or {}).get('host_ip')
|
host_ip = (server_ifaces.get(matched_iface) or {}).get('host_ip')
|
||||||
if not host_ip:
|
if not host_ip:
|
||||||
# Fallback: use LLDP mgmt IPs
|
# Fallback: use first valid IP from LLDP mgmt IPs
|
||||||
mgmt_ips = lldp.get('mgmt_ips') or []
|
for candidate in (lldp.get('mgmt_ips') or []):
|
||||||
host_ip = mgmt_ips[0] if mgmt_ips else None
|
try:
|
||||||
|
ipaddress.ip_address(candidate)
|
||||||
|
host_ip = candidate
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
if not host_ip:
|
if not host_ip:
|
||||||
return jsonify({'error': 'Cannot determine host IP for SSH'}), 400
|
return jsonify({'error': 'Cannot determine host IP for SSH'}), 400
|
||||||
|
|
||||||
@@ -433,8 +473,22 @@ def api_diagnose_start():
|
|||||||
return jsonify({'error': 'Resolved interface name contains invalid characters'}), 400
|
return jsonify({'error': 'Resolved interface name contains invalid characters'}), 400
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
|
requesting_user = _get_user()['username']
|
||||||
|
now = time.time()
|
||||||
with _diag_lock:
|
with _diag_lock:
|
||||||
_diag_jobs[job_id] = {'status': 'running', 'result': None, 'created_at': time.time()}
|
# Rate limit: max 5 diagnostic jobs per user per minute; prune stale user entries
|
||||||
|
stale_users = [u for u, ts in _diag_rate.items() if not ts or max(ts) < now - 3600]
|
||||||
|
for u in stale_users:
|
||||||
|
del _diag_rate[u]
|
||||||
|
recent = [t for t in _diag_rate.get(requesting_user, []) if now - t < 60]
|
||||||
|
if len(recent) >= 5:
|
||||||
|
return jsonify({'error': 'Rate limit exceeded: max 5 diagnostics per minute'}), 429
|
||||||
|
recent.append(now)
|
||||||
|
_diag_rate[requesting_user] = recent
|
||||||
|
_diag_jobs[job_id] = {
|
||||||
|
'status': 'running', 'result': None,
|
||||||
|
'created_at': now, 'user': requesting_user,
|
||||||
|
}
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
try:
|
try:
|
||||||
@@ -444,7 +498,7 @@ def api_diagnose_start():
|
|||||||
result = runner.run(host_ip, server_name, matched_iface, port_data)
|
result = runner.run(host_ip, server_name, matched_iface, port_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Diagnostic job {job_id} failed: {e}', exc_info=True)
|
logger.error(f'Diagnostic job {job_id} failed: {e}', exc_info=True)
|
||||||
result = {'status': 'error', 'error': str(e)}
|
result = {'status': 'error', 'error': 'Diagnostic failed; check server logs.'}
|
||||||
with _diag_lock:
|
with _diag_lock:
|
||||||
if job_id in _diag_jobs:
|
if job_id in _diag_jobs:
|
||||||
_diag_jobs[job_id]['status'] = 'done'
|
_diag_jobs[job_id]['status'] = 'done'
|
||||||
@@ -460,11 +514,15 @@ def api_diagnose_start():
|
|||||||
@require_auth
|
@require_auth
|
||||||
def api_diagnose_poll(job_id: str):
|
def api_diagnose_poll(job_id: str):
|
||||||
"""Poll a diagnostic job. Returns {status, result}."""
|
"""Poll a diagnostic job. Returns {status, result}."""
|
||||||
|
current_user = _get_user()['username']
|
||||||
with _diag_lock:
|
with _diag_lock:
|
||||||
job = _diag_jobs.get(job_id)
|
job = _diag_jobs.get(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
return jsonify({'error': 'Job not found'}), 404
|
return jsonify({'error': 'Job not found'}), 404
|
||||||
return jsonify({'status': job['status'], 'result': job.get('result')})
|
if job.get('user') != current_user:
|
||||||
|
return jsonify({'error': 'Forbidden'}), 403
|
||||||
|
snapshot = {'status': job['status'], 'result': job.get('result')}
|
||||||
|
return jsonify(snapshot)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/avatar')
|
@app.route('/api/avatar')
|
||||||
@@ -485,7 +543,11 @@ def api_avatar():
|
|||||||
os.makedirs(cache_dir, exist_ok=True)
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
cache_file = os.path.join(cache_dir, f'user_{safe_name}.jpg')
|
cache_file = os.path.join(cache_dir, f'user_{safe_name}.jpg')
|
||||||
sentinel = os.path.join(cache_dir, f'user_{safe_name}.none')
|
sentinel = os.path.join(cache_dir, f'user_{safe_name}.none')
|
||||||
cache_ttl = int(ldap_cfg.get('cache_ttl', 3600))
|
try:
|
||||||
|
cache_ttl = int(ldap_cfg.get('cache_ttl', 3600))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning('Invalid cache_ttl in ldap config; using default 3600')
|
||||||
|
cache_ttl = 3600
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
@@ -499,29 +561,41 @@ def api_avatar():
|
|||||||
return '', 404
|
return '', 404
|
||||||
|
|
||||||
# Query lldap
|
# Query lldap
|
||||||
|
bind_pw = ldap_cfg.get('bind_pw', '')
|
||||||
|
if not bind_pw:
|
||||||
|
logger.error('LDAP bind_pw not configured — avatar lookup disabled')
|
||||||
|
return '', 404
|
||||||
|
|
||||||
avatar_data = None
|
avatar_data = None
|
||||||
|
conn = None
|
||||||
try:
|
try:
|
||||||
import ldap3
|
import ldap3
|
||||||
server = ldap3.Server(ldap_cfg['host'], port=int(ldap_cfg.get('port', 3890)))
|
server = ldap3.Server(ldap_cfg['host'], port=int(ldap_cfg.get('port', 3890)))
|
||||||
conn = ldap3.Connection(server,
|
conn = ldap3.Connection(server,
|
||||||
user=ldap_cfg['bind_dn'],
|
user=ldap_cfg['bind_dn'],
|
||||||
password=ldap_cfg.get('bind_pw', ''),
|
password=bind_pw,
|
||||||
auto_bind=True, receive_timeout=5)
|
auto_bind=True, receive_timeout=5)
|
||||||
safe_uid = ldap3.utils.conv.escape_filter_chars(username)
|
safe_uid = ldap3.utils.conv.escape_filter_chars(username)
|
||||||
conn.search(ldap_cfg.get('user_base', 'ou=people,dc=example,dc=com'),
|
conn.search(ldap_cfg.get('user_base', 'ou=people,dc=example,dc=com'),
|
||||||
f'(uid={safe_uid})', attributes=['avatar'])
|
f'(uid={safe_uid})', attributes=['avatar'])
|
||||||
if conn.entries and conn.entries[0]['avatar'].value:
|
if conn.entries and conn.entries[0]['avatar'].value:
|
||||||
avatar_data = conn.entries[0]['avatar'].value
|
avatar_data = conn.entries[0]['avatar'].value
|
||||||
conn.unbind()
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.error('ldap3 not installed — run: pip install ldap3')
|
logger.error('ldap3 not installed — run: pip install ldap3')
|
||||||
return '', 404
|
return '', 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'LDAP avatar lookup failed for {username}: {e}')
|
logger.error(f'LDAP avatar lookup failed for {username}: {e}')
|
||||||
return '', 404
|
return '', 404
|
||||||
|
finally:
|
||||||
|
if conn is not None:
|
||||||
|
try:
|
||||||
|
conn.unbind()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if not avatar_data or len(avatar_data) < 100:
|
if not avatar_data or len(avatar_data) < 100:
|
||||||
open(sentinel, 'w').close()
|
with open(sentinel, 'w'):
|
||||||
|
pass
|
||||||
return '', 404
|
return '', 404
|
||||||
|
|
||||||
# Validate JPEG magic bytes (FF D8 FF)
|
# Validate JPEG magic bytes (FF D8 FF)
|
||||||
@@ -554,7 +628,8 @@ def health():
|
|||||||
db.get_state('last_check')
|
db.get_state('last_check')
|
||||||
checks['db'] = 'ok'
|
checks['db'] = 'ok'
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
checks['db'] = f'error: {e}'
|
logger.error(f'Health check db error: {e}')
|
||||||
|
checks['db'] = 'error'
|
||||||
overall = 'degraded'
|
overall = 'degraded'
|
||||||
|
|
||||||
# Monitor freshness: fail if last_check is older than 20 minutes
|
# Monitor freshness: fail if last_check is older than 20 minutes
|
||||||
@@ -564,14 +639,15 @@ def health():
|
|||||||
ts = datetime.strptime(last_check, '%Y-%m-%d %H:%M:%S UTC').replace(tzinfo=timezone.utc)
|
ts = datetime.strptime(last_check, '%Y-%m-%d %H:%M:%S UTC').replace(tzinfo=timezone.utc)
|
||||||
age_s = (datetime.now(timezone.utc) - ts).total_seconds()
|
age_s = (datetime.now(timezone.utc) - ts).total_seconds()
|
||||||
if age_s > 1200:
|
if age_s > 1200:
|
||||||
checks['monitor'] = f'stale ({int(age_s)}s since last check)'
|
checks['monitor'] = 'stale'
|
||||||
overall = 'degraded'
|
overall = 'degraded'
|
||||||
else:
|
else:
|
||||||
checks['monitor'] = f'ok ({int(age_s)}s ago)'
|
checks['monitor'] = 'ok'
|
||||||
else:
|
else:
|
||||||
checks['monitor'] = 'no data yet'
|
checks['monitor'] = 'no data yet'
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
checks['monitor'] = f'error: {e}'
|
logger.error(f'Health check monitor error: {e}')
|
||||||
|
checks['monitor'] = 'error'
|
||||||
overall = 'degraded'
|
overall = 'degraded'
|
||||||
|
|
||||||
status_code = 200 if overall == 'ok' else 503
|
status_code = 200 if overall == 'ok' else 503
|
||||||
|
|||||||
+1
-1
@@ -75,7 +75,7 @@ class DiagnosticsRunner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 '
|
f'ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 '
|
||||||
f'-o BatchMode=yes -o LogLevel=ERROR '
|
f'-o BatchMode=yes -o LogLevel=ERROR '
|
||||||
f'-o ServerAliveInterval=10 -o ServerAliveCountMax=2 '
|
f'-o ServerAliveInterval=10 -o ServerAliveCountMax=2 '
|
||||||
f'root@{ip_q} \'{remote_cmd}\''
|
f'root@{ip_q} \'{remote_cmd}\''
|
||||||
|
|||||||
+22
-22
@@ -11,7 +11,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
@@ -21,7 +20,6 @@ from urllib3.exceptions import InsecureRequestWarning
|
|||||||
|
|
||||||
import db
|
import db
|
||||||
|
|
||||||
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
|
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -91,7 +89,9 @@ class UnifiClient:
|
|||||||
self.base_url = cfg['controller']
|
self.base_url = cfg['controller']
|
||||||
self.site_id = cfg.get('site_id', 'default')
|
self.site_id = cfg.get('site_id', 'default')
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.verify = False
|
self.session.verify = cfg.get('verify_ssl', True)
|
||||||
|
if not self.session.verify:
|
||||||
|
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
|
||||||
self.headers = {
|
self.headers = {
|
||||||
'X-API-KEY': cfg['api_key'],
|
'X-API-KEY': cfg['api_key'],
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
@@ -263,7 +263,10 @@ class PulseClient:
|
|||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
execution_id = resp.json()['execution_id']
|
execution_id = resp.json().get('execution_id')
|
||||||
|
if not execution_id:
|
||||||
|
logger.error('Pulse submit response missing execution_id')
|
||||||
|
return None
|
||||||
self.last_execution_id = execution_id
|
self.last_execution_id = execution_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Pulse command submit failed: {e}')
|
logger.error(f'Pulse command submit failed: {e}')
|
||||||
@@ -315,6 +318,14 @@ class PulseClient:
|
|||||||
return self.run_command(command, _retry=False)
|
return self.run_command(command, _retry=False)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def ping(self, ip: str, count: int = 3, timeout: int = 2) -> bool:
|
||||||
|
"""Ping *ip* via the Pulse worker. Returns True if host responds."""
|
||||||
|
ip_q = shlex.quote(ip)
|
||||||
|
output = self.run_command(
|
||||||
|
f'ping -c {count} -W {timeout} {ip_q} >/dev/null 2>&1 && echo REACHABLE || echo UNREACHABLE'
|
||||||
|
)
|
||||||
|
return output is not None and output.strip() == 'REACHABLE'
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Link stats collector (ethtool + Prometheus traffic metrics)
|
# Link stats collector (ethtool + Prometheus traffic metrics)
|
||||||
@@ -344,8 +355,8 @@ class LinkStatsCollector:
|
|||||||
if not ifaces or not self.pulse.url:
|
if not ifaces or not self.pulse.url:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Validate interface names (kernel names only contain [a-zA-Z0-9_.-])
|
# Validate interface names (kernel names: [a-zA-Z0-9_.-], max 15 chars per IFNAMSIZ)
|
||||||
safe_ifaces = [i for i in ifaces if re.match(r'^[a-zA-Z0-9_.@-]+$', i)]
|
safe_ifaces = [i for i in ifaces if re.match(r'^[a-zA-Z0-9_.-]{1,15}$', i)]
|
||||||
if not safe_ifaces:
|
if not safe_ifaces:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -363,7 +374,7 @@ class LinkStatsCollector:
|
|||||||
shell_cmd = ' '.join(parts)
|
shell_cmd = ' '.join(parts)
|
||||||
|
|
||||||
ssh_cmd = (
|
ssh_cmd = (
|
||||||
f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 '
|
f'ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 '
|
||||||
f'-o BatchMode=yes -o LogLevel=ERROR '
|
f'-o BatchMode=yes -o LogLevel=ERROR '
|
||||||
f'-o ServerAliveInterval=10 -o ServerAliveCountMax=2 '
|
f'-o ServerAliveInterval=10 -o ServerAliveCountMax=2 '
|
||||||
f'root@{ip} "{shell_cmd}"'
|
f'root@{ip} "{shell_cmd}"'
|
||||||
@@ -638,19 +649,6 @@ class LinkStatsCollector:
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
def ping(ip: str, count: int = 3, timeout: int = 2) -> bool:
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
['ping', '-c', str(count), '-W', str(timeout), ip],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
return r.returncode == 0
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _now_utc() -> str:
|
def _now_utc() -> str:
|
||||||
return datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
|
return datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||||
|
|
||||||
@@ -671,6 +669,7 @@ class NetworkMonitor:
|
|||||||
self.unifi = UnifiClient(self.cfg['unifi'])
|
self.unifi = UnifiClient(self.cfg['unifi'])
|
||||||
self.tickets = TicketClient(self.cfg.get('ticket_api', {}))
|
self.tickets = TicketClient(self.cfg.get('ticket_api', {}))
|
||||||
self.link_stats = LinkStatsCollector(self.cfg, self.prom, self.unifi)
|
self.link_stats = LinkStatsCollector(self.cfg, self.prom, self.unifi)
|
||||||
|
self.pulse = self.link_stats.pulse # convenience alias
|
||||||
|
|
||||||
mon = self.cfg.get('monitor', {})
|
mon = self.cfg.get('monitor', {})
|
||||||
self.poll_interval = mon.get('poll_interval', 120)
|
self.poll_interval = mon.get('poll_interval', 120)
|
||||||
@@ -838,7 +837,7 @@ class NetworkMonitor:
|
|||||||
def _process_ping_hosts(self, suppressions: list) -> None:
|
def _process_ping_hosts(self, suppressions: list) -> None:
|
||||||
for h in self.cfg.get('monitor', {}).get('ping_hosts', []):
|
for h in self.cfg.get('monitor', {}).get('ping_hosts', []):
|
||||||
name, ip = h['name'], h['ip']
|
name, ip = h['name'], h['ip']
|
||||||
reachable = ping(ip)
|
reachable = self.pulse.ping(ip)
|
||||||
|
|
||||||
if not reachable:
|
if not reachable:
|
||||||
sup = db.check_suppressed(suppressions, 'host', name)
|
sup = db.check_suppressed(suppressions, 'host', name)
|
||||||
@@ -908,7 +907,7 @@ class NetworkMonitor:
|
|||||||
|
|
||||||
for h in self.cfg.get('monitor', {}).get('ping_hosts', []):
|
for h in self.cfg.get('monitor', {}).get('ping_hosts', []):
|
||||||
name, ip = h['name'], h['ip']
|
name, ip = h['name'], h['ip']
|
||||||
reachable = ping(ip, count=1, timeout=2)
|
reachable = self.pulse.ping(ip, count=1, timeout=2)
|
||||||
hosts[name] = {
|
hosts[name] = {
|
||||||
'ip': ip,
|
'ip': ip,
|
||||||
'interfaces': {},
|
'interfaces': {},
|
||||||
@@ -967,6 +966,7 @@ class NetworkMonitor:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Monitor loop error: {e}', exc_info=True)
|
logger.error(f'Monitor loop error: {e}', exc_info=True)
|
||||||
|
time.sleep(30)
|
||||||
|
|
||||||
time.sleep(self.poll_interval)
|
time.sleep(self.poll_interval)
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -144,9 +144,9 @@
|
|||||||
<!-- ⌘K affordance -->
|
<!-- ⌘K affordance -->
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="lt-btn lt-btn-ghost lt-btn-sm lt-cmd-hint-btn"
|
class="lt-btn lt-btn-ghost lt-btn-sm lt-cmd-hint-btn"
|
||||||
|
data-action="open-cmdpalette"
|
||||||
title="Command palette (Ctrl+K)"
|
title="Command palette (Ctrl+K)"
|
||||||
aria-label="Open command palette"
|
aria-label="Open command palette">⌕ K</button>
|
||||||
onclick="if(window.lt&<.cmdPalette)lt.cmdPalette.open()">⌕ K</button>
|
|
||||||
|
|
||||||
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
|
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
|
||||||
aria-label="Toggle theme" title="Toggle light/dark mode">☀</button>
|
aria-label="Toggle theme" title="Toggle light/dark mode">☀</button>
|
||||||
@@ -313,7 +313,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const GANDALF_CONFIG = {
|
const GANDALF_CONFIG = {
|
||||||
ticket_web_url: "{{ config.get('ticket_api', {}).get('web_url', 'http://t.lotusguild.org/ticket/') }}"
|
ticket_web_url: {{ config.get('ticket_api', {}).get('web_url', 'http://t.lotusguild.org/ticket/') | tojson }}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
@@ -346,8 +346,9 @@
|
|||||||
const btn = e.target.closest('[data-action]');
|
const btn = e.target.closest('[data-action]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const action = btn.getAttribute('data-action');
|
const action = btn.getAttribute('data-action');
|
||||||
if (action === 'show-keyboard-help' && window.lt) lt.modal.open('lt-keys-help');
|
if (action === 'open-cmdpalette' && window.lt && lt.cmdPalette) lt.cmdPalette.open();
|
||||||
if (action === 'open-settings' && window.lt) lt.modal.open('lt-settings-modal');
|
if (action === 'show-keyboard-help' && window.lt) lt.modal.open('lt-keys-help');
|
||||||
|
if (action === 'open-settings' && window.lt) lt.modal.open('lt-settings-modal');
|
||||||
});
|
});
|
||||||
|
|
||||||
lt.keys.on('r', function() { lt.autoRefresh.now(); });
|
lt.keys.on('r', function() { lt.autoRefresh.now(); });
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<!-- ── Status bar ──────────────────────────────────────────────────── -->
|
<!-- ── Status bar ──────────────────────────────────────────────────── -->
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<div class="status-chips">
|
<div class="status-chips" id="status-chips" aria-live="polite" aria-atomic="true">
|
||||||
{% if not daemon_ok %}
|
{% if not daemon_ok %}
|
||||||
<span class="chip chip-critical">⚠ MONITOR OFFLINE</span>
|
<span class="chip chip-critical">⚠ MONITOR OFFLINE</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -30,7 +30,8 @@
|
|||||||
<div class="lt-stats-grid">
|
<div class="lt-stats-grid">
|
||||||
<div class="lt-stat-card{% if summary.critical %} lt-stat-card--alert{% endif %}"
|
<div class="lt-stat-card{% if summary.critical %} lt-stat-card--alert{% endif %}"
|
||||||
id="stat-critical" role="button" tabindex="0"
|
id="stat-critical" role="button" tabindex="0"
|
||||||
data-stat-filter="critical" aria-label="{{ summary.critical or 0 }} critical alerts">
|
data-stat-filter="critical" aria-label="{{ summary.critical or 0 }} critical alerts"
|
||||||
|
aria-controls="events-table-wrap">
|
||||||
<span class="lt-stat-icon lt-text-red" aria-hidden="true">●</span>
|
<span class="lt-stat-icon lt-text-red" aria-hidden="true">●</span>
|
||||||
<div class="lt-stat-info">
|
<div class="lt-stat-info">
|
||||||
<span class="lt-stat-value lt-text-red" id="stat-critical-val">{{ summary.critical or 0 }}</span>
|
<span class="lt-stat-value lt-text-red" id="stat-critical-val">{{ summary.critical or 0 }}</span>
|
||||||
@@ -39,7 +40,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="lt-stat-card"
|
<div class="lt-stat-card"
|
||||||
id="stat-warning" role="button" tabindex="0"
|
id="stat-warning" role="button" tabindex="0"
|
||||||
data-stat-filter="warning" aria-label="{{ summary.warning or 0 }} warning alerts">
|
data-stat-filter="warning" aria-label="{{ summary.warning or 0 }} warning alerts"
|
||||||
|
aria-controls="events-table-wrap">
|
||||||
<span class="lt-stat-icon lt-text-amber" aria-hidden="true">●</span>
|
<span class="lt-stat-icon lt-text-amber" aria-hidden="true">●</span>
|
||||||
<div class="lt-stat-info">
|
<div class="lt-stat-info">
|
||||||
<span class="lt-stat-value lt-text-amber" id="stat-warning-val">{{ summary.warning or 0 }}</span>
|
<span class="lt-stat-value lt-text-amber" id="stat-warning-val">{{ summary.warning or 0 }}</span>
|
||||||
@@ -484,6 +486,7 @@
|
|||||||
|
|
||||||
function setCollapsed(v) {
|
function setCollapsed(v) {
|
||||||
wrap.classList.toggle('is-collapsed', v);
|
wrap.classList.toggle('is-collapsed', v);
|
||||||
|
wrap.setAttribute('aria-hidden', v ? 'true' : 'false');
|
||||||
btn.setAttribute('aria-expanded', v ? 'false' : 'true');
|
btn.setAttribute('aria-expanded', v ? 'false' : 'true');
|
||||||
btn.textContent = v ? '▾ Expand' : '▴ Collapse';
|
btn.textContent = v ? '▾ Expand' : '▴ Collapse';
|
||||||
try { localStorage.setItem(LS_KEY, v ? '1' : '0'); } catch(_) {}
|
try { localStorage.setItem(LS_KEY, v ? '1' : '0'); } catch(_) {}
|
||||||
|
|||||||
@@ -14,10 +14,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inspector-layout">
|
<div class="inspector-layout">
|
||||||
<div class="inspector-main" id="inspector-main">
|
<div class="inspector-main" id="inspector-main" role="region" aria-label="Switch chassis diagrams">
|
||||||
<div class="link-loading">Loading inspector data</div>
|
<div class="link-loading">Loading inspector data</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="inspector-panel" id="inspector-panel">
|
<div class="inspector-panel" id="inspector-panel" role="complementary" aria-label="Port detail panel">
|
||||||
<div class="inspector-panel-inner" id="inspector-panel-inner"></div>
|
<div class="inspector-panel-inner" id="inspector-panel-inner"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,6 +231,7 @@ function selectPort(el) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closePanel() {
|
function closePanel() {
|
||||||
|
if (_diagPollTimer) { clearInterval(_diagPollTimer); _diagPollTimer = null; }
|
||||||
document.getElementById('inspector-panel').classList.remove('open');
|
document.getElementById('inspector-panel').classList.remove('open');
|
||||||
document.querySelectorAll('.switch-port-block.selected')
|
document.querySelectorAll('.switch-port-block.selected')
|
||||||
.forEach(el => el.classList.remove('selected'));
|
.forEach(el => el.classList.remove('selected'));
|
||||||
@@ -468,7 +469,7 @@ async function loadInspector() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadInspector();
|
loadInspector();
|
||||||
var _inspInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 60;
|
const _inspInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 60;
|
||||||
if (_inspInterval > 0) lt.autoRefresh.start(loadInspector, Math.max(_inspInterval, 15) * 1000);
|
if (_inspInterval > 0) lt.autoRefresh.start(loadInspector, Math.max(_inspInterval, 15) * 1000);
|
||||||
|
|
||||||
window.onGandalfSettingsChanged = function(s) {
|
window.onGandalfSettingsChanged = function(s) {
|
||||||
|
|||||||
+26
-11
@@ -349,7 +349,7 @@ function renderUnifiSwitches(unifiSwitches, dataUpdated) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="link-host-panel" id="panel-${CSS.escape(swName)}">
|
<div class="link-host-panel" id="panel-${CSS.escape(swName)}">
|
||||||
<div class="link-host-title" data-action="toggle-panel">
|
<div class="link-host-title" data-action="toggle-panel" role="button" tabindex="0" aria-expanded="true">
|
||||||
<span class="link-host-name">${escHtml(swName)}</span>
|
<span class="link-host-name">${escHtml(swName)}</span>
|
||||||
<span class="link-host-ip">${escHtml(sw.ip || '')}</span>
|
<span class="link-host-ip">${escHtml(sw.ip || '')}</span>
|
||||||
<span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span>
|
<span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span>
|
||||||
@@ -366,8 +366,11 @@ function renderUnifiSwitches(unifiSwitches, dataUpdated) {
|
|||||||
// ── Panel collapse / expand ───────────────────────────────────────
|
// ── Panel collapse / expand ───────────────────────────────────────
|
||||||
function togglePanel(panel) {
|
function togglePanel(panel) {
|
||||||
panel.classList.toggle('collapsed');
|
panel.classList.toggle('collapsed');
|
||||||
const btn = panel.querySelector('.panel-toggle');
|
const isCollapsed = panel.classList.contains('collapsed');
|
||||||
if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[–]';
|
const btn = panel.querySelector('.panel-toggle');
|
||||||
|
const title = panel.querySelector('.link-host-title');
|
||||||
|
if (btn) btn.textContent = isCollapsed ? '[+]' : '[–]';
|
||||||
|
if (title) title.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
|
||||||
const id = panel.id;
|
const id = panel.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
|
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
|
||||||
@@ -383,8 +386,10 @@ function restoreCollapseState() {
|
|||||||
if (!panel) continue;
|
if (!panel) continue;
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
panel.classList.add('collapsed');
|
panel.classList.add('collapsed');
|
||||||
const btn = panel.querySelector('.panel-toggle');
|
const btn = panel.querySelector('.panel-toggle');
|
||||||
if (btn) btn.textContent = '[+]';
|
const title = panel.querySelector('.link-host-title');
|
||||||
|
if (btn) btn.textContent = '[+]';
|
||||||
|
if (title) title.setAttribute('aria-expanded', 'false');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,12 +468,12 @@ function renderLinks(data) {
|
|||||||
const sample = Object.values(ifaces)[0] || {};
|
const sample = Object.values(ifaces)[0] || {};
|
||||||
const ip = sample.host_ip || '';
|
const ip = sample.host_ip || '';
|
||||||
const updStr = data.updated
|
const updStr = data.updated
|
||||||
? new Date(data.updated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString()
|
? new Date(_toIso(data.updated)).toLocaleTimeString()
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
parts.push(`
|
parts.push(`
|
||||||
<div class="link-host-panel" id="panel-${CSS.escape(hostname)}">
|
<div class="link-host-panel" id="panel-${CSS.escape(hostname)}">
|
||||||
<div class="link-host-title" data-action="toggle-panel">
|
<div class="link-host-title" data-action="toggle-panel" role="button" tabindex="0" aria-expanded="true">
|
||||||
<span class="link-host-name">${escHtml(hostname)}</span>
|
<span class="link-host-name">${escHtml(hostname)}</span>
|
||||||
<span class="link-host-ip">${escHtml(ip)}</span>
|
<span class="link-host-ip">${escHtml(ip)}</span>
|
||||||
<span class="link-host-upd">${updStr}</span>
|
<span class="link-host-upd">${updStr}</span>
|
||||||
@@ -498,8 +503,10 @@ function applyLinksSearch() {
|
|||||||
function collapseAll() {
|
function collapseAll() {
|
||||||
document.querySelectorAll('.link-host-panel').forEach(p => {
|
document.querySelectorAll('.link-host-panel').forEach(p => {
|
||||||
p.classList.add('collapsed');
|
p.classList.add('collapsed');
|
||||||
const btn = p.querySelector('.panel-toggle');
|
const btn = p.querySelector('.panel-toggle');
|
||||||
if (btn) btn.textContent = '[+]';
|
const title = p.querySelector('.link-host-title');
|
||||||
|
if (btn) btn.textContent = '[+]';
|
||||||
|
if (title) title.setAttribute('aria-expanded', 'false');
|
||||||
});
|
});
|
||||||
sessionStorage.setItem('linksCollapsed', JSON.stringify(
|
sessionStorage.setItem('linksCollapsed', JSON.stringify(
|
||||||
Object.fromEntries([...document.querySelectorAll('.link-host-panel')].map(p => [p.id, true]))
|
Object.fromEntries([...document.querySelectorAll('.link-host-panel')].map(p => [p.id, true]))
|
||||||
@@ -509,8 +516,10 @@ function collapseAll() {
|
|||||||
function expandAll() {
|
function expandAll() {
|
||||||
document.querySelectorAll('.link-host-panel').forEach(p => {
|
document.querySelectorAll('.link-host-panel').forEach(p => {
|
||||||
p.classList.remove('collapsed');
|
p.classList.remove('collapsed');
|
||||||
const btn = p.querySelector('.panel-toggle');
|
const btn = p.querySelector('.panel-toggle');
|
||||||
if (btn) btn.textContent = '[–]';
|
const title = p.querySelector('.link-host-title');
|
||||||
|
if (btn) btn.textContent = '[–]';
|
||||||
|
if (title) title.setAttribute('aria-expanded', 'true');
|
||||||
});
|
});
|
||||||
sessionStorage.setItem('linksCollapsed', '{}');
|
sessionStorage.setItem('linksCollapsed', '{}');
|
||||||
}
|
}
|
||||||
@@ -575,6 +584,12 @@ document.addEventListener('click', e => {
|
|||||||
if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; }
|
if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||||
|
const toggleTitle = e.target.closest('[data-action="toggle-panel"]');
|
||||||
|
if (toggleTitle) { e.preventDefault(); togglePanel(toggleTitle.closest('.link-host-panel')); }
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('links-search')?.addEventListener('input', applyLinksSearch);
|
document.getElementById('links-search')?.addEventListener('input', applyLinksSearch);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from diagnose import DiagnosticsRunner # noqa: E402
|
|||||||
# ── build_ssh_command ────────────────────────────────────────────────────────
|
# ── build_ssh_command ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestBuildSshCommand:
|
class TestBuildSshCommand:
|
||||||
def test_contains_stricthostkeychecking_no(self):
|
def test_contains_stricthostkeychecking_accept_new(self):
|
||||||
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0')
|
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0')
|
||||||
assert 'StrictHostKeyChecking=no' in cmd
|
assert 'StrictHostKeyChecking=accept-new' in cmd
|
||||||
|
|
||||||
def test_contains_host_ip(self):
|
def test_contains_host_ip(self):
|
||||||
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0')
|
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0')
|
||||||
|
|||||||
Reference in New Issue
Block a user