670 lines
27 KiB
Python
670 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from datetime import UTC, datetime
|
|
from threading import Lock
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlmodel import Session, select
|
|
|
|
from ..connectors.authentik import AuthentikConnector
|
|
from ..connectors.gpu_metrics import GpuMetricsConnector
|
|
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
|
from ..connectors.portainer import PortainerConnector
|
|
from ..connectors.unraid import UnraidConnector
|
|
from ..core.config import settings
|
|
from ..core.redaction import redact_message
|
|
from ..core.time import utc_now
|
|
from ..db.models import AppSetting, AuditEvent, Connector, JobRun, Policy, Secret
|
|
from ..db.session import get_session
|
|
from ..services.app_registry import list_active_apps
|
|
from ..services.incident_service import list_incidents as list_live_incidents
|
|
from ..services.incident_service import (
|
|
update_incident_status as update_live_incident_status,
|
|
)
|
|
from ..services.setup_service import apply_stored_connector_settings
|
|
from ..services.exposure_preferences import get_exposure_preferences, runtime_nginx_access_list_checks_enabled
|
|
|
|
router = APIRouter(prefix='/api')
|
|
|
|
NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION = 2
|
|
NETWORK_EXPOSURE_CACHE_KEY = 'network_exposure:cache'
|
|
NETWORK_EXPOSURE_CACHE_TTL_SECONDS = 60
|
|
NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS = 300
|
|
STATISTICS_HISTORY_MAX_POINTS = 361
|
|
_STATISTICS_HISTORY: list[dict[str, Any]] = []
|
|
_STATISTICS_HISTORY_LOCK = Lock()
|
|
|
|
|
|
@router.get('/incidents')
|
|
def list_incidents(session: Session = Depends(get_session)):
|
|
return list_live_incidents(session)
|
|
|
|
|
|
@router.post('/incidents/{incident_id}/status')
|
|
def update_incident_status(incident_id: str, payload: dict | None = None, session: Session = Depends(get_session)):
|
|
status = str(payload.get('status') or 'acknowledged') if isinstance(payload, dict) else 'acknowledged'
|
|
return update_live_incident_status(session, incident_id, status)
|
|
|
|
|
|
@router.get('/operations-map')
|
|
def operations_map(session: Session = Depends(get_session)):
|
|
apps = {app.id: app for app in list_active_apps(session, order_by='id')}
|
|
connectors = session.exec(select(Connector).order_by(Connector.id)).all()
|
|
policies = session.exec(select(Policy).order_by(Policy.id)).all()
|
|
secrets_count = len(session.exec(select(Secret.id)).all())
|
|
generated_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
|
|
|
|
nodes: set[str] = {'Connector Registry', 'Policy Engine', 'Secret Store'}
|
|
edges: set[tuple[str, str]] = {('Secret Store', 'Connector Registry')}
|
|
|
|
for connector in connectors:
|
|
app = apps.get(connector.id)
|
|
if app is None:
|
|
continue
|
|
nodes.add(app.name)
|
|
edges.add(('Connector Registry', app.name))
|
|
if connector.secret_ref:
|
|
edges.add(('Secret Store', app.name))
|
|
|
|
for policy in policies:
|
|
if policy.target == '*':
|
|
continue
|
|
app = apps.get(policy.target)
|
|
if app is None:
|
|
continue
|
|
nodes.add(app.name)
|
|
edges.add(('Policy Engine', app.name))
|
|
|
|
return {
|
|
'generatedAt': generated_at,
|
|
'sources': {
|
|
'apps': {'status': 'ok', 'count': len(apps)},
|
|
'connectors': {'status': 'ok', 'count': len(connectors)},
|
|
'policies': {'status': 'ok', 'count': len(policies)},
|
|
'secrets': {'status': 'metadata_only', 'count': secrets_count},
|
|
},
|
|
'nodes': sorted(nodes),
|
|
'edges': [
|
|
{'from': source, 'to': target}
|
|
for source, target in sorted(edges)
|
|
if source in nodes and target in nodes
|
|
],
|
|
}
|
|
|
|
|
|
def _nullable_percent(value: Any) -> float | None:
|
|
if value is None or isinstance(value, bool):
|
|
return None
|
|
if isinstance(value, str):
|
|
value = value.strip().strip('%')
|
|
try:
|
|
number = float(value)
|
|
except (ValueError, TypeError, OverflowError):
|
|
return None
|
|
return round(number, 2) if math.isfinite(number) and 0 <= number <= 100 else None
|
|
|
|
|
|
def _record_statistics_sample(generated_at: str, cpu_usage: float | None, ram_usage: float | None, gpu_usage: float | None) -> list[dict[str, Any]]:
|
|
sample = {'time': generated_at, 'cpu': cpu_usage, 'ram': ram_usage, 'gpu': gpu_usage}
|
|
sampled_at = datetime.fromisoformat(generated_at.replace('Z', '+00:00'))
|
|
with _STATISTICS_HISTORY_LOCK:
|
|
if _STATISTICS_HISTORY:
|
|
previous_at = datetime.fromisoformat(_STATISTICS_HISTORY[-1]['time'].replace('Z', '+00:00'))
|
|
elapsed = (sampled_at - previous_at).total_seconds()
|
|
if elapsed >= 0 and int(sampled_at.timestamp() // 5) == int(previous_at.timestamp() // 5):
|
|
# Multiple viewers refresh the latest real measurement, rather than exhausting the window.
|
|
_STATISTICS_HISTORY[-1] = sample
|
|
elif elapsed >= 0:
|
|
_STATISTICS_HISTORY.append(sample)
|
|
else:
|
|
_STATISTICS_HISTORY.append(sample)
|
|
_STATISTICS_HISTORY[:] = [
|
|
item for item in _STATISTICS_HISTORY
|
|
if (sampled_at - datetime.fromisoformat(item['time'].replace('Z', '+00:00'))).total_seconds() <= 1800
|
|
][-STATISTICS_HISTORY_MAX_POINTS:]
|
|
return list(_STATISTICS_HISTORY)
|
|
|
|
|
|
@router.get('/statistics/summary')
|
|
def statistics_summary(session: Session = Depends(get_session)):
|
|
apply_stored_connector_settings(session)
|
|
gpu_connector = GpuMetricsConnector(snapshot_url=settings.gpu_metrics_url, verify_tls=settings.verify_tls)
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
unraid_future = executor.submit(UnraidConnector().get_system_metrics)
|
|
gpu_future = executor.submit(gpu_connector.get_metrics)
|
|
metrics = unraid_future.result()
|
|
gpu = gpu_future.result()
|
|
generated_at = datetime.now(UTC).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
|
|
cpu_usage = _nullable_percent(metrics.get('cpuUsage'))
|
|
ram_usage = _nullable_percent(metrics.get('ramUsage'))
|
|
gpu_values = [_nullable_percent(device.get('utilizationPercent')) for device in gpu.get('devices', [])]
|
|
gpu_usage = max((value for value in gpu_values if value is not None), default=None)
|
|
gpu = {**gpu, 'receivedAt': generated_at}
|
|
samples = _record_statistics_sample(generated_at, cpu_usage, ram_usage, gpu_usage)
|
|
source_warnings = []
|
|
if metrics.get('status') != 'ok':
|
|
source_warnings.append({
|
|
'source': 'unraid',
|
|
'surface': 'metrics',
|
|
'status': metrics.get('status', 'unknown'),
|
|
'reason': redact_message(str(metrics.get('reason', 'metrics_unavailable')))[:240],
|
|
})
|
|
if gpu.get('status') != 'ok':
|
|
source_warnings.append({
|
|
'source': 'gpu', 'surface': 'metrics', 'status': gpu.get('status', 'unknown'),
|
|
'reason': redact_message(str(gpu.get('reason', 'gpu_metrics_unavailable')))[:240],
|
|
})
|
|
|
|
audit_events = session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).all()
|
|
jobs = session.exec(select(JobRun).order_by(JobRun.started_at.desc())).all()
|
|
restarts: dict[str, int] = {}
|
|
durations_ms: list[int] = []
|
|
for job in jobs:
|
|
if job.action_id == 'container.restart':
|
|
restarts[job.target] = restarts.get(job.target, 0) + 1
|
|
if job.status == 'succeeded' and job.duration_ms is not None:
|
|
durations_ms.append(int(job.duration_ms))
|
|
|
|
blocked_actions = sum(
|
|
1
|
|
for event in audit_events
|
|
if event.policy_decision == 'blocked' or event.result == 'blocked'
|
|
)
|
|
voice_action_count = sum(
|
|
1 for event in audit_events
|
|
if event.source == 'voice' or event.identity == 'voice'
|
|
or (event.redacted_metadata or {}).get('requestSource') == 'voice'
|
|
)
|
|
mttr_hours = None
|
|
if durations_ms:
|
|
mttr_hours = round((sum(durations_ms) / len(durations_ms)) / 1000 / 60 / 60, 2)
|
|
|
|
return {
|
|
'generatedAt': generated_at,
|
|
'sources': {'unraid': metrics.get('status', 'unknown'), 'gpu': gpu.get('status', 'unknown'), 'audit': 'ok', 'jobs': 'ok'},
|
|
'sourceWarnings': source_warnings,
|
|
'cpuHistory': [{'time': sample['time'], 'value': sample.get('cpu')} for sample in samples],
|
|
'ramHistory': [{'time': sample['time'], 'value': sample.get('ram')} for sample in samples],
|
|
'gpuHistory': [{'time': sample['time'], 'value': sample.get('gpu')} for sample in samples],
|
|
'gpu': gpu,
|
|
'restartsByApp': [
|
|
{'appId': app_id, 'count': count}
|
|
for app_id, count in sorted(restarts.items(), key=lambda item: (-item[1], item[0]))
|
|
],
|
|
'blockedActions': blocked_actions,
|
|
'voiceActionCount': voice_action_count,
|
|
'mttrHours': mttr_hours,
|
|
}
|
|
|
|
|
|
@router.get('/storage/summary')
|
|
def storage_summary(session: Session = Depends(get_session)):
|
|
apply_stored_connector_settings(session)
|
|
unraid = UnraidConnector()
|
|
portainer = PortainerConnector()
|
|
cache_usage = 0
|
|
storage_pools: list[dict] = []
|
|
shares: list[dict] = []
|
|
disks: list[dict] = []
|
|
app_data_usage: list[dict] = []
|
|
source_warnings: list[dict] = []
|
|
|
|
cache_result = unraid.get_cache_status()
|
|
if cache_result.get('status') == 'ok':
|
|
cache_usage = int(cache_result.get('cacheUsage', 0) or 0)
|
|
storage_pools = [
|
|
{
|
|
'name': str(pool.get('name', 'cache')),
|
|
'status': str(pool.get('status', 'unknown')),
|
|
'healthy': pool.get('healthy'),
|
|
'type': pool.get('type'),
|
|
'usedGiB': pool.get('usedGiB'),
|
|
'sizeGiB': pool.get('sizeGiB'),
|
|
'freeGiB': pool.get('freeGiB'),
|
|
'usagePercent': pool.get('usagePercent'),
|
|
}
|
|
for pool in cache_result.get('pools', [])
|
|
if isinstance(pool, dict)
|
|
]
|
|
elif cache_result.get('status') not in {None, 'ok'}:
|
|
source_warnings.append({
|
|
'source': 'unraid',
|
|
'surface': 'cache',
|
|
'status': cache_result.get('status', 'error'),
|
|
'reason': redact_message(str(cache_result.get('reason', 'cache_unavailable')))[:240],
|
|
})
|
|
|
|
share_result = unraid.get_share_summary()
|
|
if share_result.get('status') == 'ok':
|
|
shares = [
|
|
{
|
|
'name': str(share.get('name', 'unknown')),
|
|
'usedGiB': float(share.get('usedGiB', 0) or 0),
|
|
'sizeGiB': float(share.get('sizeGiB', 0) or 0),
|
|
'critical': bool(share.get('critical', False)),
|
|
}
|
|
for share in share_result.get('shares', [])
|
|
if isinstance(share, dict)
|
|
]
|
|
elif share_result.get('status') not in {None, 'ok'}:
|
|
source_warnings.append({
|
|
'source': 'unraid',
|
|
'surface': 'shares',
|
|
'status': share_result.get('status', 'error'),
|
|
'reason': redact_message(str(share_result.get('reason', 'shares_unavailable')))[:240],
|
|
})
|
|
|
|
disk_result = unraid.get_disk_warnings()
|
|
if disk_result.get('status') == 'ok':
|
|
disks = [
|
|
{
|
|
'name': str(disk.get('name', 'unknown')),
|
|
'status': str(disk.get('status', 'unknown')),
|
|
'healthy': disk.get('healthy'),
|
|
'type': disk.get('type'),
|
|
'usedGiB': disk.get('usedGiB'),
|
|
'sizeGiB': disk.get('sizeGiB'),
|
|
'freeGiB': disk.get('freeGiB'),
|
|
'temperature': disk.get('temperature'),
|
|
}
|
|
for disk in disk_result.get('disks', [])
|
|
if isinstance(disk, dict)
|
|
]
|
|
elif disk_result.get('status') not in {None, 'ok'}:
|
|
source_warnings.append({
|
|
'source': 'unraid',
|
|
'surface': 'disks',
|
|
'status': disk_result.get('status', 'error'),
|
|
'reason': redact_message(str(disk_result.get('reason', 'disks_unavailable')))[:240],
|
|
})
|
|
|
|
share_names = {
|
|
str(share.get('name', '')).strip('/').lower(): share
|
|
for share in shares
|
|
if str(share.get('name', '')).strip()
|
|
}
|
|
|
|
containers_result = portainer.list_containers()
|
|
if containers_result.get('status') == 'ok':
|
|
seen: set[str] = set()
|
|
for container in containers_result.get('containers', []):
|
|
if not isinstance(container, dict):
|
|
continue
|
|
app_id = str(container.get('appId') or container.get('name') or '').strip()
|
|
if not app_id:
|
|
continue
|
|
container_name = str(container.get('name') or app_id).strip()
|
|
mounts = container.get('mounts', [])
|
|
if not isinstance(mounts, list) or not mounts:
|
|
key = f'{app_id}:{container_name}:container'
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
app_data_usage.append({
|
|
'appId': app_id,
|
|
'container': container_name,
|
|
'usedGiB': None,
|
|
'volume': container_name,
|
|
'source': None,
|
|
'destination': None,
|
|
'type': 'container',
|
|
'share': None,
|
|
'measurement': 'not_available',
|
|
})
|
|
continue
|
|
|
|
if isinstance(mounts, list):
|
|
for mount in mounts:
|
|
if not isinstance(mount, dict):
|
|
continue
|
|
source = str(mount.get('source') or '').strip()
|
|
destination = str(mount.get('destination') or '').strip()
|
|
mount_type = str(mount.get('type') or '').strip() or 'unknown'
|
|
if not source and not destination:
|
|
continue
|
|
key = f'{app_id}:{container_name}:{source}:{destination}'
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
parts = [part for part in source.replace('\\', '/').split('/') if part]
|
|
share_name = parts[2] if len(parts) >= 3 and parts[0] == 'mnt' and parts[1] in {'user', 'cache'} else None
|
|
share = share_names.get(str(share_name or '').lower())
|
|
app_data_usage.append({
|
|
'appId': app_id,
|
|
'container': container_name,
|
|
'usedGiB': None,
|
|
'volume': f'{source or mount_type} -> {destination or "container"}',
|
|
'source': source or None,
|
|
'destination': destination or None,
|
|
'type': mount_type,
|
|
'share': share_name,
|
|
'shareUsagePercent': round((float(share.get('usedGiB', 0) or 0) / float(share.get('sizeGiB', 0) or 1)) * 100) if share and float(share.get('sizeGiB', 0) or 0) > 0 else None,
|
|
'measurement': 'mount_discovered',
|
|
})
|
|
elif containers_result.get('status') not in {None, 'ok'}:
|
|
source_warnings.append({
|
|
'source': 'portainer',
|
|
'surface': 'containers',
|
|
'status': containers_result.get('status', 'error'),
|
|
'reason': redact_message(str(containers_result.get('reason', 'containers_unavailable')))[:240],
|
|
})
|
|
|
|
total_size = sum(float(share.get('sizeGiB', 0) or 0) for share in shares)
|
|
total_used = sum(float(share.get('usedGiB', 0) or 0) for share in shares)
|
|
array_usage = round((total_used / total_size) * 100) if total_size > 0 else 0
|
|
|
|
return {
|
|
'cacheUsage': cache_usage,
|
|
'arrayUsage': array_usage,
|
|
'disks': disks,
|
|
'storagePools': storage_pools,
|
|
'shares': shares,
|
|
'appDataUsage': app_data_usage,
|
|
'orphanedVolumes': [],
|
|
'forecastDays': None,
|
|
'sources': {
|
|
'unraid': share_result.get('status', cache_result.get('status', 'unknown')),
|
|
'unraidDisks': disk_result.get('status', 'unknown'),
|
|
'portainer': containers_result.get('status', 'unknown'),
|
|
},
|
|
'sourceWarnings': source_warnings,
|
|
}
|
|
|
|
|
|
def _parse_utc(value: str) -> datetime:
|
|
return datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
|
|
|
|
def _network_cache_ttl_seconds() -> int:
|
|
raw = os.getenv('NETWORK_EXPOSURE_CACHE_TTL_SECONDS', str(NETWORK_EXPOSURE_CACHE_TTL_SECONDS))
|
|
try:
|
|
return max(1, int(raw))
|
|
except (TypeError, ValueError):
|
|
return NETWORK_EXPOSURE_CACHE_TTL_SECONDS
|
|
|
|
|
|
def _network_cache_max_stale_seconds() -> int:
|
|
raw = os.getenv('NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS', str(NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS))
|
|
try:
|
|
return max(0, int(raw))
|
|
except (TypeError, ValueError):
|
|
return NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS
|
|
|
|
|
|
def _redact(value):
|
|
if isinstance(value, str):
|
|
return redact_message(value)
|
|
if isinstance(value, list):
|
|
return [_redact(item) for item in value]
|
|
if isinstance(value, dict):
|
|
return {
|
|
str(key): _redact(item)
|
|
for key, item in value.items()
|
|
if 'secret' not in str(key).lower() and 'token' not in str(key).lower() and 'password' not in str(key).lower()
|
|
}
|
|
return value
|
|
|
|
|
|
def _network_exposure_signature(session: Session) -> str:
|
|
connectors = [
|
|
{
|
|
'id': item.id,
|
|
'status': item.status,
|
|
'lastTest': item.last_test,
|
|
'secretRef': item.secret_ref,
|
|
'capabilities': item.capabilities,
|
|
}
|
|
for item in session.exec(select(Connector)).all()
|
|
if item.id in {'nginx_proxy_manager', 'authentik'}
|
|
]
|
|
secret_rows = [
|
|
{
|
|
'id': item.id,
|
|
'name': item.name,
|
|
'updatedAt': item.updated_at.isoformat() if item.updated_at else None,
|
|
'usedBy': item.used_by_json,
|
|
}
|
|
for item in session.exec(select(Secret)).all()
|
|
if any(connector_id in item.used_by_json for connector_id in ('nginx_proxy_manager', 'authentik'))
|
|
]
|
|
signature_payload = {
|
|
'schemaVersion': NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION,
|
|
'exposurePreferences': get_exposure_preferences(session).model_dump(),
|
|
'connectors': sorted(connectors, key=lambda item: item['id']),
|
|
'secrets': sorted(secret_rows, key=lambda item: item['id']),
|
|
}
|
|
serialized = json.dumps(signature_payload, sort_keys=True, default=str, separators=(',', ':'))
|
|
return hashlib.sha256(serialized.encode('utf-8')).hexdigest()
|
|
|
|
|
|
def _persistent_network_exposure(session: Session, signature: str) -> list[dict] | None:
|
|
row = session.get(AppSetting, NETWORK_EXPOSURE_CACHE_KEY)
|
|
if not row or not isinstance(row.value_json, dict):
|
|
return None
|
|
value = row.value_json
|
|
if value.get('schemaVersion') != NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION or value.get('signature') != signature:
|
|
return None
|
|
payload = value.get('payload')
|
|
expires_at_raw = value.get('expiresAt')
|
|
if not isinstance(payload, list) or not isinstance(expires_at_raw, str):
|
|
return None
|
|
expires_at = _parse_utc(expires_at_raw)
|
|
now_dt = datetime.now(UTC)
|
|
stale_until = expires_at.timestamp() + _network_cache_max_stale_seconds()
|
|
if now_dt >= expires_at and now_dt.timestamp() >= stale_until:
|
|
return None
|
|
return [item for item in payload if isinstance(item, dict)]
|
|
|
|
|
|
def _store_persistent_network_exposure(session: Session, signature: str, payload: list[dict]) -> None:
|
|
ttl_seconds = _network_cache_ttl_seconds()
|
|
expires_at = datetime.fromtimestamp(datetime.now(UTC).replace(microsecond=0).timestamp() + ttl_seconds, UTC).isoformat().replace('+00:00', 'Z')
|
|
row = session.get(AppSetting, NETWORK_EXPOSURE_CACHE_KEY)
|
|
if not row:
|
|
row = AppSetting(key=NETWORK_EXPOSURE_CACHE_KEY, is_secret=False)
|
|
row.value_json = {
|
|
'schemaVersion': NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION,
|
|
'signature': signature,
|
|
'payload': _redact(payload),
|
|
'generatedAt': datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z'),
|
|
'expiresAt': expires_at,
|
|
}
|
|
row.updated_at = utc_now()
|
|
session.add(row)
|
|
session.commit()
|
|
|
|
|
|
def _build_network_exposure() -> list[dict]:
|
|
access_list_checks_enabled = runtime_nginx_access_list_checks_enabled()
|
|
with ThreadPoolExecutor(max_workers=3) as executor:
|
|
hosts_future = executor.submit(lambda: NginxProxyManagerConnector().proxy_hosts())
|
|
certs_future = executor.submit(lambda: NginxProxyManagerConnector().certificates())
|
|
coverage_future = executor.submit(_auth_coverage_by_host)
|
|
hosts_payload = hosts_future.result()
|
|
certs_payload = certs_future.result()
|
|
coverage_by_host = coverage_future.result()
|
|
|
|
if not hosts_payload.get('success'):
|
|
return []
|
|
|
|
certs = certs_payload.get('items', []) if certs_payload.get('success') else []
|
|
cert_by_id = {item.get('id'): item for item in certs if isinstance(item, dict)}
|
|
exposures = []
|
|
for host in hosts_payload.get('items', []):
|
|
if not isinstance(host, dict):
|
|
continue
|
|
domains = host.get('domainNames') if isinstance(host.get('domainNames'), list) else []
|
|
label = ', '.join(str(item) for item in domains if str(item).strip()) or f"proxy-host-{host.get('id', 'unknown')}"
|
|
certificate = cert_by_id.get(host.get('certificateId')) if host.get('certificateId') else None
|
|
has_certificate = bool(host.get('certificateId'))
|
|
certificate_expired = bool(certificate and certificate.get('isExpired'))
|
|
certificate_expiring = bool(certificate and certificate.get('isExpiringSoon'))
|
|
coverage = coverage_by_host.get(str(host.get('id')))
|
|
auth_status = str((coverage or {}).get('coverageStatus') or 'unknown')
|
|
auth_protected = auth_status in {'protected', 'likely_protected'}
|
|
npm_protected = bool(host.get('accessListId'))
|
|
protected = npm_protected or auth_protected
|
|
protection_source = 'authentik' if auth_protected else ('npm_access_list' if npm_protected else 'none')
|
|
enabled = bool(host.get('enabled'))
|
|
ssl_forced = bool(host.get('sslForced'))
|
|
risk_reasons: list[str] = []
|
|
if enabled and not protected:
|
|
risk_reasons.append('No NPM access list or Authentik match' if access_list_checks_enabled else 'No Authentik or other verified access protection')
|
|
if enabled and not has_certificate:
|
|
risk_reasons.append('No certificate assigned')
|
|
if certificate_expired:
|
|
risk_reasons.append('Certificate expired')
|
|
elif certificate_expiring:
|
|
risk_reasons.append('Certificate expiring soon')
|
|
if enabled and has_certificate and not ssl_forced:
|
|
risk_reasons.append('SSL is not forced')
|
|
|
|
if not enabled:
|
|
risk = 'low'
|
|
elif certificate_expired:
|
|
risk = 'critical'
|
|
elif not protected or not has_certificate:
|
|
risk = 'high'
|
|
elif not ssl_forced or certificate_expiring:
|
|
risk = 'medium'
|
|
else:
|
|
risk = 'low'
|
|
|
|
exposures.append({
|
|
'source': 'reverse_proxy',
|
|
'host': label,
|
|
'port': int(host.get('forwardPort') or 0),
|
|
'protocol': str(host.get('forwardScheme') or 'http'),
|
|
'protected': protected,
|
|
'protectionSource': protection_source,
|
|
'authCoverageStatus': auth_status,
|
|
'authentikMatches': (coverage or {}).get('authentikMatches') or [],
|
|
'appId': str(host.get('forwardHost') or ''),
|
|
'risk': risk,
|
|
'riskReasons': risk_reasons,
|
|
'externalChangeRequired': bool(risk_reasons),
|
|
})
|
|
return exposures
|
|
|
|
|
|
@router.get('/network/exposure')
|
|
def network_exposure(refresh: bool = False, session: Session = Depends(get_session)):
|
|
apply_stored_connector_settings(session, {'nginx_proxy_manager', 'authentik'})
|
|
signature = _network_exposure_signature(session)
|
|
if not refresh:
|
|
persisted = _persistent_network_exposure(session, signature)
|
|
if persisted is not None:
|
|
return persisted
|
|
exposures = _build_network_exposure()
|
|
_store_persistent_network_exposure(session, signature, exposures)
|
|
return exposures
|
|
|
|
|
|
def _auth_coverage_by_host() -> dict[str, dict]:
|
|
try:
|
|
payload = AuthentikConnector().auth_coverage()
|
|
except Exception:
|
|
return {}
|
|
if not payload.get('success'):
|
|
return {}
|
|
result: dict[str, dict] = {}
|
|
for item in payload.get('publicHosts', []):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
host_id = item.get('hostId')
|
|
if host_id is None:
|
|
continue
|
|
result[str(host_id)] = item
|
|
return result
|
|
|
|
|
|
@router.get('/healthchecks')
|
|
def healthchecks(session: Session = Depends(get_session)):
|
|
checked_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
|
|
connectors = {connector.id: connector for connector in session.exec(select(Connector)).all()}
|
|
|
|
return [
|
|
{'service': 'API Gateway', 'status': 'pass', 'checkedAt': checked_at, 'details': 'DB en route registratie OK'},
|
|
_connector_healthcheck('Portainer', connectors.get('portainer'), checked_at),
|
|
_connector_healthcheck('Unraid', connectors.get('unraid'), checked_at),
|
|
_voice_bridge_healthcheck(connectors.get('home_assistant'), checked_at),
|
|
]
|
|
|
|
|
|
def _connector_healthcheck(service: str, connector: Connector | None, checked_at: str) -> dict:
|
|
if connector is None:
|
|
return {
|
|
'service': service,
|
|
'status': 'warn',
|
|
'checkedAt': checked_at,
|
|
'details': 'Niet aanwezig in connector registry.',
|
|
}
|
|
|
|
status = str(connector.status or 'unknown').lower()
|
|
if status == 'connected':
|
|
return {
|
|
'service': service,
|
|
'status': 'pass',
|
|
'checkedAt': checked_at,
|
|
'details': 'Connector registry meldt verbonden.',
|
|
}
|
|
if status in {'missing_config', 'disconnected', 'unknown'}:
|
|
return {
|
|
'service': service,
|
|
'status': 'warn',
|
|
'checkedAt': checked_at,
|
|
'details': f'Connector registry meldt {status}.',
|
|
}
|
|
return {
|
|
'service': service,
|
|
'status': 'fail',
|
|
'checkedAt': checked_at,
|
|
'details': f'Connector registry meldt {status}.',
|
|
}
|
|
|
|
|
|
def _voice_bridge_healthcheck(home_assistant: Connector | None, checked_at: str) -> dict:
|
|
if not settings.voice_token:
|
|
return {
|
|
'service': 'Voice Bridge',
|
|
'status': 'warn',
|
|
'checkedAt': checked_at,
|
|
'details': 'Voice Bridge is niet geactiveerd.',
|
|
}
|
|
|
|
if home_assistant is None:
|
|
return {
|
|
'service': 'Voice Bridge',
|
|
'status': 'warn',
|
|
'checkedAt': checked_at,
|
|
'details': 'Home Assistant connector ontbreekt.',
|
|
}
|
|
|
|
status = str(home_assistant.status or 'unknown').lower()
|
|
if status == 'connected':
|
|
return {
|
|
'service': 'Voice Bridge',
|
|
'status': 'pass',
|
|
'checkedAt': checked_at,
|
|
'details': 'Home Assistant connector beschikbaar voor voice exports.',
|
|
}
|
|
if status in {'missing_config', 'disconnected', 'unknown'}:
|
|
return {
|
|
'service': 'Voice Bridge',
|
|
'status': 'warn',
|
|
'checkedAt': checked_at,
|
|
'details': f'Home Assistant connector meldt {status}.',
|
|
}
|
|
return {
|
|
'service': 'Voice Bridge',
|
|
'status': 'fail',
|
|
'checkedAt': checked_at,
|
|
'details': f'Home Assistant connector meldt {status}.',
|
|
}
|