Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e

This commit is contained in:
Public Source Publisher committed 2026-10-01 19:39:31 +00:00
1 parent c61fbfd87f
commit 1a54836efc
101 files changed
+4973 -1298

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
"""Read-only GPU-Hot snapshot collection with an allowlisted public result."""
from __future__ import annotations
import json
import math
import re
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlsplit
import httpx
from ..core.config import settings
_MAX_RESPONSE_BYTES = 1_048_576
_MAX_FUTURE_SECONDS = 5.0
def _result(status: str, reason: str | None, *, devices=None, observed_at=None) -> dict[str, Any]:
return {
'status': status,
'observedAt': observed_at,
'devices': devices or [],
'reason': reason,
'source': 'gpu-hot',
}
def _number(value: Any, *, minimum: float = 0.0, maximum: float | None = None) -> float | None:
if value is None or isinstance(value, bool):
return None
try:
numeric = float(value)
except (TypeError, ValueError, OverflowError):
return None
if not math.isfinite(numeric) or numeric < minimum or (maximum is not None and numeric > maximum):
return None
return numeric
def _source_time(value: Any) -> datetime | None:
if not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))
if parsed.tzinfo is None or parsed.utcoffset() is None:
return None
return parsed.astimezone(timezone.utc)
except (ValueError, OverflowError):
return None
def _index(value: Any) -> str | None:
if isinstance(value, bool) or not isinstance(value, (int, str)):
return None
text = str(value)
if not re.fullmatch(r'[0-9]{1,6}', text):
return None
return str(int(text))
def normalize_gpu_snapshot(payload, *, now=None, max_age_seconds=30.0):
"""Keep measured values; never infer zero usage or a source timezone."""
if not isinstance(payload, dict):
return _result('error', 'gpu_metrics_invalid_response')
raw_devices = payload.get('gpus')
if isinstance(raw_devices, dict):
entries = list(raw_devices.items())
elif isinstance(raw_devices, list):
entries = list(enumerate(raw_devices))
else:
return _result('error', 'gpu_metrics_invalid_response')
if not entries:
return _result('unavailable', 'gpu_metrics_no_devices')
current_time = now if isinstance(now, datetime) else datetime.now(timezone.utc)
if current_time.tzinfo is None:
current_time = current_time.replace(tzinfo=timezone.utc)
current_time = current_time.astimezone(timezone.utc)
age_limit = _number(max_age_seconds) or 30.0
devices = []
observed_times = []
seen_ids = set()
incomplete = False
missing_time = False
incomplete_values = False
for key, raw_device in entries:
if not isinstance(raw_device, dict):
incomplete = True
continue
index = _index(raw_device.get('index')) or _index(key)
if index is None or index in seen_ids:
incomplete = True
continue
observed_time = _source_time(raw_device.get('timestamp'))
if observed_time is not None:
age_seconds = (current_time - observed_time).total_seconds()
if age_seconds > age_limit or age_seconds < -_MAX_FUTURE_SECONDS:
incomplete = True
continue
observed_times.append(observed_time)
else:
missing_time = True
raw_name = raw_device.get('name')
name = re.sub(r'[\x00-\x1f\x7f]', '', raw_name).strip()[:160] if isinstance(raw_name, str) else ''
if not name:
name = f'GPU {index}'
incomplete_values = True
utilization = _number(raw_device.get('utilization'), maximum=100.0)
memory_used = _number(raw_device.get('memory_used'))
memory_total = _number(raw_device.get('memory_total'))
if memory_total == 0:
memory_total = None
if memory_used is not None and memory_total is not None and memory_used > memory_total:
memory_used = None
temperature = _number(raw_device.get('temperature'), maximum=150.0)
values = (utilization, memory_used, memory_total, temperature)
if any(value is None for value in values):
incomplete_values = True
devices.append({
'id': f'gpu-{index}',
'name': name,
'utilizationPercent': utilization,
'memoryUsedMiB': memory_used,
'memoryTotalMiB': memory_total,
'temperatureC': temperature,
})
seen_ids.add(index)
if not devices:
return _result('unavailable', 'gpu_metrics_no_devices')
observed_at = None
if observed_times and not missing_time:
observed_at = min(observed_times).isoformat().replace('+00:00', 'Z')
partial = incomplete or incomplete_values or missing_time
reason = None
if partial:
reason = 'gpu_metrics_timestamp_unavailable' if missing_time and not incomplete and not incomplete_values else 'gpu_metrics_partial'
return _result('partial' if partial else 'ok', reason, devices=devices, observed_at=observed_at)
class GpuMetricsConnector:
def __init__(self, snapshot_url=None, verify_tls=True, timeout_seconds=3.0):
self.snapshot_url = settings.gpu_metrics_url if snapshot_url is None else snapshot_url
self.verify_tls = verify_tls
duration = _number(timeout_seconds)
self.timeout_seconds = min(max(duration if duration is not None else 3.0, 0.05), 3.0)
def get_metrics(self):
if not self.snapshot_url:
return _result('missing_config', 'gpu_metrics_missing_config')
if not self._valid_url():
return _result('error', 'gpu_metrics_invalid_url')
try:
with (
httpx.Client(verify=self.verify_tls, timeout=self.timeout_seconds, follow_redirects=False, trust_env=False) as client,
client.stream('GET', self.snapshot_url, headers={'Accept': 'application/json'}) as response,
):
if response.status_code != 200:
return _result('unavailable', 'gpu_metrics_http_error')
body = bytearray()
for chunk in response.iter_bytes(chunk_size=65_536):
if len(body) + len(chunk) > _MAX_RESPONSE_BYTES:
return _result('error', 'gpu_metrics_response_too_large')
body.extend(chunk)
payload = json.loads(body)
return normalize_gpu_snapshot(payload)
except httpx.HTTPError:
return _result('unavailable', 'gpu_metrics_unreachable')
except httpx.InvalidURL:
return _result('error', 'gpu_metrics_invalid_url')
except (ValueError, UnicodeError):
return _result('error', 'gpu_metrics_invalid_response')
def _valid_url(self) -> bool:
if not isinstance(self.snapshot_url, str) or re.search(r'[\s\x00-\x1f\x7f]', self.snapshot_url):
return False
try:
parsed = urlsplit(self.snapshot_url)
return (
parsed.scheme in {'http', 'https'}
and bool(parsed.hostname)
and parsed.username is None
and parsed.password is None
and not parsed.fragment
and (parsed.port is None or 0 < parsed.port <= 65535)
)
except ValueError:
return False
@@ -10,6 +10,7 @@ import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from ..services.exposure_preferences import runtime_nginx_access_list_checks_enabled
from .base import BaseConnector
@@ -307,15 +308,16 @@ class NginxProxyManagerConnector(BaseConnector):
}
def exposure_summary(self) -> dict[str, Any]:
access_list_checks_enabled = runtime_nginx_access_list_checks_enabled()
hosts = self.proxy_hosts()
certs = self.certificates()
access_lists = self.access_lists()
access_lists = self.access_lists() if access_list_checks_enabled else {}
if not hosts.get('success'):
return {**hosts, 'data': {'warnings': []}}
host_items = hosts.get('items', [])
cert_items = certs.get('items', []) if certs.get('success') else []
cert_by_id = {item.get('id'): item for item in cert_items}
warnings = self._build_warnings(host_items, cert_by_id)
warnings = self._build_warnings(host_items, cert_by_id, nginx_access_list_checks_enabled=access_list_checks_enabled)
summary = {
'totalProxyHosts': len(host_items),
'enabledProxyHosts': sum(1 for item in host_items if item.get('enabled')),
@@ -327,6 +329,7 @@ class NginxProxyManagerConnector(BaseConnector):
'hostsWithoutSslForce': sum(1 for item in host_items if item.get('enabled') and not item.get('sslForced')),
'hostsWithWebsocket': sum(1 for item in host_items if item.get('websocketSupport')),
'accessLists': access_lists.get('total', 0),
'nginxAccessListChecksEnabled': access_list_checks_enabled,
'warnings': warnings,
}
return {'success': True, 'status': 'ok', 'message': 'exposure_summary_ok', **summary, 'data': summary}
@@ -353,7 +356,7 @@ class NginxProxyManagerConnector(BaseConnector):
if not hosts.get('success'):
return {**hosts, 'items': [], 'total': 0, 'data': {'items': []}}
cert_by_id = {item.get('id'): item for item in certs.get('items', [])} if certs.get('success') else {}
items = self._build_warnings(hosts.get('items', []), cert_by_id)
items = self._build_warnings(hosts.get('items', []), cert_by_id, nginx_access_list_checks_enabled=runtime_nginx_access_list_checks_enabled())
severity_order = {'critical': 3, 'high': 2, 'warning': 1, 'info': 0}
status = 'warning' if items else 'ok'
return {
@@ -609,7 +612,7 @@ class NginxProxyManagerConnector(BaseConnector):
}
@staticmethod
def _build_warnings(hosts: list[dict[str, Any]], cert_by_id: dict[Any, dict[str, Any]]) -> list[dict[str, Any]]:
def _build_warnings(hosts: list[dict[str, Any]], cert_by_id: dict[Any, dict[str, Any]], *, nginx_access_list_checks_enabled: bool = True) -> list[dict[str, Any]]:
warnings: list[dict[str, Any]] = []
for host in hosts:
if not host.get('enabled'):
@@ -624,7 +627,7 @@ class NginxProxyManagerConnector(BaseConnector):
warnings.append({'type': 'certificate_expired', 'severity': 'critical', 'title': f'{label} certificate is expired', 'evidence': [label, str(cert.get('expiresOn'))], 'suggestedAction': 'review_certificate_in_npm'})
elif cert and cert.get('isExpiringSoon'):
warnings.append({'type': 'certificate_expiring_soon', 'severity': 'warning', 'title': f'{label} certificate expires soon', 'evidence': [label, f"{cert.get('expiresInDays')} days", str(cert.get('expiresOn'))], 'suggestedAction': 'review_certificate_in_npm'})
if not host.get('accessListId'):
if nginx_access_list_checks_enabled and not host.get('accessListId'):
warnings.append({'type': 'public_host_without_access_list', 'severity': 'high', 'title': f'{label} has no access list', 'evidence': [label, target], 'suggestedAction': 'review_access_protection'})
if not host.get('sslForced'):
warnings.append({'type': 'ssl_not_forced', 'severity': 'warning', 'title': f'{label} does not force SSL', 'evidence': [label], 'suggestedAction': 'review_ssl_policy'})
+12 -9
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass
from typing import Any
@@ -11,7 +12,6 @@ from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
_UNRAID_DEFAULT_TIMEOUT_SECONDS = 8.0
_UNRAID_REDIRECT_STATUSES = {301, 302, 303, 307, 308}
@@ -70,7 +70,8 @@ def _as_float(value: Any) -> float | None:
return None
if isinstance(value, int | float):
return float(value)
number = float(value)
return number if math.isfinite(number) else None
if isinstance(value, str):
cleaned = value.strip().strip('%')
@@ -80,7 +81,8 @@ def _as_float(value: Any) -> float | None:
if not numeric:
return None
try:
return float(numeric)
number = float(numeric)
return number if math.isfinite(number) else None
except ValueError:
return None
@@ -91,15 +93,16 @@ def _clamp_percent(value: float) -> int:
return int(round(max(0.0, min(100.0, value))))
def _percent_from_values(used: Any, total: Any) -> int | None:
def _percent_from_values(used: Any, total: Any, *, precise: bool = False) -> float | int | None:
used_value = _as_float(used)
total_value = _as_float(total)
if used_value is None or total_value is None or total_value <= 0:
return None
return _clamp_percent((used_value / total_value) * 100)
percent = max(0.0, min(100.0, (used_value / total_value) * 100))
return round(percent, 2) if precise else _clamp_percent(percent)
def _average_cpu_usage(cpus: Any) -> int | None:
def _average_cpu_usage(cpus: Any) -> float | None:
if not isinstance(cpus, list) or not cpus:
return None
@@ -127,9 +130,9 @@ def _average_cpu_usage(cpus: Any) -> int | None:
active_values.append(active_total)
if idle_values:
return _clamp_percent(100 - (sum(idle_values) / len(idle_values)))
return round(max(0.0, min(100.0, 100 - (sum(idle_values) / len(idle_values)))), 2)
if active_values:
return _clamp_percent(sum(active_values) / len(active_values))
return round(max(0.0, min(100.0, sum(active_values) / len(active_values))), 2)
return None
@@ -398,7 +401,7 @@ class UnraidConnector(BaseConnector):
)
if ram_uses_available:
memory_used = memory_total - memory_available
ram_usage = _percent_from_values(memory_used, memory_total)
ram_usage = _percent_from_values(memory_used, memory_total, precise=True)
missing = []
if cpu_usage is None:
missing.append('cpu_unavailable')