from __future__ import annotations from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime import hashlib import json import os from typing import Any from fastapi import APIRouter, Depends from sqlmodel import Session, select from ..connectors.authentik import AuthentikConnector 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.incident_service import list_incidents as list_live_incidents, update_incident_status as update_live_incident_status from ..services.app_registry import list_active_apps from ..services.setup_service import apply_stored_connector_settings router = APIRouter(prefix='/api') NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION = 1 NETWORK_EXPOSURE_CACHE_KEY = 'network_exposure:cache' NETWORK_EXPOSURE_CACHE_TTL_SECONDS = 60 NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS = 300 STATISTICS_HISTORY_MAX_POINTS = 48 _STATISTICS_HISTORY: list[dict[str, Any]] = [] @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_int(value: Any) -> int | None: if value is None or isinstance(value, bool): return None if isinstance(value, int | float): return int(round(float(value))) if isinstance(value, str): stripped = value.strip().strip('%') if not stripped: return None try: return int(round(float(stripped))) except ValueError: return None return None def _record_statistics_sample(generated_at: str, cpu_usage: int | None, ram_usage: int | None) -> list[dict[str, Any]]: if cpu_usage is None and ram_usage is None: return list(_STATISTICS_HISTORY) _STATISTICS_HISTORY.append({'time': generated_at, 'cpu': cpu_usage, 'ram': ram_usage}) if len(_STATISTICS_HISTORY) > STATISTICS_HISTORY_MAX_POINTS: del _STATISTICS_HISTORY[:-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) generated_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z') metrics = UnraidConnector().get_system_metrics() cpu_usage = _nullable_int(metrics.get('cpuUsage')) ram_usage = _nullable_int(metrics.get('ramUsage')) samples = _record_statistics_sample(generated_at, cpu_usage, ram_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], }) 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') 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'), '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], '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, '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]: 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 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}.', }