Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:35:57 +02:00
commit 1dfbca08a1
528 files changed
+131054

No files matched your search

+817
View File
@@ -0,0 +1,817 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
from urllib.parse import urljoin, urlparse
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _AuthentikConfig:
url: str
token: str
verify_tls: bool
timeout_seconds: float
outpost_url: str | None
expect_embedded_outpost: bool
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_int(value: Any, default: int = 0) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return default
def _token_value(value: Any) -> str:
token = str(value or '').strip()
if token.lower().startswith('bearer '):
return token[7:].strip()
return token
def _as_list(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if value in {None, ''}:
return []
return [value]
def _safe_url(value: Any = None) -> str:
raw = str(value or settings.apis.get('AUTHENTIK_URL') or '').strip().rstrip('/')
if not raw:
return ''
parsed = urlparse(raw)
if not parsed.username and not parsed.password:
return raw
host = parsed.hostname or ''
port = f':{parsed.port}' if parsed.port else ''
return f'{parsed.scheme}://{host}{port}{parsed.path}'.rstrip('/')
def _domain(value: Any) -> str:
text = str(value or '').strip()
if not text:
return ''
parsed = urlparse(text if '://' in text else f'https://{text}')
return (parsed.hostname or text).lower().strip()
def _response_items(payload: Any) -> list[dict[str, Any]]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
data = payload.get('data')
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(payload.get('results'), list):
return [item for item in payload['results'] if isinstance(item, dict)]
if isinstance(payload.get('items'), list):
return [item for item in payload['items'] if isinstance(item, dict)]
return []
def _pagination_next(payload: Any) -> str | None:
if not isinstance(payload, dict):
return None
pagination = payload.get('pagination')
if isinstance(pagination, dict) and pagination.get('next'):
return str(pagination['next'])
if payload.get('next'):
return str(payload['next'])
return None
class AuthentikConnector(BaseConnector):
name = 'Authentik'
def __init__(self):
super().__init__()
self._read_cache: dict[str, dict[str, Any]] = {}
def _memo(self, key: str, factory: Any) -> dict[str, Any]:
if key not in self._read_cache:
self._read_cache[key] = factory()
return self._read_cache[key]
def is_configured(self) -> bool:
return bool(settings.apis.get('AUTHENTIK_URL') and settings.apis.get('AUTHENTIK_TOKEN'))
def _config(self) -> _AuthentikConfig | None:
if not self.is_configured():
return None
return _AuthentikConfig(
url=str(settings.apis['AUTHENTIK_URL']).rstrip('/'),
token=_token_value(settings.apis['AUTHENTIK_TOKEN']),
verify_tls=_as_bool(settings.apis.get('AUTHENTIK_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('AUTHENTIK_TIMEOUT_SECONDS'), 10.0),
outpost_url=str(settings.apis.get('AUTHENTIK_OUTPOST_URL') or '').strip().rstrip('/') or None,
expect_embedded_outpost=_as_bool(settings.apis.get('AUTHENTIK_EXPECT_EMBEDDED_OUTPOST'), False),
)
def _request(self, path: str, params: dict[str, Any] | None = None) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
url = path if path.startswith(('http://', 'https://')) else f'{config.url}/api/v3{path}'
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.get(
url,
params=params,
headers={'Accept': 'application/json', 'Authorization': f'Bearer {config.token}'},
)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Authentik API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def _write_request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
"""Use the existing Authentik API identity for one typed mutation.
Callers in this repository pass only a DeploymentPlan-derived payload;
this method is private so routes cannot turn it into a generic Authentik
proxy.
"""
config = self._config()
if not config:
raise RuntimeError('missing_config')
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
request_kwargs: dict[str, Any] = {
'headers': {'Accept': 'application/json', 'Authorization': f'Bearer {config.token}'},
}
if payload is not None:
request_kwargs['json'] = payload
response = client.request(method, f'{config.url}/api/v3{path}', **request_kwargs)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Authentik API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
if not response.content:
return {}
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def _paged(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
config = self._config()
if not config:
raise RuntimeError('missing_config')
items: list[dict[str, Any]] = []
base_path = path
base_params = dict(params or {})
next_path: str | None = path
next_params = params
for _ in range(20):
if not next_path:
break
payload = self._request(next_path, next_params)
items.extend(_response_items(payload))
next_value = _pagination_next(payload)
if not next_value:
break
if next_value.isdigit():
next_path = base_path
next_params = {**base_params, 'page': int(next_value)}
continue
if next_value.startswith(('http://', 'https://')):
parsed_base = urlparse(config.url)
parsed_next = urlparse(next_value)
if parsed_next.netloc and parsed_next.netloc != parsed_base.netloc:
break
next_path = next_value
else:
next_path = urljoin('/api/v3/', next_value).replace('/api/v3', '', 1)
next_params = None
return items
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
root = self._request('/root/config/')
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
return {'status': 'auth_failed', 'reason': reason}
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': reason}
return {'status': 'connected', 'version': self._version(root)}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(applicationCount=0, outpostAvailable=False)
try:
result = self.test_connection()
if result.get('status') != 'connected':
return self._error_payload(RuntimeError(str(result.get('reason', result.get('status')))), applicationCount=0, outpostAvailable=False)
apps = self.applications()
outpost = self.outpost_health()
except RuntimeError as exc:
return self._error_payload(exc, applicationCount=0, outpostAvailable=False)
return {
'success': True,
'status': 'connected',
'message': 'Authentik connector ready.',
'url': _safe_url(),
'version': str(result.get('version') or 'unknown'),
'applicationCount': len(apps.get('items', [])),
'outpostAvailable': bool(outpost.get('available')),
'data': {},
}
def system_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing()
try:
root = self._request('/root/config/')
except RuntimeError as exc:
return self._error_payload(exc)
data = {
'version': self._version(root),
'brand': root.get('brand') if isinstance(root, dict) else None,
'url': _safe_url(),
}
return {'success': True, 'status': 'ok', 'message': 'system_status_ok', **data, 'data': data}
def applications(self) -> dict[str, Any]:
return self._memo('applications', lambda: self._collection('/core/applications/', self._map_applications, 'applications_ok'))
def providers(self) -> dict[str, Any]:
return self._memo('providers', lambda: self._collection('/core/providers/', self._map_providers, 'providers_ok'))
def proxy_providers(self) -> dict[str, Any]:
return self._memo('proxy_providers', lambda: self._collection('/providers/proxy/', self._map_proxy_providers, 'proxy_providers_ok'))
def oauth2_providers(self) -> dict[str, Any]:
return self._memo('oauth2_providers', lambda: self._collection('/providers/oauth2/', self._map_oauth2_providers, 'oauth2_providers_ok'))
def groups(self) -> dict[str, Any]:
"""Read role-group identities for enrollment takeover-safe preflight."""
return self._memo(
'groups',
lambda: self._collection(
'/core/groups/',
lambda items: [
{'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or '')}
for item in items
],
'groups_ok',
),
)
@staticmethod
def deployment_oidc_template_status(*, role_claim: str = 'roles') -> dict[str, Any]:
"""Report only whether the server-owned OIDC template is configured."""
required = (
'AUTHENTIK_OIDC_AUTHORIZATION_FLOW',
'AUTHENTIK_OIDC_INVALIDATION_FLOW',
'AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING',
)
missing = [name for name in required if not str(settings.apis.get(name) or '').strip()]
configured_claim = str(settings.apis.get('AUTHENTIK_OIDC_ROLE_CLAIM') or '').strip()
if configured_claim != role_claim:
missing.append('AUTHENTIK_OIDC_ROLE_CLAIM')
return {
'status': 'ready' if not missing else 'missing_config',
'missing': missing,
}
def ensure_deployment_oidc_application(
self,
*,
application_slug: str,
launch_url: str,
redirect_uri: str,
client_secret: str,
administrator_group: str,
support_agent_group: str,
read_only_group: str,
) -> dict[str, Any]:
"""Provision one Authentik OIDC app from the reviewed deployment template.
The method refuses tenant-wide group/flow creation and never returns the
supplied/generated client secret. Existing applications are accepted
only when their slug and launch URL already identify this deployment.
"""
if not self.is_configured():
return self._missing(reason='missing_config')
if not re.fullmatch(r'[a-z][a-z0-9-]{1,62}', application_slug):
return {'success': False, 'status': 'invalid', 'message': 'invalid_application_slug'}
if not re.fullmatch(r'https://[a-z0-9.-]+(?:/[^?#]*)?', launch_url):
return {'success': False, 'status': 'invalid', 'message': 'invalid_launch_url'}
if not redirect_uri.startswith(f'{launch_url}/') or '?' in redirect_uri or '#' in redirect_uri:
return {'success': False, 'status': 'invalid', 'message': 'invalid_redirect_uri'}
authorization_flow = str(settings.apis.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip()
invalidation_flow = str(settings.apis.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip()
group_claim_mapping = str(settings.apis.get('AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING') or '').strip()
if not authorization_flow or not invalidation_flow or not group_claim_mapping:
return {'success': False, 'status': 'blocked', 'message': 'oidc_template_not_configured'}
try:
groups = self._paged('/core/groups/')
group_names = {str(item.get('name') or '') for item in groups}
required_groups = {administrator_group, support_agent_group, read_only_group}
if not required_groups.issubset(group_names):
return {'success': False, 'status': 'blocked', 'message': 'required_role_groups_are_missing'}
applications = self._paged('/core/applications/')
matches = [item for item in applications if str(item.get('slug') or '') == application_slug]
if len(matches) > 1:
return {'success': False, 'status': 'conflict', 'message': 'multiple_authentik_applications_with_slug'}
if matches:
existing = matches[0]
existing_url = str(existing.get('meta_launch_url') or existing.get('launch_url') or '')
if existing_url.rstrip('/') != launch_url.rstrip('/'):
return {'success': False, 'status': 'conflict', 'message': 'application_slug_owned_by_different_launch_url'}
provider_id = existing.get('provider') or existing.get('provider_id')
providers = self._paged('/providers/oauth2/')
provider = next(
(item for item in providers if str(item.get('pk') or item.get('id')) == str(provider_id)),
None,
)
provider_redirects = provider.get('redirect_uris') if isinstance(provider, dict) else None
if isinstance(provider_redirects, str):
provider_redirects = [item for item in provider_redirects.splitlines() if item.strip()]
provider_mappings = provider.get('property_mappings') if isinstance(provider, dict) else None
if (
not isinstance(provider, dict)
or str(provider.get('client_id') or '') != application_slug
or str(provider.get('client_type') or '') != 'confidential'
or provider_redirects != [redirect_uri]
or group_claim_mapping not in (provider_mappings or [])
or str(provider.get('authorization_flow') or '') != authorization_flow
or str(provider.get('invalidation_flow') or '') != invalidation_flow
):
return {'success': False, 'status': 'conflict', 'message': 'existing_oidc_provider_does_not_match_managed_contract'}
return {
'success': True,
'status': 'already_managed',
'message': 'oidc_application_already_matches_typed_plan',
'applicationId': existing.get('pk') or existing.get('id'),
'clientSecretCreated': False,
}
provider = self._write_request(
'POST',
'/providers/oauth2/',
{
'name': f'{application_slug} OIDC',
'authorization_flow': authorization_flow,
'invalidation_flow': invalidation_flow,
'client_type': 'confidential',
'client_id': application_slug,
'client_secret': client_secret,
'redirect_uris': [redirect_uri],
'property_mappings': [group_claim_mapping],
'include_claims_in_id_token': True,
'sub_mode': 'hashed_user_id',
'issuer_mode': 'per_provider',
},
)
provider_id = provider.get('pk') or provider.get('id') if isinstance(provider, dict) else None
if not provider_id:
return {'success': False, 'status': 'failed', 'message': 'oidc_provider_response_missing_identifier'}
try:
application = self._write_request(
'POST',
'/core/applications/',
{
'name': application_slug,
'slug': application_slug,
'provider': provider_id,
'meta_launch_url': launch_url,
},
)
except RuntimeError:
# A generated client secret must never appear in an API error,
# audit record or browser response. Compensate only the provider
# this method just created, and fail closed if that cleanup is
# not acknowledged by Authentik.
try:
self._write_request('DELETE', f'/providers/oauth2/{provider_id}/')
except RuntimeError:
return {'success': False, 'status': 'partial_failure', 'message': 'oidc_application_failed_provider_cleanup_failed'}
return {'success': False, 'status': 'failed', 'message': 'oidc_application_creation_failed_provider_removed'}
except RuntimeError:
# The provider request carries the confidential client secret. A
# remote error body is not a safe channel for returning it.
return {'success': False, 'status': 'failed', 'message': 'oidc_provider_creation_failed'}
application_id = application.get('pk') or application.get('id') if isinstance(application, dict) else None
if not application_id:
try:
self._write_request('DELETE', f'/providers/oauth2/{provider_id}/')
except RuntimeError:
return {'success': False, 'status': 'partial_failure', 'message': 'oidc_application_response_missing_identifier_provider_cleanup_failed'}
return {'success': False, 'status': 'failed', 'message': 'oidc_application_response_missing_identifier_provider_removed'}
return {
'success': True,
'status': 'created',
'message': 'oidc_application_created_from_typed_template',
'applicationId': application_id,
'providerId': provider_id,
'clientSecretCreated': True,
}
def remove_deployment_oidc_application(
self,
*,
application_id: str | int,
provider_id: str | int,
application_slug: str,
launch_url: str,
) -> dict[str, Any]:
"""Compensate only an OIDC pair created during this enrollment call.
This is intentionally private to the enrollment orchestrator. It first
re-reads both resources and refuses to delete if the identity is no
longer exactly the bounded application/provider pair it created.
"""
if not self.is_configured():
return self._missing(reason='missing_config')
if not re.fullmatch(r'[a-z][a-z0-9-]{1,62}', application_slug):
return {'success': False, 'status': 'invalid', 'message': 'invalid_application_slug'}
if not re.fullmatch(r'https://[a-z0-9.-]+(?:/[^?#]*)?', launch_url):
return {'success': False, 'status': 'invalid', 'message': 'invalid_launch_url'}
try:
applications = self._paged('/core/applications/')
matches = [
item for item in applications
if str(item.get('pk') or item.get('id')) == str(application_id)
]
if len(matches) != 1:
return {'success': False, 'status': 'not_found', 'message': 'managed_oidc_application_not_found'}
application = matches[0]
if (
application.get('slug') != application_slug
or str(application.get('meta_launch_url') or application.get('launch_url') or '').rstrip('/') != launch_url.rstrip('/')
or str(application.get('provider') or application.get('provider_id') or '') != str(provider_id)
):
return {'success': False, 'status': 'conflict', 'message': 'oidc_application_is_not_the_created_managed_pair'}
provider = self._write_request('GET', f'/providers/oauth2/{provider_id}/')
if not isinstance(provider, dict) or str(provider.get('client_id') or '') != application_slug:
return {'success': False, 'status': 'conflict', 'message': 'oidc_provider_is_not_the_created_managed_pair'}
self._write_request('DELETE', f'/core/applications/{application_id}/')
self._write_request('DELETE', f'/providers/oauth2/{provider_id}/')
except RuntimeError as exc:
return self._error_payload(exc)
return {
'success': True,
'status': 'removed',
'applicationId': application_id,
'providerId': provider_id,
}
def outposts(self) -> dict[str, Any]:
return self._memo('outposts', lambda: self._collection('/outposts/instances/', self._map_outposts, 'outposts_ok'))
def outpost_health(self) -> dict[str, Any]:
return self._memo('outpost_health', self._outpost_health_uncached)
def _outpost_health_uncached(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0, available=False, embeddedExpected=False)
outposts = self.outposts()
if not outposts.get('success'):
return {**outposts, 'available': False}
items = outposts.get('items', [])
available = any(str(item.get('health') or '').lower() in {'healthy', 'ok', 'connected'} for item in items)
config = self._config()
embedded_expected = bool(config and config.expect_embedded_outpost)
reachability = {'status': 'reported_healthy', 'reachable': True, 'statusCode': None} if available else self._probe_outpost(config) if config and config.outpost_url else {
'status': 'not_configured',
'reachable': None,
'statusCode': None,
}
reachable = reachability.get('reachable')
status = 'ok' if available else ('reachable_unverified' if reachable else ('warning' if embedded_expected else 'unknown'))
if available:
message = 'outpost_health_ok'
elif reachable:
message = 'outpost_reachable_health_unverified'
else:
message = 'outpost_health_unknown'
data = {
'items': items,
'available': available,
'embeddedExpected': embedded_expected,
'outpostUrl': _safe_url(config.outpost_url) if config and config.outpost_url else None,
'reachable': reachable,
'reachabilityStatus': reachability.get('status'),
'reachabilityStatusCode': reachability.get('statusCode'),
'telemetryStatus': 'reported' if available else 'unverified',
}
return {'success': True, 'status': status, 'message': message, 'items': items, 'total': len(items), **data, 'data': data}
@staticmethod
def _probe_outpost(config: _AuthentikConfig) -> dict[str, Any]:
if not config.outpost_url:
return {'status': 'not_configured', 'reachable': None, 'statusCode': None}
try:
# Never forward the Authentik API bearer token to a separately
# configured outpost origin. This probe verifies reachability only.
with httpx.Client(
timeout=min(config.timeout_seconds, 8.0),
verify=config.verify_tls,
follow_redirects=False,
) as client:
response = client.get(
config.outpost_url,
headers={'Accept': 'text/html,application/json;q=0.9,*/*;q=0.8'},
)
except httpx.RequestError:
return {'status': 'unreachable', 'reachable': False, 'statusCode': None}
reachable = response.status_code < 500
return {
'status': 'reachable' if reachable else 'degraded',
'reachable': reachable,
'statusCode': response.status_code,
}
def flows_summary(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0, byDesignation={})
try:
items = self._paged('/flows/instances/')
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0, byDesignation={})
mapped = [{'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or item.get('slug') or ''), 'designation': str(item.get('designation') or 'unknown')} for item in items]
by_designation: dict[str, int] = {}
for item in mapped:
by_designation[item['designation']] = by_designation.get(item['designation'], 0) + 1
return {'success': True, 'status': 'ok', 'message': 'flows_summary_ok', 'total': len(mapped), 'items': mapped, 'byDesignation': by_designation, 'data': {'items': mapped, 'byDesignation': by_designation}}
def policies_summary(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0, enabled=0, disabled=0)
try:
items = self._paged('/policies/all/')
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0, enabled=0, disabled=0)
mapped = [{'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'enabled': bool(item.get('enabled', True))} for item in items]
enabled = sum(1 for item in mapped if item['enabled'])
data = {'items': mapped, 'enabled': enabled, 'disabled': len(mapped) - enabled}
return {'success': True, 'status': 'ok', 'message': 'policies_summary_ok', 'total': len(mapped), **data, 'data': data}
def events(self, limit: int = 25) -> dict[str, Any]:
cache_key = f'events:{max(1, min(limit, 100))}'
return self._memo(cache_key, lambda: self._events_uncached(limit))
def _events_uncached(self, limit: int = 25) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0)
try:
raw = self._paged('/events/events/', {'page_size': max(1, min(limit, 100))})
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0)
items = [self._map_event(item) for item in raw[:limit]]
return {'success': True, 'status': 'ok', 'message': 'events_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def auth_coverage(self) -> dict[str, Any]:
return self._memo('auth_coverage', self._auth_coverage_uncached)
def _auth_coverage_uncached(self) -> dict[str, Any]:
apps = self.applications()
providers = self.providers()
proxy = self.proxy_providers()
if not apps.get('success'):
return {**apps, 'summary': self._empty_coverage_summary(), 'publicHosts': [], 'data': {'publicHosts': [], 'summary': self._empty_coverage_summary()}}
npm_hosts = self._npm_hosts()
hosts = npm_hosts.get('items', []) if npm_hosts.get('success') else []
proxy_domains = {_domain(item.get('externalHost')): item for item in proxy.get('items', []) if _domain(item.get('externalHost'))} if proxy.get('success') else {}
app_domains = {_domain(item.get('launchUrl')): item for item in apps.get('items', []) if _domain(item.get('launchUrl'))}
public_hosts: list[dict[str, Any]] = []
for host in hosts:
if not host.get('enabled', True):
continue
domains = [str(item) for item in _as_list(host.get('domainNames'))]
matches = [domain for domain in domains if _domain(domain) in proxy_domains or _domain(domain) in app_domains]
status = 'protected' if matches else ('likely_protected' if host.get('accessListId') else 'unprotected')
public_hosts.append({
'hostId': host.get('id'),
'domains': domains,
'target': f"{host.get('forwardScheme', 'http')}://{host.get('forwardHost', '')}:{host.get('forwardPort', '')}",
'coverageStatus': status,
'authentikMatches': matches,
'accessListId': host.get('accessListId'),
})
summary = {
'totalApplications': len(apps.get('items', [])),
'totalProviders': len(providers.get('items', [])) if providers.get('success') else 0,
'totalProxyProviders': len(proxy.get('items', [])) if proxy.get('success') else 0,
'totalPublicHosts': len(public_hosts),
'protectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'protected'),
'likelyProtectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'likely_protected'),
'unprotectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'unprotected'),
'npmStatus': npm_hosts.get('status', 'unknown'),
}
data = {'summary': summary, 'publicHosts': public_hosts}
return {'success': True, 'status': 'ok', 'message': 'auth_coverage_ok', 'summary': summary, 'publicHosts': public_hosts, 'data': data}
def security_warnings(self) -> dict[str, Any]:
return self._memo('security_warnings', self._security_warnings_uncached)
def _security_warnings_uncached(self) -> dict[str, Any]:
warnings: list[dict[str, Any]] = []
if not self.is_configured():
npm_hosts = self._npm_hosts()
if npm_hosts.get('success') and npm_hosts.get('items'):
warnings.append({'type': 'authentik_missing_config_with_public_hosts', 'severity': 'high', 'title': 'Authentik connector is missing while NPM has public hosts', 'evidence': [str(len(npm_hosts.get('items', [])))], 'suggestedAction': 'configure_authentik_connector'})
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'items': warnings, 'total': len(warnings), 'data': {'items': warnings}}
coverage = self.auth_coverage()
outpost = self.outpost_health()
apps = self.applications()
proxy = self.proxy_providers()
oauth = self.oauth2_providers()
events = self.events()
if not outpost.get('items'):
warnings.append({'type': 'no_outposts', 'severity': 'high', 'title': 'No Authentik outposts reported', 'evidence': [], 'suggestedAction': 'review_outpost_health'})
if outpost.get('embeddedExpected') and not outpost.get('available'):
outpost_status = str(outpost.get('status') or 'unknown')
if outpost.get('reachable'):
warnings.append({
'type': 'embedded_outpost_health_unverified',
'severity': 'info',
'title': 'Embedded outpost is reachable but health telemetry is not reported',
'evidence': [outpost_status, str(outpost.get('reachabilityStatusCode') or '')],
'suggestedAction': 'review_embedded_outpost_telemetry',
})
else:
warnings.append({'type': 'embedded_outpost_unavailable', 'severity': 'warning', 'title': 'Expected embedded outpost is unavailable or unknown', 'evidence': [outpost_status], 'suggestedAction': 'review_embedded_outpost'})
for host in coverage.get('publicHosts', []):
if host.get('coverageStatus') == 'unprotected':
warnings.append({'type': 'public_host_without_authentik', 'severity': 'high', 'title': f"{', '.join(host.get('domains') or [])} has no Authentik mapping", 'evidence': host.get('domains') or [], 'suggestedAction': 'map_host_to_authentik'})
app_slugs = {str(item.get('slug')) for item in apps.get('items', []) if item.get('slug')}
provider_app_slugs = {str(item.get('assignedApplicationSlug')) for item in proxy.get('items', []) if item.get('assignedApplicationSlug')}
for provider in proxy.get('items', []):
if not provider.get('assignedApplicationSlug'):
warnings.append({'type': 'proxy_provider_without_app', 'severity': 'warning', 'title': f"{provider.get('name')} is not assigned to an application", 'evidence': [str(provider.get('externalHost') or provider.get('id'))], 'suggestedAction': 'assign_proxy_provider'})
for app in apps.get('items', []):
slug = str(app.get('slug') or '')
if slug and slug not in provider_app_slugs and not app.get('providerId'):
warnings.append({'type': 'application_without_provider', 'severity': 'warning', 'title': f"{app.get('name')} has no provider mapping", 'evidence': [slug], 'suggestedAction': 'review_application_provider'})
for provider in oauth.get('items', []):
if _as_int(provider.get('redirectUriCount')) >= 4:
warnings.append({'type': 'oauth_many_redirect_uris', 'severity': 'warning', 'title': f"{provider.get('name')} has many redirect URIs", 'evidence': [str(provider.get('redirectUriCount'))], 'suggestedAction': 'review_oauth_redirect_uris'})
if not events.get('items'):
warnings.append({'type': 'no_recent_events', 'severity': 'info', 'title': 'No recent Authentik events returned', 'evidence': [], 'suggestedAction': 'review_event_retention_or_permissions'})
warnings = [item for item in warnings if item.get('evidence') is not app_slugs]
severity_order = {'critical': 3, 'high': 2, 'warning': 1, 'info': 0}
items = sorted(warnings, key=lambda item: severity_order.get(str(item.get('severity')), 0), reverse=True)
status = 'warning' if items else 'ok'
return {'success': True, 'status': status, 'message': 'security_warnings_detected' if items else 'security_warnings_ok', 'items': items, 'total': len(items), 'data': {'items': items}}
def _collection(self, path: str, mapper: Any, message: str) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0)
try:
items = mapper(self._paged(path))
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0)
return {'success': True, 'status': 'ok', 'message': message, 'total': len(items), 'items': items, 'data': {'items': items}}
@staticmethod
def _version(payload: Any) -> str:
if not isinstance(payload, dict):
return 'unknown'
return str(payload.get('version') or payload.get('build') or payload.get('authentik_version') or 'unknown')
@staticmethod
def _map_applications(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{
'id': item.get('pk') or item.get('id'),
'name': str(item.get('name') or ''),
'slug': str(item.get('slug') or ''),
'launchUrl': str(item.get('launch_url') or item.get('launchUrl') or item.get('meta_launch_url') or ''),
'providerId': item.get('provider') or item.get('providerId'),
} for item in raw]
@staticmethod
def _map_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for item in raw:
type_text = str(item.get('type') or item.get('component') or '').lower()
provider_type = 'proxy' if 'proxy' in type_text else ('oauth2' if 'oauth' in type_text or 'oidc' in type_text else 'unknown')
result.append({'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'providerType': provider_type, 'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug')})
return result
@staticmethod
def _map_proxy_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{
'id': item.get('pk') or item.get('id'),
'name': str(item.get('name') or ''),
'externalHost': str(item.get('external_host') or item.get('externalHost') or ''),
'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug'),
'mode': str(item.get('mode') or ''),
} for item in raw]
@staticmethod
def _map_oauth2_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
mapped = []
for item in raw:
redirect_uris = item.get('redirect_uris') or item.get('redirectUris') or []
if isinstance(redirect_uris, str):
uris = [line for line in redirect_uris.splitlines() if line.strip()]
else:
uris = [str(uri) for uri in _as_list(redirect_uris)]
mapped.append({'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug'), 'redirectUriCount': len(uris), 'redirectUris': uris[:10]})
return mapped
@staticmethod
def _map_outposts(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{
'id': item.get('pk') or item.get('id'),
'name': str(item.get('name') or ''),
'type': str(item.get('type') or item.get('outpost_type') or 'unknown'),
'health': str(item.get('health') or item.get('health_status') or item.get('status') or 'unknown'),
'providerCount': len(_as_list(item.get('providers'))),
} for item in raw]
@staticmethod
def _map_event(raw: dict[str, Any]) -> dict[str, Any]:
user = raw.get('user') if isinstance(raw.get('user'), dict) else {}
return {
'id': raw.get('pk') or raw.get('id'),
'action': str(raw.get('action') or raw.get('event') or ''),
'created': raw.get('created') or raw.get('timestamp'),
'username': str(user.get('username') or raw.get('username') or ''),
'clientIp': AuthentikConnector._mask_ip(raw.get('client_ip') or raw.get('clientIp')),
}
@staticmethod
def _mask_ip(value: Any) -> str:
text = str(value or '')
parts = text.split('.')
if len(parts) == 4:
return '.'.join(parts[:3] + ['x'])
return 'masked' if text else ''
@staticmethod
def _empty_coverage_summary() -> dict[str, Any]:
return {'totalApplications': 0, 'totalProviders': 0, 'totalProxyProviders': 0, 'totalPublicHosts': 0, 'protectedHosts': 0, 'likelyProtectedHosts': 0, 'unprotectedHosts': 0, 'npmStatus': 'unknown'}
@staticmethod
def _missing(**extra: Any) -> dict[str, Any]:
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': _safe_url(), 'required_env_vars': ['AUTHENTIK_URL', 'AUTHENTIK_TOKEN'], 'data': {}, **extra}
@staticmethod
def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]:
reason = redact_message(str(exc))
status = 'auth_failed' if reason.startswith('auth_failed') else ('missing_config' if reason == 'missing_config' else 'unreachable')
return {'success': False, 'status': status, 'message': reason, 'data': {}, **extra}
@staticmethod
def _npm_hosts() -> dict[str, Any]:
try:
from .nginx_proxy_manager import NginxProxyManagerConnector
return NginxProxyManagerConnector().proxy_hosts()
except Exception as exc:
return {'success': False, 'status': 'unavailable', 'message': redact_message(str(exc)), 'items': []}
+14
View File
@@ -0,0 +1,14 @@
class BaseConnector:
name: str = 'base'
def __init__(self):
self.last_error: str | None = None
def is_configured(self):
return False
def set_error(self, message: str):
self.last_error = message
def clear_error(self):
self.last_error = None
+370
View File
@@ -0,0 +1,370 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
_TORRENT_FIELDS = [
'name',
'state',
'progress',
'download_payload_rate',
'upload_payload_rate',
'eta',
'ratio',
'total_size',
'tracker_host',
'label',
'save_path',
'error',
'is_finished',
]
@dataclass
class _DelugeConfig:
url: str
rpc_url: str
password: str
verify_tls: bool
timeout_seconds: float
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_number(value: Any, default: float = 0) -> float:
if value is None or value == '':
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def _safe_path(value: Any) -> str:
text = str(value or '').replace('\\', '/').rstrip('/')
if not text:
return ''
parts = [part for part in text.split('/') if part]
if not parts:
return ''
return f'.../{parts[-1]}'
def _safe_url() -> str:
value = settings.apis.get('DELUGE_URL')
return str(value).rstrip('/') if value else ''
class DelugeConnector(BaseConnector):
name = 'Deluge'
def __init__(self) -> None:
self._client: httpx.Client | None = None
self._rpc_id = 0
def is_configured(self) -> bool:
return bool(settings.apis.get('DELUGE_URL') and settings.apis.get('DELUGE_PASSWORD'))
def _config(self) -> _DelugeConfig | None:
if not self.is_configured():
return None
base_url = str(settings.apis['DELUGE_URL']).rstrip('/')
web_path = str(settings.apis.get('DELUGE_WEB_PATH') or '/json').strip() or '/json'
if not web_path.startswith('/'):
web_path = f'/{web_path}'
rpc_url = base_url if base_url.endswith('/json') else f'{base_url}{web_path}'
return _DelugeConfig(
url=base_url,
rpc_url=rpc_url,
password=str(settings.apis['DELUGE_PASSWORD']),
verify_tls=_as_bool(settings.apis.get('DELUGE_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('DELUGE_TIMEOUT_SECONDS'), 10.0),
)
def _http(self) -> httpx.Client:
config = self._config()
if not config:
raise RuntimeError('missing_config')
if self._client is None:
self._client = httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls)
return self._client
def _rpc(self, method: str, params: list[Any] | None = None, authed: bool = True) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
if authed:
self._login()
self._rpc_id += 1
body = {'method': method, 'params': params or [], 'id': self._rpc_id}
try:
response = self._http().post(config.rpc_url, json=body)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Deluge Web JSON-RPC error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
payload = response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if isinstance(payload, dict) and payload.get('error'):
error = payload['error']
message = redact_message(str(error.get('message') if isinstance(error, dict) else error))
if 'password' in message.lower() or 'auth' in message.lower():
raise RuntimeError(f'auth_failed {message}')
raise RuntimeError(message)
return payload.get('result') if isinstance(payload, dict) else payload
def _login(self) -> bool:
config = self._config()
if not config:
raise RuntimeError('missing_config')
result = self._rpc('auth.login', [config.password], authed=False)
if result is not True:
raise RuntimeError('auth_failed password=[REDACTED]')
return True
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
optional_warnings: list[dict[str, str]] = []
def optional_rpc(method: str, params: list[Any] | None = None) -> Any:
try:
return self._rpc(method, params)
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
raise
optional_warnings.append({'method': method, 'reason': reason})
return None
try:
self._login()
web_connected = optional_rpc('web.connected')
daemon_connected = optional_rpc('daemon.connected')
version = optional_rpc('daemon.get_version')
session_payload = optional_rpc('core.get_session_status', [['num_torrents']])
if web_connected is None and daemon_connected is None and version is None and session_payload is None:
reason = optional_warnings[0]['reason'] if optional_warnings else 'connection_failed'
return {'status': 'unreachable', 'reason': reason, 'diagnostics': {'optionalMethodWarnings': optional_warnings}}
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
return {'status': 'auth_failed', 'reason': reason}
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': reason}
fallback = 'core.get_session_status' if session_payload is not None and optional_warnings else None
return {
'status': 'connected',
'webConnected': bool(web_connected) if web_connected is not None else None,
'daemonConnected': bool(daemon_connected) if daemon_connected is not None else None,
'version': str(version or 'unknown'),
'diagnostics': {
'healthFallback': fallback,
'optionalMethodWarnings': optional_warnings,
},
}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'url': '',
'webConnected': None,
'daemonConnected': None,
'version': 'unknown',
'required_env_vars': ['DELUGE_URL', 'DELUGE_PASSWORD'],
}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Deluge connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'url': _safe_url(),
'webConnected': result.get('webConnected'),
'daemonConnected': result.get('daemonConnected'),
'version': str(result.get('version', 'unknown')),
'diagnostics': result.get('diagnostics', {'healthFallback': None, 'optionalMethodWarnings': []}),
}
def session_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_session('missing_config')
try:
connected = bool(self._rpc('web.connected'))
session_payload = self._rpc('core.get_session_status', [['download_rate', 'upload_rate', 'num_torrents']])
torrents = self.torrent_status()
except RuntimeError as exc:
return self._empty_session('auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', redact_message(str(exc)))
items = torrents.get('items', []) if isinstance(torrents, dict) else []
return {
'success': True,
'status': 'ok',
'connected': connected,
'downloadRate': _as_number((session_payload or {}).get('download_rate')),
'uploadRate': _as_number((session_payload or {}).get('upload_rate')),
'numTorrents': int(_as_number((session_payload or {}).get('num_torrents'), len(items))),
'paused': sum(1 for item in items if item.get('paused')),
'active': sum(1 for item in items if item.get('active')),
'stalled': sum(1 for item in items if item.get('stalled')),
'errors': sum(1 for item in items if str(item.get('state', '')).lower() == 'error'),
}
def torrent_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_torrents('missing_config')
try:
payload = self._rpc('web.update_ui', [_TORRENT_FIELDS, {}])
except RuntimeError as exc:
return self._empty_torrents('auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', redact_message(str(exc)))
records = (payload or {}).get('torrents') if isinstance(payload, dict) else {}
items = [self._map_torrent(torrent_id, raw) for torrent_id, raw in (records or {}).items() if isinstance(raw, dict)]
return {'success': True, 'status': 'ok', 'message': 'torrents_ok', 'total': len(items), 'items': items}
def active_downloads(self) -> dict[str, Any]:
return self._filtered('active', lambda item: bool(item.get('active')))
def stalled_downloads(self) -> dict[str, Any]:
return self._filtered('stalled', lambda item: bool(item.get('stalled')))
def paused_downloads(self) -> dict[str, Any]:
return self._filtered('paused', lambda item: bool(item.get('paused')))
def speed_status(self) -> dict[str, Any]:
session = self.session_status()
return {
'success': bool(session.get('success')),
'status': session.get('status', 'unreachable'),
'connected': bool(session.get('connected')),
'downloadRate': session.get('downloadRate', 0),
'uploadRate': session.get('uploadRate', 0),
'active': session.get('active', 0),
'paused': session.get('paused', 0),
'stalled': session.get('stalled', 0),
'errors': session.get('errors', 0),
}
def pause_torrent(self, torrent_id: str) -> dict[str, Any]:
return self._torrent_mutation('core.pause_torrent', torrent_id, 'torrent_paused')
def resume_torrent(self, torrent_id: str) -> dict[str, Any]:
return self._torrent_mutation('core.resume_torrent', torrent_id, 'torrent_resumed')
def pause_all(self) -> dict[str, Any]:
return self._bulk_mutation('core.pause_all_torrents', 'all_torrents_paused')
def resume_all(self) -> dict[str, Any]:
return self._bulk_mutation('core.resume_all_torrents', 'all_torrents_resumed')
def _torrent_mutation(self, method: str, torrent_id: str, message: str) -> dict[str, Any]:
clean_id = torrent_id.strip()
if not clean_id:
return {'success': False, 'status': 'error', 'message': 'torrent_id_required'}
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'data': {'torrentId': clean_id}}
try:
self._rpc(method, [[clean_id]])
except RuntimeError as exc:
status = 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable'
return {'success': False, 'status': status, 'message': redact_message(str(exc)), 'data': {'torrentId': clean_id}}
return {'success': True, 'status': 'ok', 'message': message, 'data': {'torrentId': clean_id}}
def _bulk_mutation(self, method: str, message: str) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
try:
self._rpc(method)
except RuntimeError as exc:
status = 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable'
return {'success': False, 'status': status, 'message': redact_message(str(exc))}
return {'success': True, 'status': 'ok', 'message': message, 'data': {'accepted': True}}
def _filtered(self, name: str, predicate: Any) -> dict[str, Any]:
payload = self.torrent_status()
if not payload.get('success'):
return {'success': False, 'status': payload.get('status'), 'message': payload.get('message'), 'total': 0, 'items': []}
items = [item for item in payload.get('items', []) if predicate(item)]
return {'success': True, 'status': 'ok', 'message': f'{name}_downloads_ok', 'total': len(items), 'items': items}
@staticmethod
def _map_torrent(torrent_id: str, raw: dict[str, Any]) -> dict[str, Any]:
state = str(raw.get('state') or 'unknown')
state_lower = state.lower()
download_rate = _as_number(raw.get('download_payload_rate'))
error = str(raw.get('error') or '')
paused = state_lower == 'paused'
active = state_lower in {'downloading', 'allocating', 'checking', 'moving'} and download_rate > 0
stalled = state_lower in {'error', 'queued'} or (state_lower == 'downloading' and download_rate <= 0)
return {
'id': str(torrent_id),
'name': str(raw.get('name') or torrent_id),
'state': state,
'progress': _as_number(raw.get('progress')),
'downloadPayloadRate': download_rate,
'uploadPayloadRate': _as_number(raw.get('upload_payload_rate')),
'eta': _as_number(raw.get('eta')),
'ratio': _as_number(raw.get('ratio')),
'totalSize': _as_number(raw.get('total_size')),
'trackerHost': str(raw.get('tracker_host') or ''),
'category': str(raw.get('label') or raw.get('category') or ''),
'savePath': _safe_path(raw.get('save_path')),
'error': error,
'isFinished': bool(raw.get('is_finished')),
'paused': paused,
'stalled': stalled,
'active': active,
}
@staticmethod
def _empty_session(status: str, message: str | None = None) -> dict[str, Any]:
return {
'success': False,
'status': status,
'message': message or status,
'connected': False,
'downloadRate': 0,
'uploadRate': 0,
'numTorrents': 0,
'paused': 0,
'active': 0,
'stalled': 0,
'errors': 0,
}
@staticmethod
def _empty_torrents(status: str, message: str | None = None) -> dict[str, Any]:
return {'success': False, 'status': status, 'message': message or status, 'total': 0, 'items': []}
+496
View File
@@ -0,0 +1,496 @@
from __future__ import annotations
import copy
import json
from datetime import UTC, datetime
from threading import Lock
from time import monotonic
from typing import Any
from urllib.parse import urlsplit, urlunsplit
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
_CACHE_SECONDS = 15
_WARNING_AGE_SECONDS = 180
_CRITICAL_AGE_SECONDS = 600
_ACTIVE_WARNING_AGE_SECONDS = 1_800
_ACTIVE_CRITICAL_AGE_SECONDS = 7_200
_QUEUE_SAMPLE_LIMIT = 50
_CACHE_LOCK = Lock()
_CACHE: tuple[float, dict[str, Any]] | None = None
_DEFAULT_RUNNER_TARGETS = {
"linux-validation": "itworx-validation-runner-2",
"docker-validation": "itworx-docker-runner",
"browser-validation": "itworx-browser-runner",
"security-validation": "itworx-security-runner",
"mcp-validation": "itworx-security-runner",
"unraid-deploy": "itworx-security-runner",
}
_DEFAULT_RUNNER_TARGET_CANDIDATES = {
"linux-validation": ("itworx-validation-runner-2", "itworx-validation-runner-3"),
"docker-validation": (
"itworx-docker-runner",
"itworx-validation-runner-2",
"itworx-security-runner",
),
"browser-validation": ("itworx-browser-runner",),
"security-validation": ("itworx-security-runner",),
"mcp-validation": ("itworx-security-runner",),
"unraid-deploy": ("itworx-security-runner",),
}
class GiteaActionsConnector(BaseConnector):
"""Read a bounded Gitea Actions control-plane snapshot; never execute jobs."""
name = "Gitea Actions"
def is_configured(self) -> bool:
return bool(
settings.apis.get("GITEA_URL") and settings.apis.get("GITEA_ADMIN_TOKEN")
)
def _base_url(self) -> str:
raw = str(settings.apis.get("GITEA_URL") or "").strip().rstrip("/")
parsed = urlsplit(raw)
if (
parsed.scheme not in {"http", "https"}
or not parsed.netloc
or parsed.username
):
raise RuntimeError("invalid_gitea_url")
return urlunsplit(
(parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")
)
def _request(
self, path: str, params: dict[str, Any] | None = None
) -> dict[str, Any]:
if not self.is_configured():
raise RuntimeError("missing_config")
token = str(settings.apis.get("GITEA_ADMIN_TOKEN") or "")
timeout = max(
2.0,
min(20.0, float(settings.apis.get("GITEA_TIMEOUT_SECONDS") or 8)),
)
try:
with httpx.Client(
timeout=timeout,
verify=bool(settings.apis.get("GITEA_VERIFY_TLS", settings.verify_tls)),
) as client:
response = client.get(
f"{self._base_url()}{path}",
params=params,
headers={
"Authorization": f"token {token}",
"Accept": "application/json",
},
)
except (httpx.RequestError, ValueError) as exc:
raise RuntimeError(redact_message(str(exc))) from exc
if response.status_code in {401, 403}:
raise RuntimeError("gitea_admin_access_denied")
if response.status_code >= 400:
raise RuntimeError(f"gitea_actions_http_{response.status_code}")
try:
payload = response.json()
except ValueError as exc:
raise RuntimeError("gitea_actions_invalid_response") from exc
if not isinstance(payload, dict):
raise RuntimeError("gitea_actions_invalid_response") # noqa: TRY004
return payload
@staticmethod
def _timestamp(value: Any) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
@staticmethod
def _labels(value: Any) -> list[str]:
if not isinstance(value, list):
return []
labels = []
for item in value:
label = item.get("name") if isinstance(item, dict) else item
text = str(label or "").strip()
if text:
labels.append(text[:120])
return labels
@staticmethod
def _configured_restart_targets() -> dict[str, str]:
raw = str(settings.apis.get("GITEA_RUNNER_TARGETS_JSON") or "{}")
try:
payload = json.loads(raw)
except json.JSONDecodeError:
payload = {}
return (
{
str(label)[:120]: str(target)[:240]
for label, target in payload.items()
if str(label).strip() and str(target).strip()
}
if isinstance(payload, dict)
else {}
)
@classmethod
def _restart_targets(cls) -> dict[str, str]:
return {**_DEFAULT_RUNNER_TARGETS, **cls._configured_restart_targets()}
@classmethod
def _target_for_label(
cls,
label: str,
runners: list[dict[str, Any]],
targets: dict[str, str],
) -> str | None:
"""Keep explicit mappings authoritative; prove only built-in defaults."""
configured = cls._configured_restart_targets()
if label in configured:
return configured[label]
compatible = {
str(runner.get("name") or "")
for runner in runners
if label in runner.get("labels", []) and str(runner.get("name") or "")
}
for candidate in _DEFAULT_RUNNER_TARGET_CANDIDATES.get(
label, (targets.get(label, ""),)
):
if candidate and candidate in compatible:
return candidate
return None
def snapshot(self, *, refresh: bool = False) -> dict[str, Any]:
global _CACHE
now_monotonic = monotonic()
with _CACHE_LOCK:
if (
not refresh
and _CACHE is not None
and now_monotonic - _CACHE[0] < _CACHE_SECONDS
):
cached = copy.deepcopy(_CACHE[1])
cached["cache"] = {"hit": True, "ttlSeconds": _CACHE_SECONDS}
return cached
if not self.is_configured():
return {
"status": "missing_config",
"severity": "unknown",
"generatedAt": datetime.now(UTC).isoformat(),
"runners": [],
"queue": {
"queued": 0,
"inProgress": 0,
"oldestQueuedAgeSeconds": None,
"oldestInProgressAgeSeconds": None,
"sampleTruncated": False,
"blockedByRunnerLabels": [],
"staleAssignmentRunnerLabels": [],
},
"recommendedAction": None,
"cache": {"hit": False, "ttlSeconds": _CACHE_SECONDS},
}
try:
runner_payload = self._request(
"/api/v1/admin/actions/runners", {"disabled": "false"}
)
queued_payload = self._request(
"/api/v1/admin/actions/jobs",
{
"status": "queued",
"page": 1,
"limit": 100,
"sort": "id",
"order": "asc",
},
)
pending_payload = self._request(
"/api/v1/admin/actions/jobs",
{
"status": "pending",
"page": 1,
"limit": 100,
"sort": "id",
"order": "asc",
},
)
active_payload = self._request(
"/api/v1/admin/actions/jobs",
{"status": "in_progress", "page": 1, "limit": 100},
)
except RuntimeError as exc:
return {
"status": "error",
"severity": "warning",
"reason": redact_message(str(exc))[:240],
"generatedAt": datetime.now(UTC).isoformat(),
"runners": [],
"queue": {
"queued": 0,
"inProgress": 0,
"oldestQueuedAgeSeconds": None,
"oldestInProgressAgeSeconds": None,
"sampleTruncated": False,
"blockedByRunnerLabels": [],
"staleAssignmentRunnerLabels": [],
},
"recommendedAction": None,
"cache": {"hit": False, "ttlSeconds": _CACHE_SECONDS},
}
raw_runners = runner_payload.get("runners")
runner_items = raw_runners if isinstance(raw_runners, list) else []
runners = [
{
"id": int(item.get("id", 0) or 0),
"name": str(item.get("name") or "unknown")[:160],
"status": str(item.get("status") or "unknown")[:32],
"busy": bool(item.get("busy")),
"disabled": bool(item.get("disabled")),
"labels": self._labels(item.get("labels")),
}
for item in runner_items
if isinstance(item, dict)
]
queued_jobs = [
item
for payload in (queued_payload, pending_payload)
for item in (
payload.get("jobs") if isinstance(payload.get("jobs"), list) else []
)
if isinstance(item, dict)
]
active_jobs = (
active_payload.get("jobs")
if isinstance(active_payload.get("jobs"), list)
else []
)
now = datetime.now(UTC)
queued_times = [self._timestamp(item.get("created_at")) for item in queued_jobs]
queued_times = [item for item in queued_times if item is not None]
oldest_age = max(
(int((now - item).total_seconds()) for item in queued_times), default=None
)
active_times = [
self._timestamp(item.get("started_at"))
or self._timestamp(item.get("created_at"))
for item in active_jobs
]
active_times = [item for item in active_times if item is not None]
oldest_active_age = max(
(int((now - item).total_seconds()) for item in active_times), default=None
)
stale_active_jobs = []
for job in active_jobs:
created_at = self._timestamp(job.get("started_at")) or self._timestamp(
job.get("created_at")
)
if (
created_at is not None
and int((now - created_at).total_seconds())
>= _ACTIVE_CRITICAL_AGE_SECONDS
):
stale_active_jobs.append(job)
online_runners = [
item
for item in runners
if item["status"] == "online" and not item["disabled"]
]
blocked_jobs = []
stale_assignment_jobs: list[dict[str, Any]] = []
queue_items = []
for job in queued_jobs:
required = set(self._labels(job.get("labels")))
matching = [
runner
for runner in online_runners
if required.issubset(set(runner["labels"]))
]
if required and not matching:
blocked_jobs.append(job)
created = self._timestamp(job.get("created_at"))
age = int((now - created).total_seconds()) if created else None
all_matching_busy = bool(matching) and all(
bool(runner["busy"]) for runner in matching
)
reason = (
"no_online_runner_matches_required_capability"
if required and not matching
else "all_matching_runners_are_busy"
if all_matching_busy
else "awaiting_scheduler_assignment"
)
if (
required
and matching
and not all_matching_busy
and age is not None
and age >= _CRITICAL_AGE_SECONDS
):
stale_assignment_jobs.append(
{
"job": job,
"required": sorted(required),
"matching": matching,
"ageSeconds": age,
}
)
if len(queue_items) < _QUEUE_SAMPLE_LIMIT:
queue_items.append(
{
"id": int(job.get("id", 0) or 0),
"name": str(job.get("name") or "job")[:160],
"labels": sorted(required)[:20],
"ageSeconds": age,
"matchingRunners": [runner["name"] for runner in matching][:20],
"reason": reason,
}
)
severity = "healthy"
if (
(oldest_age or 0) >= _CRITICAL_AGE_SECONDS
or (oldest_active_age or 0) >= _ACTIVE_CRITICAL_AGE_SECONDS
or blocked_jobs
):
severity = "critical"
elif (
(oldest_age or 0) >= _WARNING_AGE_SECONDS
or (oldest_active_age or 0) >= _ACTIVE_WARNING_AGE_SECONDS
or len(online_runners) < len(runners)
):
severity = "warning"
targets = self._restart_targets()
blocked_labels = [
label for job in blocked_jobs for label in self._labels(job.get("labels"))
]
stale_active_labels = [
label
for job in stale_active_jobs
for label in self._labels(job.get("labels"))
]
stale_assignment_labels = sorted(
{
label
for candidate in stale_assignment_jobs
for label in candidate["required"]
}
)
recommended_action: dict[str, Any] | None = None
for candidate in sorted(
stale_assignment_jobs,
key=lambda item: int(item["ageSeconds"]),
reverse=True,
):
job = candidate["job"]
matching_by_name = {
str(runner.get("name") or ""): runner for runner in candidate["matching"]
}
for label in candidate["required"]:
target = self._target_for_label(label, candidate["matching"], targets)
if not target:
continue
target_runner = matching_by_name.get(target)
if target_runner is not None and bool(target_runner.get("busy")):
continue
recommended_action = {
"intent": "runner.recover",
"target": target,
"requiresApproval": False,
"autoExecute": True,
"reason": "Gitea Actions scheduler left a compatible idle runner unassigned for at least ten minutes.",
"evidence": {
"jobId": int(job.get("id", 0) or 0),
"label": label,
"minimumAgeSeconds": _CRITICAL_AGE_SECONDS,
},
}
break
if recommended_action is not None:
break
if recommended_action is None:
restart_target = None
restart_reason = ""
for label in [*blocked_labels, *stale_active_labels]:
target = self._target_for_label(label, runners, targets)
if target:
restart_target = target
restart_reason = (
"Gitea Actions queue is stalled without a matching online runner."
if label in blocked_labels
else "A Gitea Actions job exceeded the two-hour guardrail on an explicitly mapped runner."
)
break
recommended_action = (
{
"intent": "container.restart",
"target": restart_target,
"requiresApproval": True,
"autoExecute": False,
"reason": restart_reason,
}
if severity == "critical" and restart_target
else None
)
result = {
"status": "ok",
"severity": severity,
"generatedAt": now.isoformat(),
"runners": runners,
"summary": {
"total": len(runners),
"online": len(online_runners),
"busy": sum(bool(item["busy"]) for item in runners),
"offline": sum(item["status"] != "online" for item in runners),
},
"queue": {
"queued": int(queued_payload.get("total_count", 0) or 0)
+ int(pending_payload.get("total_count", 0) or 0),
"inProgress": int(
active_payload.get("total_count", len(active_jobs)) or 0
),
"oldestQueuedAgeSeconds": oldest_age,
"oldestInProgressAgeSeconds": oldest_active_age,
"sampleTruncated": (
int(queued_payload.get("total_count", 0) or 0)
> len(
queued_payload.get("jobs")
if isinstance(queued_payload.get("jobs"), list)
else []
)
or int(pending_payload.get("total_count", 0) or 0)
> len(
pending_payload.get("jobs")
if isinstance(pending_payload.get("jobs"), list)
else []
)
),
"blockedByRunnerLabels": sorted(set(blocked_labels))[:20],
"staleActiveRunnerLabels": sorted(set(stale_active_labels))[:20],
"staleAssignmentRunnerLabels": stale_assignment_labels[:20],
"items": queue_items,
},
"recommendedAction": recommended_action,
"cache": {"hit": False, "ttlSeconds": _CACHE_SECONDS},
}
with _CACHE_LOCK:
_CACHE = (now_monotonic, copy.deepcopy(result))
return result
@@ -0,0 +1,645 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlparse
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _NpmConfig:
url: str
identity: str
secret: str
verify_tls: bool
timeout_seconds: float
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_int(value: Any, default: int = 0) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return default
def _as_list(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if value in {None, ''}:
return []
return [value]
def _safe_url(value: Any = None) -> str:
raw = str(value or settings.apis.get('NPM_URL') or '').strip().rstrip('/')
if not raw:
return ''
parsed = urlparse(raw)
if not parsed.username and not parsed.password:
return raw
host = parsed.hostname or ''
port = f':{parsed.port}' if parsed.port else ''
return f'{parsed.scheme}://{host}{port}{parsed.path}'.rstrip('/')
def _parse_date(value: Any) -> datetime | None:
if not value:
return None
text = str(value).strip()
if not text:
return None
for candidate in (text, text.replace('Z', '+00:00')):
try:
parsed = datetime.fromisoformat(candidate)
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
except ValueError:
continue
for fmt in ('%Y-%m-%d', '%Y-%m-%d %H:%M:%S'):
try:
return datetime.strptime(text, fmt).replace(tzinfo=UTC)
except ValueError:
continue
return None
def _days_until(value: Any) -> int | None:
parsed = _parse_date(value)
if not parsed:
return None
return (parsed - datetime.now(UTC)).days
def _response_items(payload: Any) -> list[dict[str, Any]]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
data = payload.get('data')
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(payload.get('items'), list):
return [item for item in payload['items'] if isinstance(item, dict)]
return []
class NginxProxyManagerConnector(BaseConnector):
name = 'Nginx Proxy Manager'
def __init__(self):
super().__init__()
self._runtime_token: str | None = None
self._read_cache: dict[str, dict[str, Any]] = {}
def _memo(self, key: str, factory: Any) -> dict[str, Any]:
if key not in self._read_cache:
self._read_cache[key] = factory()
return self._read_cache[key]
def is_configured(self) -> bool:
return bool(settings.apis.get('NPM_URL') and settings.apis.get('NPM_IDENTITY') and settings.apis.get('NPM_SECRET'))
def _config(self) -> _NpmConfig | None:
if not self.is_configured():
return None
return _NpmConfig(
url=str(settings.apis['NPM_URL']).rstrip('/'),
identity=str(settings.apis['NPM_IDENTITY']),
secret=str(settings.apis['NPM_SECRET']),
verify_tls=_as_bool(settings.apis.get('NPM_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('NPM_TIMEOUT_SECONDS'), 10.0),
)
def _login(self) -> str:
if self._runtime_token:
return self._runtime_token
config = self._config()
if not config:
raise RuntimeError('missing_config')
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.post(
f'{config.url}/api/tokens',
json={'identity': config.identity, 'secret': config.secret},
headers={'Accept': 'application/json'},
)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {400, 401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
payload = response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
token = payload.get('token') if isinstance(payload, dict) else None
if not token:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
self._runtime_token = str(token)
return self._runtime_token
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
token = self._login()
try:
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.request(
method,
f'{config.url}/api{path}',
headers={'Accept': 'application/json', 'Authorization': f'Bearer {token}'},
**kwargs,
)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
self._login()
hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 100}))
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
return {'status': 'auth_failed', 'reason': reason}
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': reason}
return {'status': 'connected', 'proxyHostCount': len(hosts)}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(proxyHostCount=0, certificateCount=0)
result = self.test_connection()
status = str(result.get('status', 'error'))
certs = self.certificates() if status == 'connected' else {'items': []}
return {
'success': status == 'connected',
'status': status,
'message': 'Nginx Proxy Manager connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'url': _safe_url(),
'proxyHostCount': _as_int(result.get('proxyHostCount')),
'certificateCount': len(certs.get('items', [])) if isinstance(certs, dict) else 0,
'data': {},
}
def proxy_hosts(self) -> dict[str, Any]:
return self._memo('proxy_hosts', self._proxy_hosts_uncached)
def _proxy_hosts_uncached(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0)
try:
items = [self._map_proxy_host(item) for item in _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 100}))]
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0)
return {'success': True, 'status': 'ok', 'message': 'proxy_hosts_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def certificates(self) -> dict[str, Any]:
return self._memo('certificates', self._certificates_uncached)
def _certificates_uncached(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0)
try:
items = [self._map_certificate(item) for item in _response_items(self._request('GET', '/nginx/certificates', params={'limit': 100}))]
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0)
return {'success': True, 'status': 'ok', 'message': 'certificates_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def expiring_certificates(self, days: int = 30) -> dict[str, Any]:
certs = self.certificates()
items = [
item for item in certs.get('items', [])
if item.get('isExpired') or (item.get('expiresInDays') is not None and int(item.get('expiresInDays')) <= days)
]
return {**certs, 'message': 'expiring_certificates_ok', 'total': len(items), 'items': items, 'data': {'items': items, 'days': days}}
def access_lists(self) -> dict[str, Any]:
return self._memo('access_lists', self._access_lists_uncached)
def _access_lists_uncached(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0)
try:
items = [self._map_access_list(item) for item in _response_items(self._request('GET', '/nginx/access-lists', params={'limit': 100}))]
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0)
return {'success': True, 'status': 'ok', 'message': 'access_lists_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def validate_managed_access_list(self, access_list_id: int) -> dict[str, Any]:
"""Read-only verification for a trusted managed-host access list.
Expanded authorization entries are inspected in memory, then discarded;
the result contains only the trusted numeric identifier and status.
"""
if not self.is_configured():
return self._missing(reason='missing_config')
if isinstance(access_list_id, bool) or not isinstance(access_list_id, int) or not 1 <= access_list_id <= 2_147_483_647:
return {'success': False, 'status': 'invalid', 'message': 'invalid_access_list_id'}
try:
access_lists = _response_items(self._request(
'GET', '/nginx/access-lists',
params={'limit': 1000, 'expand': 'items,clients'},
))
except RuntimeError as exc:
return self._error_payload(exc)
matches = [item for item in access_lists if _as_int(item.get('id')) == access_list_id]
if (
len(matches) != 1
or matches[0].get('satisfy_any', matches[0].get('satisfyAny')) is not False
or not isinstance(matches[0].get('items'), list)
or not matches[0]['items']
):
return {'success': False, 'status': 'blocked', 'message': 'required_access_list_unavailable'}
return {
'success': True,
'status': 'verified',
'message': 'required_access_list_verified',
'accessListId': access_list_id,
}
def exposure_summary(self) -> dict[str, Any]:
hosts = self.proxy_hosts()
certs = self.certificates()
access_lists = self.access_lists()
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)
summary = {
'totalProxyHosts': len(host_items),
'enabledProxyHosts': sum(1 for item in host_items if item.get('enabled')),
'publicHosts': sum(1 for item in host_items if item.get('enabled')),
'hostsWithoutCertificate': sum(1 for item in host_items if item.get('enabled') and not item.get('certificateId')),
'hostsWithExpiredCertificate': sum(1 for item in host_items if (cert_by_id.get(item.get('certificateId')) or {}).get('isExpired')),
'hostsWithExpiringCertificate': sum(1 for item in host_items if (cert_by_id.get(item.get('certificateId')) or {}).get('isExpiringSoon')),
'hostsWithoutAccessList': sum(1 for item in host_items if item.get('enabled') and not item.get('accessListId')),
'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),
'warnings': warnings,
}
return {'success': True, 'status': 'ok', 'message': 'exposure_summary_ok', **summary, 'data': summary}
def host_reachability(self) -> dict[str, Any]:
hosts = self.proxy_hosts()
if not hosts.get('success'):
return {**hosts, 'data': {'checks': []}}
checks = [
{
'hostId': item.get('id'),
'domains': item.get('domainNames', []),
'target': f"{item.get('forwardScheme')}://{item.get('forwardHost')}:{item.get('forwardPort')}",
'status': 'metadata_only',
'message': 'V1 does not perform active target scans by default.',
}
for item in hosts.get('items', [])
]
return {'success': True, 'status': 'metadata_only', 'message': 'host_reachability_metadata_only', 'checks': checks, 'data': {'checks': checks}}
def security_warnings(self) -> dict[str, Any]:
hosts = self.proxy_hosts()
certs = self.certificates()
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)
severity_order = {'critical': 3, 'high': 2, 'warning': 1, 'info': 0}
status = 'warning' if items else 'ok'
return {
'success': True,
'status': status,
'message': 'security_warnings_detected' if items else 'security_warnings_ok',
'total': len(items),
'items': sorted(items, key=lambda item: severity_order.get(str(item.get('severity')), 0), reverse=True),
'data': {'items': items},
}
def ensure_managed_proxy_host(
self,
*,
hostname: str,
forward_host: str,
forward_port: int,
certificate_mode: str,
force_ssl: bool,
http2: bool,
upload_limit_mb: int,
read_timeout_seconds: int,
ownership_id: str | None = None,
access_list_id: int = 0,
) -> dict[str, Any]:
"""Create or verify one deployment-owned proxy host.
This is intentionally not a general NPM mutation method. Every value
is derived from DeploymentPlanV1 and this refuses to take over a host
that does not already point at the exact managed container.
"""
if not self.is_configured():
return self._missing(reason='missing_config')
if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?', hostname):
return {'success': False, 'status': 'invalid', 'message': 'invalid_hostname'}
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,62}', forward_host):
return {'success': False, 'status': 'invalid', 'message': 'invalid_forward_host'}
if not 1 <= int(forward_port) <= 65535:
return {'success': False, 'status': 'invalid', 'message': 'invalid_forward_port'}
if certificate_mode not in {'existing', 'managed'}:
return {'success': False, 'status': 'invalid', 'message': 'invalid_certificate_mode'}
if not force_ssl:
return {'success': False, 'status': 'invalid', 'message': 'managed_proxy_requires_force_ssl'}
if ownership_id is not None and not re.fullmatch(r'enr-[0-9a-f]{20}', ownership_id):
return {'success': False, 'status': 'invalid', 'message': 'invalid_managed_ownership_id'}
if isinstance(access_list_id, bool) or not isinstance(access_list_id, int) or not 0 <= access_list_id <= 2_147_483_647:
return {'success': False, 'status': 'invalid', 'message': 'invalid_access_list_id'}
try:
if access_list_id:
access_list = self.validate_managed_access_list(access_list_id)
if not access_list.get('success'):
return access_list
hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000}))
exact = [
item for item in hosts
if hostname in {str(value).strip().lower() for value in _as_list(item.get('domain_names') or item.get('domainNames'))}
]
if len(exact) > 1:
return {'success': False, 'status': 'conflict', 'message': 'multiple_hostname_owners'}
if exact:
existing = exact[0]
same_target = (
str(existing.get('forward_host') or existing.get('forwardHost') or '') == forward_host
and _as_int(existing.get('forward_port') or existing.get('forwardPort')) == int(forward_port)
)
if not same_target:
return {'success': False, 'status': 'conflict', 'message': 'hostname_owned_by_different_target'}
metadata = existing.get('meta') if isinstance(existing.get('meta'), dict) else {}
if ownership_id is not None and metadata.get('appops_enrollment_id') != ownership_id:
return {'success': False, 'status': 'conflict', 'message': 'hostname_is_not_owned_by_this_enrollment'}
hardened = (
bool(existing.get('ssl_forced') or existing.get('sslForced'))
and bool(existing.get('http2_support') or existing.get('http2Support'))
and bool(existing.get('block_exploits') or existing.get('blockExploits'))
and not bool(existing.get('caching_enabled') or existing.get('cachingEnabled'))
and not bool(existing.get('allow_websocket_upgrade') or existing.get('websocketSupport'))
and bool(existing.get('certificate_id') or existing.get('certificateId'))
and _as_int(existing.get('access_list_id') or existing.get('accessListId')) == access_list_id
)
if not hardened:
return {'success': False, 'status': 'conflict', 'message': 'existing_proxy_host_does_not_meet_managed_security_contract'}
return {
'success': True,
'status': 'already_managed',
'message': 'proxy_host_already_matches_typed_plan',
'hostId': existing.get('id'),
'hostname': hostname,
}
certificate_id: Any = 0
metadata: dict[str, Any] = {}
if certificate_mode == 'existing':
certificates = _response_items(self._request('GET', '/nginx/certificates', params={'limit': 1000}))
matching_certificate = next(
(
item for item in certificates
if hostname in {
str(value).strip().lower()
for value in _as_list(item.get('domain_names') or item.get('domainNames'))
}
),
None,
)
if not matching_certificate:
return {'success': False, 'status': 'blocked', 'message': 'existing_certificate_not_found'}
certificate_id = matching_certificate.get('id')
else:
email = str(settings.apis.get('NPM_LETSENCRYPT_EMAIL') or '').strip()
if not email or '@' not in email or len(email) > 254:
return {'success': False, 'status': 'blocked', 'message': 'certificate_automation_not_configured'}
certificate_id = 'new'
metadata = {'letsencrypt_email': email, 'letsencrypt_agree': True}
if ownership_id is not None:
metadata = {**metadata, 'appops_enrollment_id': ownership_id}
advanced = (
f'client_max_body_size {int(upload_limit_mb)}m;\n'
f'proxy_read_timeout {int(read_timeout_seconds)}s;\n'
f'proxy_send_timeout {int(read_timeout_seconds)}s;'
)
created = self._request(
'POST',
'/nginx/proxy-hosts',
json={
'domain_names': [hostname],
'forward_scheme': 'http',
'forward_host': forward_host,
'forward_port': int(forward_port),
'access_list_id': access_list_id,
'certificate_id': certificate_id,
'ssl_forced': bool(force_ssl),
'http2_support': bool(http2),
'hsts_enabled': False,
'hsts_subdomains': False,
'caching_enabled': False,
'block_exploits': True,
'allow_websocket_upgrade': False,
'meta': metadata,
'advanced_config': advanced,
},
)
except RuntimeError as exc:
return self._error_payload(exc)
host_id = created.get('id') if isinstance(created, dict) else None
if ownership_id is not None or access_list_id:
if host_id is None:
return {
'success': False,
'status': 'ownership_unverified',
'message': 'managed_proxy_creation_response_missing_identifier',
}
try:
after_create = _response_items(
self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000})
)
except RuntimeError as exc:
return {
'success': False,
'status': 'ownership_unverified',
'message': redact_message(str(exc)),
'hostId': host_id,
}
verified = [item for item in after_create if str(item.get('id')) == str(host_id)]
metadata = verified[0].get('meta') if len(verified) == 1 and isinstance(verified[0].get('meta'), dict) else {}
same_target = bool(
len(verified) == 1
and str(verified[0].get('forward_host') or verified[0].get('forwardHost') or '') == forward_host
and _as_int(verified[0].get('forward_port') or verified[0].get('forwardPort')) == int(forward_port)
and _as_int(verified[0].get('access_list_id') or verified[0].get('accessListId')) == access_list_id
)
if not same_target or (ownership_id is not None and metadata.get('appops_enrollment_id') != ownership_id):
return {
'success': False,
'status': 'ownership_unverified',
'message': 'managed_proxy_creation_ownership_not_verified',
'hostId': host_id,
}
result = {
'success': True,
'status': 'created',
'message': 'proxy_host_created_from_typed_plan',
'hostId': host_id,
'hostname': hostname,
}
if ownership_id is not None:
result['enrollmentId'] = ownership_id
return result
def remove_managed_proxy_host(self, *, host_id: int | str, ownership_id: str) -> dict[str, Any]:
"""Compensate only a proxy host created by this exact enrollment."""
if not re.fullmatch(r'enr-[0-9a-f]{20}', ownership_id):
return {'success': False, 'status': 'invalid', 'message': 'invalid_managed_ownership_id'}
if not self.is_configured():
return self._missing(reason='missing_config')
try:
hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000}))
matching = [item for item in hosts if str(item.get('id')) == str(host_id)]
if len(matching) != 1:
return {'success': False, 'status': 'not_found', 'message': 'managed_proxy_host_not_found'}
metadata = matching[0].get('meta') if isinstance(matching[0].get('meta'), dict) else {}
if metadata.get('appops_enrollment_id') != ownership_id:
return {'success': False, 'status': 'conflict', 'message': 'proxy_host_is_not_owned_by_this_enrollment'}
self._request('DELETE', f'/nginx/proxy-hosts/{host_id}')
except RuntimeError as exc:
return self._error_payload(exc)
return {'success': True, 'status': 'removed', 'hostId': host_id, 'enrollmentId': ownership_id}
@staticmethod
def _map_proxy_host(raw: dict[str, Any]) -> dict[str, Any]:
return {
'id': raw.get('id'),
'domainNames': [str(item) for item in _as_list(raw.get('domain_names') or raw.get('domainNames'))],
'forwardHost': str(raw.get('forward_host') or raw.get('forwardHost') or ''),
'forwardPort': _as_int(raw.get('forward_port') or raw.get('forwardPort')),
'forwardScheme': str(raw.get('forward_scheme') or raw.get('forwardScheme') or 'http'),
'enabled': bool(raw.get('enabled')),
'sslForced': bool(raw.get('ssl_forced') or raw.get('sslForced')),
'http2Support': bool(raw.get('http2_support') or raw.get('http2Support')),
'hstsEnabled': bool(raw.get('hsts_enabled') or raw.get('hstsEnabled')),
'certificateId': raw.get('certificate_id') or raw.get('certificateId') or None,
'accessListId': raw.get('access_list_id') or raw.get('accessListId') or None,
'blockExploits': bool(raw.get('block_exploits') or raw.get('blockExploits')),
'cachingEnabled': bool(raw.get('caching_enabled') or raw.get('cachingEnabled')),
'websocketSupport': bool(raw.get('allow_websocket_upgrade') or raw.get('websocket_support') or raw.get('websocketSupport')),
'locationsCount': len(_as_list(raw.get('locations'))),
'meta': raw.get('meta') if isinstance(raw.get('meta'), dict) else {},
}
@staticmethod
def _map_certificate(raw: dict[str, Any]) -> dict[str, Any]:
expires = raw.get('expires_on') or raw.get('expiresOn') or raw.get('expires')
days = _days_until(expires)
return {
'id': raw.get('id'),
'niceName': str(raw.get('nice_name') or raw.get('niceName') or raw.get('name') or ''),
'domainNames': [str(item) for item in _as_list(raw.get('domain_names') or raw.get('domainNames'))],
'provider': str(raw.get('provider') or raw.get('type') or 'unknown').lower(),
'expiresOn': expires,
'expiresInDays': days,
'isExpired': days is not None and days < 0,
'isExpiringSoon': days is not None and days <= 30,
}
@staticmethod
def _map_access_list(raw: dict[str, Any]) -> dict[str, Any]:
return {
'id': raw.get('id'),
'name': str(raw.get('name') or ''),
'authorizationType': str(raw.get('auth_type') or raw.get('authorizationType') or raw.get('authorization_type') or 'unknown'),
'satisfyAny': bool(raw.get('satisfy_any') or raw.get('satisfyAny')),
'itemsCount': len(_as_list(raw.get('items'))),
'clientsCount': len(_as_list(raw.get('clients'))),
}
@staticmethod
def _build_warnings(hosts: list[dict[str, Any]], cert_by_id: dict[Any, dict[str, Any]]) -> list[dict[str, Any]]:
warnings: list[dict[str, Any]] = []
for host in hosts:
if not host.get('enabled'):
continue
domains = host.get('domainNames') or []
label = ', '.join(domains) or f"host-{host.get('id')}"
cert = cert_by_id.get(host.get('certificateId')) if host.get('certificateId') else None
target = f"{host.get('forwardScheme')}://{host.get('forwardHost')}:{host.get('forwardPort')}"
if not host.get('certificateId'):
warnings.append({'type': 'host_without_certificate', 'severity': 'high', 'title': f'{label} has no certificate', 'evidence': [label, target], 'suggestedAction': 'review_certificate'})
elif cert and cert.get('isExpired'):
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'):
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'})
if not host.get('hstsEnabled'):
warnings.append({'type': 'hsts_disabled', 'severity': 'info', 'title': f'{label} has HSTS disabled', 'evidence': [label], 'suggestedAction': 'review_hsts_policy'})
if str(host.get('forwardHost') or '').lower() in {'unknown-app', 'unknown', ''}:
warnings.append({'type': 'unknown_proxy_target', 'severity': 'warning', 'title': f'{label} forwards to an unknown target', 'evidence': [target], 'suggestedAction': 'map_proxy_host_target'})
return warnings
@staticmethod
def _missing(**extra: Any) -> dict[str, Any]:
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': _safe_url(), 'required_env_vars': ['NPM_URL', 'NPM_IDENTITY', 'NPM_SECRET'], 'data': {}, **extra}
@staticmethod
def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]:
reason = redact_message(str(exc))
status = 'auth_failed' if reason.startswith('auth_failed') else ('missing_config' if reason == 'missing_config' else 'unreachable')
return {'success': False, 'status': status, 'message': reason, 'data': {}, **extra}
+329
View File
@@ -0,0 +1,329 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from xml.etree import ElementTree
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _PlexConfig:
url: str
token: str
verify_tls: bool
timeout_seconds: float
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_int(value: Any, default: int = 0) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return default
def _safe_path(value: Any) -> str:
text = str(value or '').replace('\\', '/').rstrip('/')
parts = [part for part in text.split('/') if part]
return f'.../{parts[-1]}' if parts else ''
def _mask_ip(value: Any) -> str:
text = str(value or '')
if not text:
return ''
parts = text.split('.')
if len(parts) == 4:
return f'{parts[0]}.{parts[1]}.{parts[2]}.x'
return '[masked]'
def _safe_url() -> str:
value = settings.apis.get('PLEX_URL')
return str(value).rstrip('/') if value else ''
class PlexConnector(BaseConnector):
name = 'Plex'
def is_configured(self) -> bool:
return bool(settings.apis.get('PLEX_URL') and settings.apis.get('PLEX_TOKEN'))
def _config(self) -> _PlexConfig | None:
if not self.is_configured():
return None
return _PlexConfig(
url=str(settings.apis['PLEX_URL']).rstrip('/'),
token=str(settings.apis['PLEX_TOKEN']),
verify_tls=_as_bool(settings.apis.get('PLEX_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('PLEX_TIMEOUT_SECONDS'), 10.0),
)
def _request(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
config = self._config()
if not config:
raise RuntimeError('missing_config')
request_params = dict(params or {})
request_params['X-Plex-Token'] = config.token
url = f'{config.url}{path if path.startswith("/") else f"/{path}"}'
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.get(url, headers={'Accept': 'application/json'}, params=request_params)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Plex API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
return response.json()
except ValueError:
return self._xml_to_container(response.text)
def _xml_to_container(self, text: str) -> dict[str, Any]:
try:
root = ElementTree.fromstring(text)
except ElementTree.ParseError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
container: dict[str, Any] = {'MediaContainer': dict(root.attrib)}
children = []
for child in root:
item = dict(child.attrib)
item['tag'] = child.tag
children.append(item)
if children:
container['MediaContainer']['Directory' if children[0].get('tag') == 'Directory' else 'Metadata'] = children
return container
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
container = self._media_container(self._request('/'))
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
return {'status': 'auth_failed', 'reason': reason}
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': reason}
return {
'status': 'connected',
'friendlyName': str(container.get('friendlyName') or ''),
'version': str(container.get('version') or 'unknown'),
'machineIdentifier': str(container.get('machineIdentifier') or ''),
}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'url': '',
'version': 'unknown',
'machineIdentifier': '',
'friendlyName': '',
'required_env_vars': ['PLEX_URL', 'PLEX_TOKEN'],
}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Plex connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'url': _safe_url(),
'version': str(result.get('version', 'unknown')),
'machineIdentifier': str(result.get('machineIdentifier', '')),
'friendlyName': str(result.get('friendlyName', '')),
}
def server_status(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
try:
container = self._media_container(self._request('/'))
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc))}
return {
'success': True,
'status': 'ok',
'friendlyName': str(container.get('friendlyName') or ''),
'version': str(container.get('version') or 'unknown'),
'platform': str(container.get('platform') or ''),
'updatedAt': container.get('updatedAt'),
'myPlex': container.get('myPlex'),
'machineIdentifier': str(container.get('machineIdentifier') or ''),
'libraryCount': _as_int(container.get('size')),
}
def library_status(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
container = self._media_container(self._request('/library/sections'))
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = container.get('Directory') if isinstance(container.get('Directory'), list) else []
items = [self._map_library(item) for item in records if isinstance(item, dict)]
return {'success': True, 'status': 'ok', 'message': 'libraries_ok', 'total': len(items), 'items': items, 'libraries': items}
def active_sessions(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
container = self._media_container(self._request('/status/sessions'))
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = container.get('Metadata') if isinstance(container.get('Metadata'), list) else []
items = [self._map_session(item) for item in records if isinstance(item, dict)]
return {'success': True, 'status': 'ok', 'message': 'sessions_ok', 'total': len(items), 'items': items, 'sessions': items}
def recent_media(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
container = self._media_container(self._request('/library/recentlyAdded', {'X-Plex-Container-Size': 25}))
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = container.get('Metadata') if isinstance(container.get('Metadata'), list) else []
items = [self._map_recent(item) for item in records if isinstance(item, dict)]
return {'success': True, 'status': 'ok', 'message': 'recent_media_ok', 'total': len(items), 'items': items}
def stream_summary(self) -> dict[str, Any]:
sessions = self.active_sessions()
items = sessions.get('items', []) if isinstance(sessions, dict) else []
return {
'success': bool(sessions.get('success')),
'status': sessions.get('status', 'unreachable'),
'activeStreams': len(items),
'total': len(items),
'directPlay': sum(1 for item in items if item.get('transcodeDecision') == 'directplay'),
'directStream': sum(1 for item in items if item.get('transcodeDecision') == 'directstream'),
'transcoding': sum(1 for item in items if item.get('transcodeDecision') == 'transcode'),
'bandwidth': sum(_as_int(item.get('bandwidth')) for item in items),
'users': len({item.get('user') for item in items if item.get('user')}),
}
def scan_library(self, library_id: str) -> dict[str, Any]:
clean_id = library_id.strip()
if not clean_id:
return {'success': False, 'status': 'error', 'message': 'library_id_required'}
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'data': {'libraryId': clean_id}}
try:
self._request(f'/library/sections/{clean_id}/refresh')
except RuntimeError as exc:
return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'data': {'libraryId': clean_id}}
return {'success': True, 'status': 'ok', 'message': 'scan_started', 'data': {'libraryId': clean_id}}
def scan_all_libraries(self) -> dict[str, Any]:
libraries = self.library_status()
if not libraries.get('success'):
return {'success': False, 'status': libraries.get('status', 'error'), 'message': libraries.get('message', 'library_status_failed')}
accepted: list[str] = []
for item in libraries.get('items', []):
library_id = str(item.get('id') or '')
if library_id and self.scan_library(library_id).get('success'):
accepted.append(library_id)
return {'success': True, 'status': 'ok', 'message': 'scan_all_started', 'data': {'libraryIds': accepted}}
def _map_library(self, raw: dict[str, Any]) -> dict[str, Any]:
library_id = str(raw.get('key') or raw.get('id') or '')
count = 0
if library_id:
try:
count = _as_int(self._media_container(self._request(f'/library/sections/{library_id}/all')).get('size'))
except RuntimeError:
count = 0
locations_raw = raw.get('Location') if isinstance(raw.get('Location'), list) else []
return {
'id': library_id,
'key': library_id,
'title': str(raw.get('title') or ''),
'type': str(raw.get('type') or ''),
'agent': str(raw.get('agent') or ''),
'scanner': str(raw.get('scanner') or ''),
'count': count,
'itemCount': count,
'refreshStatus': raw.get('refreshing'),
'locations': [_safe_path(item.get('path')) for item in locations_raw if isinstance(item, dict)],
}
@staticmethod
def _map_session(raw: dict[str, Any]) -> dict[str, Any]:
user = raw.get('User') if isinstance(raw.get('User'), dict) else {}
player = raw.get('Player') if isinstance(raw.get('Player'), dict) else {}
session = raw.get('Session') if isinstance(raw.get('Session'), dict) else {}
transcode = raw.get('TranscodeSession') if isinstance(raw.get('TranscodeSession'), dict) else {}
media = raw.get('Media')[0] if isinstance(raw.get('Media'), list) and raw.get('Media') else {}
decision = str(transcode.get('videoDecision') or media.get('videoDecision') or (media.get('Part', [{}])[0].get('decision') if isinstance(media.get('Part'), list) and media.get('Part') else '') or 'directplay').lower()
duration = _as_int(raw.get('duration'))
offset = _as_int(raw.get('viewOffset'))
return {
'sessionKey': str(raw.get('sessionKey') or ''),
'id': str(raw.get('sessionKey') or ''),
'user': str(user.get('title') or 'unknown'),
'title': str(raw.get('grandparentTitle') or raw.get('title') or 'unknown'),
'libraryType': str(raw.get('type') or ''),
'type': str(raw.get('type') or ''),
'player': str(player.get('title') or ''),
'state': str(player.get('state') or ''),
'product': str(player.get('product') or ''),
'platform': str(player.get('platform') or ''),
'ip': _mask_ip(player.get('address')),
'ipAddress': _mask_ip(player.get('address')),
'transcodeDecision': decision,
'videoDecision': str(transcode.get('videoDecision') or media.get('videoDecision') or decision),
'audioDecision': str(transcode.get('audioDecision') or media.get('audioDecision') or ''),
'bandwidth': _as_int(session.get('bandwidth')),
'progress': round((offset / duration) * 100, 1) if duration else 0,
}
@staticmethod
def _map_recent(raw: dict[str, Any]) -> dict[str, Any]:
title = str(raw.get('title') or 'unknown')
if raw.get('grandparentTitle'):
title = f"{raw.get('grandparentTitle')} - {title}"
return {
'id': str(raw.get('ratingKey') or raw.get('key') or raw.get('title') or ''),
'title': title,
'type': str(raw.get('type') or ''),
'year': raw.get('year'),
'library': str(raw.get('librarySectionTitle') or ''),
'addedAt': raw.get('addedAt'),
'thumb': raw.get('thumb'),
'summary': raw.get('summary'),
}
@staticmethod
def _media_container(payload: dict[str, Any]) -> dict[str, Any]:
container = payload.get('MediaContainer') if isinstance(payload, dict) else {}
return container if isinstance(container, dict) else {}
+492
View File
@@ -0,0 +1,492 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote, urlsplit, urlunsplit
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
_PORTAINER_DEFAULT_TIMEOUT_SECONDS = 8.0
@dataclass
class _ConnectorConfig:
url: str
api_key: str
endpoint_id: str
verify_tls: bool
class PortainerConnector(BaseConnector):
name = 'Portainer'
default_headers = {
'Content-Type': 'application/json',
}
def __init__(self):
super().__init__()
def is_configured(self):
return bool(settings.apis.get('PORTAINER_URL') and settings.apis.get('PORTAINER_API_KEY') and settings.apis.get('PORTAINER_ENDPOINT_ID'))
def _config(self) -> _ConnectorConfig | None:
if not self.is_configured():
return None
return _ConnectorConfig(
url=str(settings.apis['PORTAINER_URL']).strip().rstrip('/'),
api_key=str(settings.apis['PORTAINER_API_KEY']).strip(),
endpoint_id=str(settings.apis['PORTAINER_ENDPOINT_ID']).strip(),
verify_tls=bool(settings.apis.get('PORTAINER_VERIFY_TLS', settings.verify_tls)),
)
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
config = self._config()
if not config:
raise RuntimeError('missing_config')
headers = dict(self.default_headers)
headers['X-API-Key'] = config.api_key
url = f'{config.url}{path}'
try:
self.clear_error()
with httpx.Client(timeout=_PORTAINER_DEFAULT_TIMEOUT_SECONDS, verify=config.verify_tls) as client:
response = client.request(method=method, url=url, headers=headers, **kwargs)
except (httpx.RequestError, ValueError) as exc:
message = str(exc)
redacted = redact_message(message)
self.set_error(redacted)
raise RuntimeError(redacted)
if response.status_code == 401:
message = 'unauthorized'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 500:
message = f'Portainer server error: HTTP {response.status_code}'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 400:
response_text = response.text or ''
message = f'Portainer API error: HTTP {response.status_code} {response_text[:256]}'
redacted = redact_message(message)
self.set_error(redacted)
raise RuntimeError(redacted)
return response
def test_connection(self):
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
self._request('GET', '/api/status')
self._request('GET', f'/api/endpoints/{self._config().endpoint_id}/docker/version')
except RuntimeError as exc:
reason = str(exc)
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
if 'endpoints/' in reason or 'HTTP 404' in reason or 'HTTP 403' in reason:
return {'status': 'endpoint_error', 'reason': reason}
return {'status': 'error', 'reason': reason}
return {'status': 'connected', 'endpointReachable': True}
def list_containers(self, include_stats: bool = False, stats_limit: int = 200):
config = self._config()
if not config:
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
path = f'/api/endpoints/{config.endpoint_id}/docker/containers/json?all=1'
response = self._request('GET', path)
payload = response.json()
except (RuntimeError, ValueError) as exc:
return {'status': 'error', 'reason': str(exc), 'containers': []}
if not isinstance(payload, list):
return {'status': 'error', 'reason': 'unexpected_payload', 'containers': []}
containers = [self._map_container(container, config) for container in payload]
if include_stats:
self._attach_container_stats(containers, max(1, min(int(stats_limit or 200), 500)))
return {'status': 'ok', 'containers': containers}
def _attach_container_stats(self, containers: list[dict[str, Any]], stats_limit: int) -> None:
candidates = [
container
for container in containers
if container.get('state') == 'running' and container.get('id') and container.get('id') != 'unknown'
]
selected = candidates[:stats_limit]
selected_ids = {str(container.get('id')) for container in selected}
for container in containers:
if container.get('state') != 'running':
container['metricsStatus'] = 'not_running'
elif str(container.get('id')) not in selected_ids:
container['metricsStatus'] = 'not_requested'
if not selected:
return
max_workers = min(12, len(selected))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(self.get_container_stats, str(container.get('id'))): container
for container in selected
}
for future in as_completed(futures):
container = futures[future]
try:
stats = future.result()
except Exception as exc: # pragma: no cover - get_container_stats normalizes expected failures.
stats = {'metricsStatus': 'error', 'metricsError': redact_message(str(exc))}
container.update(stats)
def get_container_stats(self, container_id: str) -> dict[str, Any]:
if not self.is_configured():
return {'metricsStatus': 'missing_config', 'metricsError': 'missing_config'}
try:
safe_container_id = quote(str(container_id).strip(), safe='')
response = self._request(
'GET',
f'/api/endpoints/{self._config().endpoint_id}/docker/containers/{safe_container_id}/stats',
params={'stream': 'false'},
)
payload = response.json()
except (RuntimeError, ValueError) as exc:
return {
'metricsStatus': 'error',
'metricsError': redact_message(str(exc)),
}
metrics = _map_container_stats(payload)
metrics['metricsSource'] = 'portainer.docker.stats'
return metrics
def get_container_status(self, container_name: str):
containers = self.list_containers()
if containers.get('status') != 'ok':
return containers
entry = next((c for c in containers.get('containers', []) if c['name'] == container_name), None)
if entry:
return {'status': 'ok', 'container': entry}
return {'status': 'not_found', 'reason': f'Container {container_name} not found'}
def restart_container(self, target: str):
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config', 'container': target}
containers = self.list_containers()
if containers.get('status') != 'ok':
return containers
target_normalized = str(target).strip().lower()
selected = None
for candidate in containers.get('containers', []):
name = str(candidate.get('name', '')).lower()
container_id = str(candidate.get('id', '')).lower()
app_id = str(candidate.get('appId', '')).lower()
if (
name == target_normalized
or container_id == target_normalized
or app_id == target_normalized
or (target_normalized and (target_normalized in name or target_normalized in container_id))
):
selected = candidate
break
if not selected:
return {'status': 'not_found', 'reason': f'Container {target} not found'}
container_name = selected.get('id') or selected.get('name')
if not container_name:
return {'status': 'error', 'reason': 'Unable to resolve container identifier'}
endpoint_id = self._config().endpoint_id
if not endpoint_id:
return {'status': 'error', 'reason': 'invalid_connector_config'}
try:
self._request(
'POST',
f'/api/endpoints/{endpoint_id}/docker/containers/{container_name}/restart',
timeout=30.0,
)
except (RuntimeError, ValueError) as exc:
return {'status': 'error', 'reason': str(exc)}
return {
'status': 'ok',
'container': str(container_name),
'target': target,
'action': 'container.restart',
}
def get_container_logs(self, container_name: str, tail: int = 100):
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config', 'container': container_name, 'logs': []}
try:
response = self._request(
'GET',
f'/api/endpoints/{self._config().endpoint_id}/docker/containers/{container_name}/logs',
params={'stdout': 1, 'stderr': 1, 'tail': max(1, int(tail))},
)
except (RuntimeError, ValueError) as exc:
return {'status': 'error', 'reason': str(exc), 'container': container_name, 'logs': []}
output = (response.text or '').strip()
logs = [line for line in output.splitlines() if line.strip()]
return {'status': 'ok', 'container': container_name, 'logs': logs}
def list_stacks(self):
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config', 'stacks': []}
try:
payload = self._request('GET', '/api/stacks').json()
except (RuntimeError, ValueError) as exc:
return {'status': 'error', 'reason': str(exc), 'stacks': []}
if isinstance(payload, dict) and isinstance(payload.get('stacks'), list):
payload = payload.get('stacks', [])
if not isinstance(payload, list):
return {'status': 'error', 'reason': 'unexpected_payload', 'stacks': []}
return {'status': 'ok', 'stacks': [self._map_stack(stack) for stack in payload]}
def get_stack_status(self, stack_id: str):
stacks = self.list_stacks()
if stacks.get('status') != 'ok':
return stacks
entry = next((stack for stack in stacks.get('stacks', []) if stack['id'] == stack_id), None)
if entry:
return {'status': 'ok', 'stack': entry}
return {'status': 'not_found', 'reason': f'Stack {stack_id} not found'}
@staticmethod
def _map_container(raw: dict[str, Any], config: _ConnectorConfig | None = None) -> dict[str, Any]:
names = raw.get('Names') or []
raw_name = names[0] if names else raw.get('Name', '')
name = str(raw_name).strip('/').strip()
image = str(raw.get('Image', '')).strip()
state = str(raw.get('State', 'unknown')).lower()
state_map = {
'running': 'running',
'exited': 'exited',
'restarting': 'restarting',
'paused': 'paused',
}
mapped_state = state_map.get(state, 'exited')
health_raw = raw.get('State')
health = 'healthy'
if health_raw in {'exited', 'stopped', 'dead'}:
health = 'unhealthy'
ports_raw = raw.get('Ports', []) if isinstance(raw.get('Ports'), list) else []
ports = []
for port in ports_raw:
if not isinstance(port, dict):
continue
public_port = port.get('PublicPort')
private_port = port.get('PrivatePort')
if public_port is None or private_port is None:
continue
ports.append(f"{public_port}:{private_port}")
if not ports:
ports = []
container_id = str(raw.get('Id', name or 'unknown'))
mounts = []
for mount in raw.get('Mounts', []) if isinstance(raw.get('Mounts'), list) else []:
if not isinstance(mount, dict):
continue
source = str(mount.get('Source') or mount.get('Name') or '').strip()
destination = str(mount.get('Destination') or '').strip()
if not source and not destination:
continue
mounts.append({
'source': source,
'destination': destination,
'type': str(mount.get('Type') or '').strip(),
})
return {
'id': container_id,
'name': name or str(raw.get('Id', 'unknown')),
'appId': _infer_app_id(name),
'state': mapped_state,
'health': health,
'image': image or 'unknown',
'ports': ports,
'mounts': mounts,
'cpu': 'n/a',
'ram': 'n/a',
'metricsStatus': 'not_requested',
'restartCount': int(raw.get('RestartCount', 0) or 0),
'criticality': 'medium',
'voiceEnabled': name in {'widefrog', 'sonarr', 'jellyfin'},
'protected': _is_protected_app(name),
'portainerUrl': _container_deep_link(config, container_id),
}
@staticmethod
def _map_stack(raw: dict[str, Any]) -> dict[str, Any]:
stack_id = raw.get('Id') or raw.get('id') or raw.get('name')
status = str(raw.get('Status', 'unknown')).lower()
status_map = {
'stopped': 'critical',
'active': 'healthy',
'running': 'healthy',
'error': 'error',
'restarting': 'warning',
}
return {
'id': str(stack_id),
'name': str(raw.get('Name', stack_id or 'stack')),
'apps': [raw.get('EntryPoint')] if raw.get('EntryPoint') else [],
'containers': int(raw.get('ContainersCount', 1) or 0),
'status': status_map.get(status, 'healthy'),
'lastRedeploy': str(raw.get('UpdatedAt') or raw.get('created', 'unknown')),
'updatePolicy': str(raw.get('UpdateStrategy', 'Manual')),
'risk': 'medium',
}
def _infer_app_id(name: str) -> str:
normalized = str(name).lower()
if not normalized:
return ''
known = {
'widefrog': 'widefrog',
'sonarr': 'sonarr',
'radarr': 'radarr',
'jellyfin': 'jellyfin',
'authentik-server': 'authentik',
'authentik': 'authentik',
'postgres': 'postgres',
'redis': 'redis',
'portainer': 'portainer',
'home-assistant': 'home_assistant',
}
return known.get(normalized.split('/')[-1], normalized.split('/')[0].strip() if normalized else '')
def _is_protected_app(name: str) -> bool:
return _infer_app_id(name) in {'authentik', 'portainer', 'postgres', 'redis', 'home_assistant'}
def _nested_number(payload: dict[str, Any], path: tuple[str, ...]) -> float | None:
current: Any = payload
for key in path:
if not isinstance(current, dict) or key not in current:
return None
current = current[key]
try:
return float(current)
except (TypeError, ValueError):
return None
def _format_percent(value: float | None) -> str:
if value is None:
return 'n/a'
return f'{max(value, 0.0):.1f}%'
def _format_bytes(value: float | None) -> str:
if value is None or value < 0:
return 'n/a'
units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
amount = float(value)
unit = units[0]
for unit in units:
if amount < 1024 or unit == units[-1]:
break
amount /= 1024
if unit == 'B' or amount >= 10 or amount.is_integer():
return f'{amount:.0f} {unit}'
return f'{amount:.1f} {unit}'
def _map_container_stats(payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(payload, dict):
return {
'cpu': 'n/a',
'ram': 'n/a',
'metricsStatus': 'partial',
}
cpu_total = _nested_number(payload, ('cpu_stats', 'cpu_usage', 'total_usage'))
cpu_previous = _nested_number(payload, ('precpu_stats', 'cpu_usage', 'total_usage'))
system_total = _nested_number(payload, ('cpu_stats', 'system_cpu_usage'))
system_previous = _nested_number(payload, ('precpu_stats', 'system_cpu_usage'))
online_cpus = _nested_number(payload, ('cpu_stats', 'online_cpus'))
if not online_cpus:
cpu_stats = payload.get('cpu_stats') if isinstance(payload.get('cpu_stats'), dict) else {}
cpu_usage = cpu_stats.get('cpu_usage') if isinstance(cpu_stats.get('cpu_usage'), dict) else {}
per_cpu = cpu_usage.get('percpu_usage')
online_cpus = float(len(per_cpu)) if isinstance(per_cpu, list) and per_cpu else 1.0
cpu_percent: float | None = None
if cpu_total is not None and cpu_previous is not None and system_total is not None and system_previous is not None:
cpu_delta = cpu_total - cpu_previous
system_delta = system_total - system_previous
if cpu_delta >= 0 and system_delta > 0:
cpu_percent = (cpu_delta / system_delta) * float(online_cpus or 1.0) * 100.0
memory_usage = _nested_number(payload, ('memory_stats', 'usage'))
memory_limit = _nested_number(payload, ('memory_stats', 'limit'))
ram = _format_bytes(memory_usage)
if memory_usage is not None and memory_limit:
ram = f'{_format_bytes(memory_usage)} / {_format_bytes(memory_limit)}'
return {
'cpu': _format_percent(cpu_percent),
'ram': ram,
'metricsStatus': 'measured' if cpu_percent is not None and memory_usage is not None else 'partial',
}
def _safe_portainer_base_url(raw_url: str) -> str | None:
parsed = urlsplit(str(raw_url or '').strip())
if parsed.scheme not in {'http', 'https'} or not parsed.netloc:
return None
path = parsed.path.rstrip('/')
return urlunsplit((parsed.scheme, parsed.netloc, path, '', '')).rstrip('/')
def _container_deep_link(config: _ConnectorConfig | None, container_id: str) -> str | None:
if not config or not config.endpoint_id or not container_id or container_id == 'unknown':
return None
base_url = _safe_portainer_base_url(config.url)
if not base_url:
return None
endpoint_id = quote(str(config.endpoint_id).strip(), safe='')
safe_container_id = quote(str(container_id).strip(), safe='')
return f'{base_url}/#!/{endpoint_id}/docker/containers/{safe_container_id}'
+299
View File
@@ -0,0 +1,299 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _ProwlarrConfig:
url: str
api_key: str
verify_tls: bool
timeout_seconds: float
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_int(value: Any, default: int = 0) -> int:
if value is None or value == '':
return default
if isinstance(value, bool):
return int(value)
if isinstance(value, int | float):
return int(value)
try:
return int(str(value).strip())
except ValueError:
return default
def _safe_url() -> str:
value = settings.apis.get('PROWLARR_URL')
return str(value).rstrip('/') if value else ''
class ProwlarrConnector(BaseConnector):
name = 'Prowlarr'
def is_configured(self) -> bool:
return bool(settings.apis.get('PROWLARR_URL') and settings.apis.get('PROWLARR_API_KEY'))
def _config(self) -> _ProwlarrConfig | None:
if not self.is_configured():
return None
return _ProwlarrConfig(
url=str(settings.apis['PROWLARR_URL']).rstrip('/'),
api_key=str(settings.apis['PROWLARR_API_KEY']),
verify_tls=_as_bool(settings.apis.get('PROWLARR_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('PROWLARR_TIMEOUT_SECONDS'), 10.0),
)
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
headers = dict(kwargs.pop('headers', {}) or {})
headers['X-Api-Key'] = config.api_key
url = f'{config.url}{path}'
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.request(method=method, url=url, headers=headers, **kwargs)
except (httpx.RequestError, ValueError) as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('unauthorized')
raise RuntimeError('unauthorized')
if response.status_code == 404:
self.set_error('unsupported_endpoint')
raise RuntimeError('unsupported_endpoint')
if response.status_code >= 500:
message = f'Prowlarr server error: HTTP {response.status_code}'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 400:
message = redact_message(f'Prowlarr API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
if response.status_code == 204:
return None
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
payload = self._request('GET', '/api/v1/system/status')
except RuntimeError as exc:
reason = str(exc)
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable' if reason not in {'unauthorized'} else 'error', 'reason': redact_message(reason)}
version = payload.get('version') if isinstance(payload, dict) else None
return {'status': 'connected', 'version': str(version or 'unknown')}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'version': 'unknown',
'url': '',
'required_env_vars': ['PROWLARR_URL', 'PROWLARR_API_KEY'],
}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Prowlarr connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'version': str(result.get('version', 'unknown')),
'url': _safe_url(),
}
def indexer_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_indexers('missing_config')
try:
payload = self._request('GET', '/api/v1/indexer')
except RuntimeError as exc:
result = self._empty_indexers('unavailable' if str(exc) == 'unsupported_endpoint' else 'error')
result['message'] = redact_message(str(exc))
return result
items_raw = payload if isinstance(payload, list) else []
items = [self._map_indexer(item) for item in items_raw if isinstance(item, dict)]
return {
'success': True,
'status': 'ok',
'message': 'indexers_ok',
'total': len(items),
'enabled': sum(1 for item in items if item['enabled']),
'disabled': sum(1 for item in items if not item['enabled']),
'failing': sum(1 for item in items if item['status'] in {'warning', 'error'}),
'protocols': {
'torrent': sum(1 for item in items if item['protocol'] == 'torrent'),
'usenet': sum(1 for item in items if item['protocol'] == 'usenet'),
'unknown': sum(1 for item in items if item['protocol'] == 'unknown'),
},
'items': items,
}
def application_status(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
payload = self._request('GET', '/api/v1/applications')
except RuntimeError as exc:
return {'success': False, 'status': 'unavailable' if str(exc) == 'unsupported_endpoint' else 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
items_raw = payload if isinstance(payload, list) else []
items = [self._map_application(item) for item in items_raw if isinstance(item, dict)]
return {'success': True, 'status': 'ok', 'message': 'applications_ok', 'total': len(items), 'items': items}
def indexer_health(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'warnings': [], 'errors': []}
try:
payload = self._request('GET', '/api/v1/health')
except RuntimeError as exc:
return {'success': False, 'status': 'unavailable' if str(exc) == 'unsupported_endpoint' else 'error', 'message': redact_message(str(exc)), 'warnings': [], 'errors': []}
records = payload if isinstance(payload, list) else []
warnings = [str(item.get('message') or item.get('source') or 'warning') for item in records if isinstance(item, dict) and str(item.get('type', '')).lower() != 'error']
errors = [str(item.get('message') or item.get('source') or 'error') for item in records if isinstance(item, dict) and str(item.get('type', '')).lower() == 'error']
return {
'success': True,
'status': 'ok' if not warnings and not errors else 'warning',
'message': 'healthy' if not warnings and not errors else 'health_issues_detected',
'warnings': warnings,
'errors': errors,
}
def search_test(self, query: str) -> dict[str, Any]:
clean_query = query.strip()
if not clean_query:
return {'success': False, 'status': 'error', 'message': 'query_required', 'total': 0, 'items': []}
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
payload = self._request('GET', '/api/v1/search', params={'query': clean_query})
except RuntimeError as exc:
return {'success': False, 'status': 'unavailable' if str(exc) == 'unsupported_endpoint' else 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = payload if isinstance(payload, list) else []
items = [self._map_search_result(item) for item in records if isinstance(item, dict)]
return {'success': True, 'status': 'ok', 'message': 'search_ok', 'total': len(items), 'items': items}
def sync_status(self) -> dict[str, Any]:
indexers = self.indexer_status()
applications = self.application_status()
health = self.indexer_health()
success = bool(indexers.get('success')) and bool(applications.get('success')) and bool(health.get('success'))
return {
'success': success,
'status': 'ok' if success else 'unavailable',
'message': 'sync_status_ok' if success else 'sync_status_unavailable',
'applicationsConfigured': int(applications.get('total', 0)),
'indexersConfigured': int(indexers.get('total', 0)),
'indexersEnabled': int(indexers.get('enabled', 0)),
'indexersFailing': int(indexers.get('failing', 0)),
'healthWarnings': len(health.get('warnings', [])),
'healthErrors': len(health.get('errors', [])),
'lastSync': None,
}
@staticmethod
def _map_indexer(raw: dict[str, Any]) -> dict[str, Any]:
protocol = str(raw.get('protocol') or raw.get('protocolName') or 'unknown').lower()
if protocol not in {'torrent', 'usenet'}:
protocol = 'unknown'
enabled = bool(raw.get('enable', raw.get('enabled', False)))
status = str(raw.get('status') or ('healthy' if enabled else 'unknown')).lower()
if status in {'ok', 'available'}:
status = 'healthy'
if status not in {'healthy', 'warning', 'error', 'unknown'}:
status = 'unknown'
categories_raw = raw.get('categories') if isinstance(raw.get('categories'), list) else []
categories = [str(item.get('name') if isinstance(item, dict) else item) for item in categories_raw if item]
return {
'id': raw.get('id'),
'name': str(raw.get('name') or raw.get('definitionName') or 'unknown'),
'protocol': protocol,
'enabled': enabled,
'status': status,
'categories': categories,
'privacy': str(raw.get('privacy') or 'unknown').lower(),
}
@staticmethod
def _map_application(raw: dict[str, Any]) -> dict[str, Any]:
enabled = bool(raw.get('enable', raw.get('enabled', False)))
status = str(raw.get('status') or ('healthy' if enabled else 'unknown')).lower()
if status not in {'healthy', 'warning', 'error', 'unknown'}:
status = 'unknown'
return {
'id': raw.get('id'),
'name': str(raw.get('name') or raw.get('implementation') or 'unknown'),
'implementation': str(raw.get('implementation') or 'unknown'),
'syncLevel': str(raw.get('syncLevel') or raw.get('sync_level') or 'unknown'),
'enabled': enabled,
'status': status,
}
@staticmethod
def _map_search_result(raw: dict[str, Any]) -> dict[str, Any]:
categories_raw = raw.get('categories') if isinstance(raw.get('categories'), list) else []
categories = [str(item.get('name') if isinstance(item, dict) else item) for item in categories_raw if item]
return {
'title': str(raw.get('title') or 'unknown'),
'indexer': str(raw.get('indexer') or raw.get('indexerName') or 'unknown'),
'protocol': str(raw.get('protocol') or 'unknown').lower(),
'size': raw.get('size'),
'age': raw.get('age'),
'seeders': _as_int(raw.get('seeders'), 0),
'leechers': _as_int(raw.get('leechers'), 0),
'category': categories[0] if categories else str(raw.get('category') or 'unknown'),
'downloadAllowed': False,
}
@staticmethod
def _empty_indexers(status: str) -> dict[str, Any]:
return {
'success': False,
'status': status,
'message': status,
'total': 0,
'enabled': 0,
'disabled': 0,
'failing': 0,
'protocols': {'torrent': 0, 'usenet': 0, 'unknown': 0},
'items': [],
}
+314
View File
@@ -0,0 +1,314 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _RadarrConfig:
url: str
api_key: str
verify_tls: bool
default_root_folder: str | None
default_quality_profile: int | None
default_minimum_availability: str
search_on_add: bool
timeout_seconds: float
def _as_int(value: Any, default: int = 0) -> int:
if value is None or value == '':
return default
if isinstance(value, bool):
return int(value)
if isinstance(value, int | float):
return int(value)
try:
return int(str(value).strip())
except ValueError:
return default
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _safe_url() -> str:
value = settings.apis.get('RADARR_URL')
if not value:
return ''
return str(value).rstrip('/')
class RadarrConnector(BaseConnector):
name = 'Radarr'
def is_configured(self) -> bool:
return bool(settings.apis.get('RADARR_URL') and settings.apis.get('RADARR_API_KEY'))
def _config(self) -> _RadarrConfig | None:
if not self.is_configured():
return None
return _RadarrConfig(
url=str(settings.apis['RADARR_URL']).rstrip('/'),
api_key=str(settings.apis['RADARR_API_KEY']),
verify_tls=_as_bool(settings.apis.get('RADARR_VERIFY_TLS'), False),
default_root_folder=(
str(settings.apis.get('RADARR_DEFAULT_ROOT_FOLDER'))
if settings.apis.get('RADARR_DEFAULT_ROOT_FOLDER')
else None
),
default_quality_profile=(
_as_int(settings.apis.get('RADARR_DEFAULT_QUALITY_PROFILE'))
if settings.apis.get('RADARR_DEFAULT_QUALITY_PROFILE')
else None
),
default_minimum_availability=str(settings.apis.get('RADARR_DEFAULT_MINIMUM_AVAILABILITY') or 'released'),
search_on_add=_as_bool(settings.apis.get('RADARR_SEARCH_ON_ADD'), True),
timeout_seconds=_as_float(settings.apis.get('RADARR_TIMEOUT_SECONDS'), 10.0),
)
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
headers = dict(kwargs.pop('headers', {}) or {})
headers['X-Api-Key'] = config.api_key
url = f'{config.url}{path}'
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.request(method=method, url=url, headers=headers, **kwargs)
except (httpx.RequestError, ValueError) as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('unauthorized')
raise RuntimeError('unauthorized')
if response.status_code >= 500:
message = f'Radarr server error: HTTP {response.status_code}'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 400:
message = redact_message(f'Radarr API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
if response.status_code == 204:
return None
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
payload = self._request('GET', '/api/v3/system/status')
except RuntimeError as exc:
reason = str(exc)
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': redact_message(reason)}
version = payload.get('version') if isinstance(payload, dict) else None
return {'status': 'connected', 'version': str(version or 'unknown')}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'version': 'unknown',
'url': '',
'required_env_vars': ['RADARR_URL', 'RADARR_API_KEY'],
}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Radarr connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'version': str(result.get('version', 'unknown')),
'url': _safe_url(),
}
def search_movie(self, query: str) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'items': []}
try:
payload = self._request('GET', '/api/v3/movie/lookup', params={'term': query})
except RuntimeError as exc:
return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'items': []}
if not isinstance(payload, list):
return {'success': False, 'status': 'error', 'message': 'unexpected_payload', 'items': []}
return {
'success': True,
'status': 'connected',
'items': [self._map_movie(item) for item in payload if isinstance(item, dict)],
}
def queue_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_queue('missing_config')
try:
payload = self._request('GET', '/api/v3/queue')
except RuntimeError as exc:
result = self._empty_queue('unavailable')
result['message'] = redact_message(str(exc))
return result
records = payload.get('records', []) if isinstance(payload, dict) else []
if not isinstance(records, list):
records = []
items = [self._map_queue_item(item) for item in records if isinstance(item, dict)]
return {
'success': True,
'status': 'ok',
'total': int(payload.get('totalRecords', len(items))) if isinstance(payload, dict) else len(items),
'downloading': sum(1 for item in items if item['status'] == 'downloading'),
'queued': sum(1 for item in items if item['status'] == 'queued'),
'failed': sum(1 for item in items if item['trackedDownloadStatus'] == 'error' or item['status'] == 'failed'),
'stalled': sum(1 for item in items if item['status'] == 'stalled'),
'items': items,
}
def wanted_missing(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
payload = self._request('GET', '/api/v3/wanted/missing', params={'page': 1, 'pageSize': 20})
except RuntimeError as exc:
return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = payload.get('records', []) if isinstance(payload, dict) else []
if not isinstance(records, list):
records = []
return {
'success': True,
'status': 'connected',
'total': int(payload.get('totalRecords', len(records))) if isinstance(payload, dict) else len(records),
'items': records[:20],
}
def add_movie(self, payload: dict[str, Any]) -> dict[str, Any]:
config = self._config()
if not config:
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
title = str(payload.get('title') or '').strip()
tmdb_id = _as_int(payload.get('tmdbId') or payload.get('tmdb_id'))
if not title or not tmdb_id:
return {'success': False, 'status': 'error', 'message': 'title_and_tmdbId_required'}
root_folder = str(payload.get('rootFolderPath') or config.default_root_folder or '').strip()
quality_profile_id = _as_int(payload.get('qualityProfileId'), config.default_quality_profile or 0)
minimum_availability = str(
payload.get('minimumAvailability') or config.default_minimum_availability or 'released'
).strip()
if not root_folder or not quality_profile_id:
return {'success': False, 'status': 'missing_config', 'message': 'missing_defaults'}
search_on_add = _as_bool(payload.get('searchForMovie'), config.search_on_add)
request_payload = {
**payload,
'title': title,
'tmdbId': tmdb_id,
'rootFolderPath': root_folder,
'qualityProfileId': quality_profile_id,
'monitored': _as_bool(payload.get('monitored'), True),
'minimumAvailability': minimum_availability,
'searchForMovie': search_on_add,
'addOptions': {
'searchForMovie': search_on_add,
},
}
try:
result = self._request('POST', '/api/v3/movie', json=request_payload)
except RuntimeError as exc:
return {'success': False, 'status': 'error', 'message': redact_message(str(exc))}
return {
'success': True,
'status': 'connected',
'message': 'movie_added',
'data': result if isinstance(result, dict) else {},
}
@staticmethod
def _map_movie(raw: dict[str, Any]) -> dict[str, Any]:
return {
'id': _as_int(raw.get('id')),
'tmdbId': _as_int(raw.get('tmdbId')),
'imdbId': str(raw.get('imdbId', '')),
'title': str(raw.get('title', '')),
'year': _as_int(raw.get('year')),
'overview': str(raw.get('overview', '')),
'status': str(raw.get('status', 'unknown')),
'remotePoster': str(raw.get('remotePoster', '')),
'existing': bool(raw.get('id') and raw.get('path')),
'monitored': _as_bool(raw.get('monitored'), False),
'minimumAvailability': str(raw.get('minimumAvailability', 'unknown')),
}
@staticmethod
def _map_queue_item(raw: dict[str, Any]) -> dict[str, Any]:
movie = raw.get('movie') if isinstance(raw.get('movie'), dict) else {}
return {
'id': raw.get('id'),
'title': str(raw.get('title') or movie.get('title') or 'unknown'),
'status': str(raw.get('status', 'unknown')).lower(),
'trackedDownloadStatus': str(raw.get('trackedDownloadStatus', 'unknown')).lower(),
'size': raw.get('size'),
'sizeleft': raw.get('sizeleft'),
}
@staticmethod
def _empty_queue(status: str) -> dict[str, Any]:
return {
'success': False,
'status': status,
'message': status,
'total': 0,
'downloading': 0,
'queued': 0,
'failed': 0,
'stalled': 0,
'items': [],
}
+300
View File
@@ -0,0 +1,300 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
_SONARR_DEFAULT_TIMEOUT_SECONDS = 8.0
@dataclass
class _SonarrConfig:
url: str
api_key: str
verify_tls: bool
default_root_folder: str | None
default_quality_profile: int | None
search_on_add: bool
def _as_int(value: Any, default: int = 0) -> int:
if value is None or value == '':
return default
if isinstance(value, bool):
return int(value)
if isinstance(value, int | float):
return int(value)
try:
return int(str(value).strip())
except ValueError:
return default
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _safe_url() -> str:
value = settings.apis.get('SONARR_URL')
if not value:
return ''
return str(value).rstrip('/')
class SonarrConnector(BaseConnector):
name = 'Sonarr'
def is_configured(self) -> bool:
return bool(settings.apis.get('SONARR_URL') and settings.apis.get('SONARR_API_KEY'))
def _config(self) -> _SonarrConfig | None:
if not self.is_configured():
return None
return _SonarrConfig(
url=str(settings.apis['SONARR_URL']).rstrip('/'),
api_key=str(settings.apis['SONARR_API_KEY']),
verify_tls=bool(settings.verify_tls),
default_root_folder=(
str(settings.apis.get('SONARR_DEFAULT_ROOT_FOLDER'))
if settings.apis.get('SONARR_DEFAULT_ROOT_FOLDER')
else None
),
default_quality_profile=(
_as_int(settings.apis.get('SONARR_DEFAULT_QUALITY_PROFILE'))
if settings.apis.get('SONARR_DEFAULT_QUALITY_PROFILE')
else None
),
search_on_add=_as_bool(settings.apis.get('SONARR_SEARCH_ON_ADD'), True),
)
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
headers = dict(kwargs.pop('headers', {}) or {})
headers['X-Api-Key'] = config.api_key
url = f'{config.url}{path}'
try:
self.clear_error()
with httpx.Client(timeout=_SONARR_DEFAULT_TIMEOUT_SECONDS, verify=config.verify_tls) as client:
response = client.request(method=method, url=url, headers=headers, **kwargs)
except (httpx.RequestError, ValueError) as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('unauthorized')
raise RuntimeError('unauthorized')
if response.status_code >= 500:
message = f'Sonarr server error: HTTP {response.status_code}'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 400:
message = redact_message(f'Sonarr API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
if response.status_code == 204:
return None
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
payload = self._request('GET', '/api/v3/system/status')
except RuntimeError as exc:
reason = str(exc)
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'error', 'reason': redact_message(reason)}
version = payload.get('version') if isinstance(payload, dict) else None
return {'status': 'connected', 'version': str(version or 'unknown')}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'version': 'unknown',
'url': '',
'required_env_vars': ['SONARR_URL', 'SONARR_API_KEY'],
}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Sonarr connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'version': str(result.get('version', 'unknown')),
'url': _safe_url(),
}
def search_series(self, query: str) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'items': []}
try:
payload = self._request('GET', '/api/v3/series/lookup', params={'term': query})
except RuntimeError as exc:
return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'items': []}
if not isinstance(payload, list):
return {'success': False, 'status': 'error', 'message': 'unexpected_payload', 'items': []}
return {
'success': True,
'status': 'connected',
'items': [self._map_series(item) for item in payload if isinstance(item, dict)],
}
def queue_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_queue('missing_config')
try:
payload = self._request('GET', '/api/v3/queue')
except RuntimeError as exc:
result = self._empty_queue('error')
result['message'] = redact_message(str(exc))
return result
records = payload.get('records', []) if isinstance(payload, dict) else []
if not isinstance(records, list):
records = []
items = [self._map_queue_item(item) for item in records if isinstance(item, dict)]
return {
'success': True,
'status': 'connected',
'total': int(payload.get('totalRecords', len(items))) if isinstance(payload, dict) else len(items),
'downloading': sum(1 for item in items if item['status'] == 'downloading'),
'queued': sum(1 for item in items if item['status'] == 'queued'),
'failed': sum(1 for item in items if item['trackedDownloadStatus'] == 'error' or item['status'] == 'failed'),
'stalled': sum(1 for item in items if item['status'] == 'stalled'),
'items': items,
}
def wanted_missing(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
payload = self._request('GET', '/api/v3/wanted/missing', params={'page': 1, 'pageSize': 20})
except RuntimeError as exc:
return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []}
records = payload.get('records', []) if isinstance(payload, dict) else []
if not isinstance(records, list):
records = []
return {
'success': True,
'status': 'connected',
'total': int(payload.get('totalRecords', len(records))) if isinstance(payload, dict) else len(records),
'items': records[:20],
}
def add_series(self, payload: dict[str, Any]) -> dict[str, Any]:
config = self._config()
if not config:
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
title = str(payload.get('title') or '').strip()
tvdb_id = _as_int(payload.get('tvdbId') or payload.get('tvdb_id'))
if not title or not tvdb_id:
return {'success': False, 'status': 'error', 'message': 'title_and_tvdbId_required'}
root_folder = str(payload.get('rootFolderPath') or config.default_root_folder or '').strip()
quality_profile_id = _as_int(payload.get('qualityProfileId'), config.default_quality_profile or 0)
if not root_folder or not quality_profile_id:
return {'success': False, 'status': 'missing_config', 'message': 'missing_defaults'}
request_payload = {
**payload,
'title': title,
'tvdbId': tvdb_id,
'rootFolderPath': root_folder,
'qualityProfileId': quality_profile_id,
'monitored': _as_bool(payload.get('monitored'), True),
'seasonFolder': _as_bool(payload.get('seasonFolder'), True),
'searchForMissingEpisodes': _as_bool(payload.get('searchForMissingEpisodes'), config.search_on_add),
'addOptions': {
'searchForMissingEpisodes': _as_bool(payload.get('searchForMissingEpisodes'), config.search_on_add),
},
}
try:
result = self._request('POST', '/api/v3/series', json=request_payload)
except RuntimeError as exc:
return {'success': False, 'status': 'error', 'message': redact_message(str(exc))}
return {
'success': True,
'status': 'connected',
'message': 'series_added',
'data': result if isinstance(result, dict) else {},
}
@staticmethod
def _map_series(raw: dict[str, Any]) -> dict[str, Any]:
return {
'id': _as_int(raw.get('id')),
'tvdbId': _as_int(raw.get('tvdbId')),
'title': str(raw.get('title', '')),
'year': _as_int(raw.get('year')),
'overview': str(raw.get('overview', '')),
'status': str(raw.get('status', 'unknown')),
'remotePoster': str(raw.get('remotePoster', '')),
'existing': bool(raw.get('id') and raw.get('path')),
'monitored': _as_bool(raw.get('monitored'), False),
'seasons': raw.get('seasons') if isinstance(raw.get('seasons'), list) else [],
}
@staticmethod
def _map_queue_item(raw: dict[str, Any]) -> dict[str, Any]:
return {
'id': raw.get('id'),
'title': str(raw.get('title', raw.get('series', {}).get('title', 'unknown'))),
'status': str(raw.get('status', 'unknown')).lower(),
'trackedDownloadStatus': str(raw.get('trackedDownloadStatus', 'unknown')).lower(),
'size': raw.get('size'),
'sizeleft': raw.get('sizeleft'),
}
@staticmethod
def _empty_queue(status: str) -> dict[str, Any]:
return {
'success': False,
'status': status,
'message': status,
'total': 0,
'downloading': 0,
'queued': 0,
'failed': 0,
'stalled': 0,
'items': [],
}
+384
View File
@@ -0,0 +1,384 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _TautulliConfig:
url: str
api_key: str
verify_tls: bool
timeout_seconds: float
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_int(value: Any, default: int = 0) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return default
def _safe_url() -> str:
value = settings.apis.get('TAUTULLI_URL')
return str(value).rstrip('/') if value else ''
def _mask_ip(value: Any) -> str:
text = str(value or '')
parts = text.split('.')
if len(parts) == 4:
return f'{parts[0]}.{parts[1]}.{parts[2]}.x'
return '[masked]' if text else ''
def _list_from_data(data: Any, *keys: str) -> list[dict[str, Any]]:
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(data, dict):
for key in keys:
value = data.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
value = data.get('data')
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
def _title(raw: dict[str, Any]) -> str:
title = str(raw.get('full_title') or raw.get('title') or raw.get('media_title') or 'unknown')
parent = raw.get('grandparent_title') or raw.get('grandparentTitle')
return f'{parent} - {title}' if parent and str(parent) not in title else title
class TautulliConnector(BaseConnector):
name = 'Tautulli'
def is_configured(self) -> bool:
return bool(settings.apis.get('TAUTULLI_URL') and settings.apis.get('TAUTULLI_API_KEY'))
def _config(self) -> _TautulliConfig | None:
if not self.is_configured():
return None
return _TautulliConfig(
url=str(settings.apis['TAUTULLI_URL']).rstrip('/'),
api_key=str(settings.apis['TAUTULLI_API_KEY']),
verify_tls=_as_bool(settings.apis.get('TAUTULLI_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('TAUTULLI_TIMEOUT_SECONDS'), 10.0),
)
def _command(self, cmd: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
config = self._config()
if not config:
raise RuntimeError('missing_config')
query = {'apikey': config.api_key, 'cmd': cmd, **(params or {})}
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.get(f'{config.url}/api/v2', params=query, headers={'Accept': 'application/json'})
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Tautulli API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
payload = response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
response_meta = payload.get('response') if isinstance(payload, dict) else {}
if isinstance(response_meta, dict) and str(response_meta.get('result', '')).lower() == 'error':
message = redact_message(str(response_meta.get('message') or 'tautulli_error'))
if 'api' in message.lower() and 'key' in message.lower():
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
self.set_error(message)
raise RuntimeError(message)
return payload
@staticmethod
def _data(payload: dict[str, Any]) -> Any:
response = payload.get('response') if isinstance(payload, dict) else {}
return response.get('data') if isinstance(response, dict) else None
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
data = self._data(self._command('get_server_info'))
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
return {'status': 'auth_failed', 'reason': reason}
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': reason}
return {
'status': 'connected',
'tautulliVersion': str((data or {}).get('tautulli_version') or 'unknown') if isinstance(data, dict) else 'unknown',
'plexServerName': str((data or {}).get('pms_name') or '') if isinstance(data, dict) else '',
}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': '', 'required_env_vars': ['TAUTULLI_URL', 'TAUTULLI_API_KEY']}
result = self.test_connection()
status = str(result.get('status', 'error'))
return {
'success': status == 'connected',
'status': status,
'message': 'Tautulli connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))),
'url': _safe_url(),
'tautulliVersion': str(result.get('tautulliVersion', 'unknown')),
'plexServerName': str(result.get('plexServerName', '')),
}
def status(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'data': {}}
try:
data = self._data(self._command('status'))
except RuntimeError as exc:
return self._error_payload(exc, data={})
return {'success': True, 'status': 'ok', 'message': 'status_ok', 'data': data or {}}
def activity(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'streamCount': 0, 'sessions': []}
try:
data = self._data(self._command('get_activity'))
except RuntimeError as exc:
return self._error_payload(exc, streamCount=0, sessions=[])
records = _list_from_data(data, 'sessions')
sessions = [self._map_session(item) for item in records]
return {
'success': True,
'status': 'ok',
'message': 'activity_ok',
'streamCount': _as_int((data or {}).get('stream_count'), len(sessions)) if isinstance(data, dict) else len(sessions),
'lanBandwidth': _as_int((data or {}).get('lan_bandwidth')) if isinstance(data, dict) else 0,
'wanBandwidth': _as_int((data or {}).get('wan_bandwidth')) if isinstance(data, dict) else 0,
'totalBandwidth': _as_int((data or {}).get('total_bandwidth')) if isinstance(data, dict) else sum(item['bandwidth'] for item in sessions),
'sessions': sessions,
'data': {'sessions': sessions},
}
def history(self, limit: int = 25) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
data = self._data(self._command('get_history', {'length': limit}))
except RuntimeError as exc:
return self._error_payload(exc, total=0, items=[])
items = [self._map_history(item) for item in _list_from_data(data, 'data', 'history')]
return {'success': True, 'status': 'ok', 'message': 'history_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def users(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
data = self._data(self._command('get_users'))
except RuntimeError as exc:
return self._error_payload(exc, total=0, items=[])
items = [self._map_user(item) for item in _list_from_data(data, 'users')]
return {'success': True, 'status': 'ok', 'message': 'users_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def libraries(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
data = self._data(self._command('get_libraries'))
except RuntimeError as exc:
return self._error_payload(exc, total=0, items=[])
items = [self._map_library(item) for item in _list_from_data(data, 'libraries')]
return {'success': True, 'status': 'ok', 'message': 'libraries_ok', 'total': len(items), 'items': items, 'libraries': items, 'data': {'items': items}}
def recently_added(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []}
try:
data = self._data(self._command('get_recently_added', {'count': 25}))
except RuntimeError as exc:
return self._error_payload(exc, total=0, items=[])
items = [self._map_recent(item) for item in _list_from_data(data, 'recently_added', 'items')]
return {'success': True, 'status': 'ok', 'message': 'recently_added_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def watch_stats(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'totalPlays': 0, 'totalDuration': 0, 'topUsers': [], 'topLibraries': [], 'topPlatforms': [], 'playsByDay': []}
try:
by_day = [self._map_day(item) for item in _list_from_data(self._data(self._command('get_plays_by_date')), 'data')]
top_users = [self._map_top_user(item) for item in _list_from_data(self._data(self._command('get_plays_by_top_10_users')), 'data')]
top_platforms = [self._map_top_platform(item) for item in _list_from_data(self._data(self._command('get_plays_by_top_10_platforms')), 'data')]
except RuntimeError as exc:
return self._error_payload(exc, totalPlays=0, totalDuration=0, topUsers=[], topLibraries=[], topPlatforms=[], playsByDay=[])
total_plays = sum(_as_int(item.get('plays')) for item in by_day) or sum(_as_int(item.get('plays')) for item in top_users)
total_duration = sum(_as_int(item.get('duration')) for item in by_day) or sum(_as_int(item.get('duration')) for item in top_users)
return {
'success': True,
'status': 'ok',
'message': 'watch_stats_ok',
'totalPlays': total_plays,
'totalDuration': total_duration,
'topUsers': top_users,
'topLibraries': [],
'topPlatforms': top_platforms,
'playsByDay': by_day,
'data': {'topUsers': top_users, 'topPlatforms': top_platforms, 'playsByDay': by_day},
}
def stream_summary(self) -> dict[str, Any]:
activity = self.activity()
sessions = activity.get('sessions', []) if isinstance(activity, dict) else []
return {
'success': bool(activity.get('success')),
'status': activity.get('status', 'unreachable'),
'message': activity.get('message', 'stream_summary'),
'activeStreams': len(sessions),
'directPlay': sum(1 for item in sessions if item.get('transcodeDecision') == 'directplay'),
'directStream': sum(1 for item in sessions if item.get('transcodeDecision') == 'directstream'),
'transcoding': sum(1 for item in sessions if item.get('transcodeDecision') == 'transcode'),
'lanStreams': sum(1 for item in sessions if item.get('location') == 'lan'),
'wanStreams': sum(1 for item in sessions if item.get('location') == 'wan'),
'bandwidth': sum(_as_int(item.get('bandwidth')) for item in sessions),
'data': {'sessions': sessions},
}
def plex_info(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
try:
data = self._data(self._command('get_server_info'))
except RuntimeError as exc:
return self._error_payload(exc)
data = data if isinstance(data, dict) else {}
machine = str(data.get('pms_identifier') or data.get('machine_identifier') or '')
return {
'success': True,
'status': 'ok',
'message': 'plex_info_ok',
'name': str(data.get('pms_name') or data.get('name') or ''),
'version': str(data.get('pms_version') or data.get('version') or 'unknown'),
'platform': str(data.get('pms_platform') or data.get('platform') or ''),
'machineIdentifier': f'{machine[:8]}...' if machine else '',
'plexPass': bool(data.get('pms_is_plexpass', data.get('plexpass', False))),
'data': {},
}
@staticmethod
def _map_session(raw: dict[str, Any]) -> dict[str, Any]:
decision = str(raw.get('transcode_decision') or raw.get('transcodeDecision') or 'direct play').lower().replace(' ', '')
if decision == 'copy':
decision = 'directstream'
return {
'sessionKey': str(raw.get('session_key') or raw.get('sessionKey') or ''),
'user': str(raw.get('friendly_name') or raw.get('username') or raw.get('user') or 'unknown'),
'title': _title(raw),
'mediaType': str(raw.get('media_type') or raw.get('type') or ''),
'player': str(raw.get('player') or ''),
'platform': str(raw.get('platform') or ''),
'state': str(raw.get('state') or ''),
'progress': _as_int(raw.get('progress_percent') or raw.get('progress')),
'qualityProfile': str(raw.get('quality_profile') or raw.get('qualityProfile') or ''),
'transcodeDecision': decision,
'videoDecision': str(raw.get('video_decision') or raw.get('videoDecision') or decision).lower().replace(' ', ''),
'audioDecision': str(raw.get('audio_decision') or raw.get('audioDecision') or '').lower().replace(' ', ''),
'bandwidth': _as_int(raw.get('bandwidth')),
'ipAddress': _mask_ip(raw.get('ip_address') or raw.get('ipAddress')),
'location': str(raw.get('location') or '').lower(),
}
@staticmethod
def _map_history(raw: dict[str, Any]) -> dict[str, Any]:
return {
'rowId': raw.get('row_id') or raw.get('rowId'),
'user': str(raw.get('friendly_name') or raw.get('username') or 'unknown'),
'title': _title(raw),
'mediaType': str(raw.get('media_type') or ''),
'watchedStatus': raw.get('watched_status'),
'date': raw.get('date'),
'duration': _as_int(raw.get('duration')),
'platform': str(raw.get('platform') or ''),
'transcodeDecision': str(raw.get('transcode_decision') or raw.get('transcodeDecision') or '').lower().replace(' ', ''),
}
@staticmethod
def _map_user(raw: dict[str, Any]) -> dict[str, Any]:
return {
'userId': str(raw.get('user_id') or raw.get('userId') or ''),
'username': str(raw.get('username') or ''),
'friendlyName': str(raw.get('friendly_name') or raw.get('friendlyName') or raw.get('username') or ''),
'lastSeen': raw.get('last_seen') or raw.get('lastSeen'),
'plays': _as_int(raw.get('plays')),
'duration': _as_int(raw.get('duration')),
}
@staticmethod
def _map_library(raw: dict[str, Any]) -> dict[str, Any]:
return {
'sectionId': str(raw.get('section_id') or raw.get('sectionId') or ''),
'name': str(raw.get('section_name') or raw.get('name') or ''),
'type': str(raw.get('section_type') or raw.get('type') or ''),
'count': _as_int(raw.get('count')),
'parentCount': _as_int(raw.get('parent_count') or raw.get('parentCount')),
'childCount': _as_int(raw.get('child_count') or raw.get('childCount')),
'lastAccessed': raw.get('last_accessed') or raw.get('lastAccessed'),
}
@staticmethod
def _map_recent(raw: dict[str, Any]) -> dict[str, Any]:
return {
'title': _title(raw),
'mediaType': str(raw.get('media_type') or raw.get('type') or ''),
'library': str(raw.get('section_name') or raw.get('library') or ''),
'addedAt': raw.get('added_at') or raw.get('addedAt'),
'year': raw.get('year'),
'thumb': raw.get('thumb'),
}
@staticmethod
def _map_day(raw: dict[str, Any]) -> dict[str, Any]:
return {'date': raw.get('date'), 'plays': _as_int(raw.get('plays')), 'duration': _as_int(raw.get('duration'))}
@staticmethod
def _map_top_user(raw: dict[str, Any]) -> dict[str, Any]:
return {'name': str(raw.get('friendly_name') or raw.get('username') or raw.get('user') or 'unknown'), 'plays': _as_int(raw.get('plays')), 'duration': _as_int(raw.get('duration'))}
@staticmethod
def _map_top_platform(raw: dict[str, Any]) -> dict[str, Any]:
return {'platform': str(raw.get('platform') or 'unknown'), 'plays': _as_int(raw.get('plays')), 'duration': _as_int(raw.get('duration'))}
@staticmethod
def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]:
reason = redact_message(str(exc))
status = 'auth_failed' if reason.startswith('auth_failed') else ('missing_config' if reason == 'missing_config' else 'unreachable')
return {'success': False, 'status': status, 'message': reason, **extra}
+742
View File
@@ -0,0 +1,742 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Any
import httpx
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}
@dataclass
class _ConnectorConfig:
url: str
api_key: str
verify_tls: bool
def _as_bool(value: Any) -> bool | None:
if isinstance(value, bool):
return value
if value is None:
return None
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {'true', '1', 'yes', 'on', 'running', 'started'}:
return True
if normalized in {'false', '0', 'off', 'stopped'}:
return False
return None
def _as_int(value: Any, default: int = 0) -> int:
if value is None:
return default
if isinstance(value, bool):
return int(value)
if isinstance(value, int | float):
return int(value)
if isinstance(value, str):
cleaned = value.strip().strip('%')
if not cleaned:
return default
numeric = re.sub(r'[^0-9.+-]', '', cleaned)
if not numeric:
return default
try:
return int(float(numeric))
except ValueError:
return default
return default
def _as_float(value: Any) -> float | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, int | float):
return float(value)
if isinstance(value, str):
cleaned = value.strip().strip('%')
if not cleaned:
return None
numeric = re.sub(r'[^0-9.+-]', '', cleaned)
if not numeric:
return None
try:
return float(numeric)
except ValueError:
return None
return None
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:
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)
def _average_cpu_usage(cpus: Any) -> int | None:
if not isinstance(cpus, list) or not cpus:
return None
idle_values: list[float] = []
active_values: list[float] = []
active_fields = ('percentUser', 'percentSystem', 'percentNice', 'percentIrq')
for raw_cpu in cpus:
if not isinstance(raw_cpu, dict):
continue
idle = _as_float(raw_cpu.get('percentIdle'))
if idle is not None:
idle_values.append(max(0.0, min(100.0, idle)))
continue
active_total = 0.0
has_active = False
for field in active_fields:
value = _as_float(raw_cpu.get(field))
if value is None:
continue
active_total += value
has_active = True
if has_active:
active_values.append(active_total)
if idle_values:
return _clamp_percent(100 - (sum(idle_values) / len(idle_values)))
if active_values:
return _clamp_percent(sum(active_values) / len(active_values))
return None
def _storage_value_to_gib(value: Any) -> int:
raw = _as_int(value, 0)
if raw <= 0:
return 0
if raw >= 1_048_576:
return int(raw / 1024 / 1024)
return raw
def _get_nested(data: dict[str, Any], path: str) -> Any:
cursor: Any = data
for part in path.split('.'):
if not isinstance(cursor, dict):
return None
cursor = cursor.get(part)
if cursor is None:
return None
return cursor
def _first_value(data: dict[str, Any], paths: tuple[str, ...]) -> Any:
for path in paths:
value = _get_nested(data, path)
if value is not None:
return value
return None
def _normalize_status(value: Any, allowed: tuple[str, ...], default: str = "stopped") -> str:
if value is None:
return default
candidate = str(value).strip().lower()
if not candidate:
return default
if candidate in {"1", "true", "started", "running", "online", "healthy"}:
return allowed[0]
if candidate in {"0", "false", "stopped", "offline", "unhealthy", "critical", "invalid"}:
return allowed[1] if len(allowed) > 1 else default
return candidate if candidate in allowed else default
def _safe_disk_warning_count(disks: list[dict[str, Any]]) -> int:
total = 0
for raw_disk in disks:
if not isinstance(raw_disk, dict):
continue
warning = raw_disk.get('warning')
if warning is True:
total += 1
continue
if raw_disk.get('critical') is not None:
total += 1
continue
status = str(raw_disk.get('status', raw_disk.get('state', ''))).strip().lower()
if status in {'warning', 'degraded', 'failed', 'offline', 'faulty', 'disk_dsbl', 'disk_invalid'}:
total += 1
if _as_bool(raw_disk.get('healthy')) is False:
total += 1
return total
def _trusted_https_redirect(response: httpx.Response) -> httpx.URL | None:
if response.status_code not in _UNRAID_REDIRECT_STATUSES:
return None
location = response.headers.get('location')
if not location:
return None
redirected = response.url.join(location)
if redirected.scheme != 'https' or redirected.host != response.url.host:
return None
return redirected
class UnraidConnector(BaseConnector):
name = 'Unraid'
default_headers = {
'Content-Type': 'application/json',
'x-api-key': '',
}
def __init__(self):
super().__init__()
def is_configured(self):
return bool(settings.apis.get('UNRAID_URL') and settings.apis.get('UNRAID_API_KEY'))
def _config(self) -> _ConnectorConfig | None:
if not self.is_configured():
return None
return _ConnectorConfig(
url=str(settings.apis['UNRAID_URL']).rstrip('/'),
api_key=str(settings.apis['UNRAID_API_KEY']),
verify_tls=bool(settings.apis.get('UNRAID_VERIFY_TLS', settings.verify_tls)),
)
def _request(self, query: str) -> dict[str, Any]:
config = self._config()
if not config:
raise RuntimeError('missing_config')
headers = dict(self.default_headers)
headers['x-api-key'] = config.api_key
url = f'{config.url}/graphql'
try:
self.clear_error()
with httpx.Client(timeout=_UNRAID_DEFAULT_TIMEOUT_SECONDS, verify=config.verify_tls) as client:
response = client.post(url, headers=headers, json={'query': query})
if response.status_code in _UNRAID_REDIRECT_STATUSES:
redirected = _trusted_https_redirect(response)
if redirected is None:
raise RuntimeError('Unraid API refused an untrusted redirect')
response = client.post(str(redirected), headers=headers, json={'query': query})
except (httpx.RequestError, ValueError) as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
message = 'unauthorized'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 500:
message = f'Unraid server error: HTTP {response.status_code}'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 400:
payload = response.text or ''
message = f'Unraid API error: HTTP {response.status_code} {payload[:256]}'
redacted = redact_message(message)
self.set_error(redacted)
raise RuntimeError(redacted)
try:
payload = response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if not isinstance(payload, dict):
message = 'Invalid GraphQL payload'
self.set_error(message)
raise RuntimeError(message)
if payload.get('errors'):
error_messages = [
str(err.get('message', 'unknown'))
for err in payload.get('errors') if isinstance(err, dict)
]
message = f'GraphQL error: {", ".join(error_messages[:2])}'
redacted = redact_message(message)
self.set_error(redacted)
raise RuntimeError(redacted)
data = payload.get('data')
if data is None:
message = 'No GraphQL data field'
self.set_error(message)
raise RuntimeError(message)
if not isinstance(data, dict):
message = 'Invalid GraphQL data'
self.set_error(message)
raise RuntimeError(message)
return data
def _query(self, queries: list[str]) -> tuple[dict[str, Any], str]:
last_error = ''
for query in queries:
try:
return self._request(query), 'ok'
except RuntimeError as exc:
last_error = str(exc)
if last_error == 'missing_config':
return {'error': 'missing_config'}, 'missing_config'
if 'VMs are not available' in last_error:
return {'error': last_error}, 'unavailable'
continue
return {'error': last_error or 'query_failed'}, 'error'
def test_connection(self):
data, status = self._query(['query { __typename }'])
if status == 'ok':
return {'status': 'connected'}
if status == 'missing_config':
return {'status': 'missing_config', 'reason': 'missing_config'}
reason = str(data.get('error', 'connection_failed'))
return {'status': 'error', 'reason': reason}
def get_server_info(self):
payload, status = self._query([
'query { info { os { platform distro release uptime } cpu { manufacturer brand cores threads } } }',
'query { serverInfo { hostname version uptime cpuPercent ramPercent } }',
'query { system { name version uptime cpuPercent ramPercent } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed'))}
uptime = _first_value(payload, ('info.os.uptime', 'serverInfo.uptime', 'system.uptime', 'info.uptime'))
hostname = _first_value(payload, ('serverInfo.hostname', 'system.hostname', 'info.hostname', 'serverInfo.name'))
version = _first_value(payload, ('info.os.release', 'serverInfo.version', 'system.version', 'info.version'))
cpu_brand = _first_value(payload, ('info.cpu.brand',))
cpu_cores = _first_value(payload, ('info.cpu.cores',))
cpu_threads = _first_value(payload, ('info.cpu.threads',))
return {
'status': 'ok',
'info': {
'hostname': str(hostname or 'unknown'),
'version': str(version or 'unknown'),
'uptime': str(uptime or 'unknown'),
'name': str(hostname or 'unraid'),
'cpu': {
'brand': cpu_brand,
'cores': _as_int(cpu_cores, 0) if cpu_cores is not None else None,
'threads': _as_int(cpu_threads, 0) if cpu_threads is not None else None,
},
},
}
def get_system_metrics(self):
payload, status = self._query([
(
'query { metrics { '
'cpu { cpus { percentIdle percentUser percentSystem percentNice percentIrq } } '
'memory { total used free available active } '
'} }'
),
])
if status != 'ok':
return {
'status': status,
'reason': str(payload.get('error', 'metrics_unavailable')),
'cpuUsage': None,
'ramUsage': None,
}
cpu_usage = _average_cpu_usage(_first_value(payload, ('metrics.cpu.cpus',)))
memory_total = _first_value(payload, ('metrics.memory.total',))
memory_available = _first_value(payload, ('metrics.memory.available',))
memory_used = _first_value(payload, ('metrics.memory.used',))
# Linux includes reclaimable cache (and often ZFS ARC) in ``used``.
# Prefer total - available so the dashboard reflects actual memory
# pressure rather than useful filesystem cache.
ram_uses_available = (
isinstance(memory_total, (int, float))
and isinstance(memory_available, (int, float))
and 0 <= memory_available <= memory_total
)
if ram_uses_available:
memory_used = memory_total - memory_available
ram_usage = _percent_from_values(memory_used, memory_total)
missing = []
if cpu_usage is None:
missing.append('cpu_unavailable')
if ram_usage is None:
missing.append('ram_unavailable')
memory = _first_value(payload, ('metrics.memory',))
return {
'status': 'ok' if not missing else 'partial',
'reason': ','.join(missing) if missing else None,
'cpuUsage': cpu_usage,
'ramUsage': ram_usage,
'sources': {
'cpu': 'metrics.cpu.cpus.percentIdle' if cpu_usage is not None else 'unavailable',
'ram': 'metrics.memory.available_total' if ram_usage is not None and ram_uses_available else ('metrics.memory.used_total' if ram_usage is not None else 'unavailable'),
},
'memory': memory if isinstance(memory, dict) else {},
}
def get_array_status(self):
payload, status = self._query([
'query { array { state } }',
'query { array { status started } }',
'query { system { array { status started } } }',
'query { arrayStatus started }',
'query { system { arrayStatus started } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed'))}
raw_status = _first_value(payload, ('array.state', 'array.status', 'arrayStatus', 'system.array.status', 'system.arrayStatus', 'state', 'status'))
started = _first_value(payload, ('array.started', 'system.array.started', 'started'))
if isinstance(started, bool):
raw_status = 'started' if started else 'stopped'
elif isinstance(started, str):
if started.lower() in {'true', '1', 'yes', 'running', 'started', 'online'}:
raw_status = 'started'
elif started.lower() in {'false', '0', 'no', 'stopped', 'offline'}:
raw_status = 'stopped'
normalized = _normalize_status(raw_status, ('started', 'stopped', 'maintenance'), default='maintenance')
return {'status': 'ok', 'arrayStatus': normalized}
def get_disk_warnings(self):
payload, status = self._query([
'query { array { disks { name status warning critical fsSize fsUsed fsFree type } } }',
'query { disks { name smartStatus temperature isSpinning size } }',
'query { array { disks { name status healthy warning } } }',
'query { system { disks { name status healthy warning } } }',
'query { disks { name status healthy warning } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'disks': [], 'diskWarnings': 0}
disks_raw = _first_value(payload, ('array.disks', 'system.disks', 'disks')) or []
if not isinstance(disks_raw, list):
disks_raw = []
disks: list[dict[str, Any]] = []
for raw_disk in disks_raw:
if not isinstance(raw_disk, dict):
continue
status_value = str(raw_disk.get('status') or raw_disk.get('smartStatus') or 'unknown')
warning = raw_disk.get('warning') is not None or raw_disk.get('critical') is not None
healthy = _as_bool(raw_disk.get('healthy', None))
if healthy is None:
healthy = not warning and status_value.lower() in {'ok', 'disk_ok', 'unknown'}
used_gib = _storage_value_to_gib(raw_disk.get('fsUsed', raw_disk.get('used')))
size_gib = _storage_value_to_gib(raw_disk.get('fsSize', raw_disk.get('size')))
free_gib = _storage_value_to_gib(raw_disk.get('fsFree', raw_disk.get('free')))
if size_gib <= 0 and used_gib > 0 and free_gib > 0:
size_gib = used_gib + free_gib
disks.append({
'name': str(raw_disk.get('name', 'unknown')),
'status': status_value,
'healthy': healthy,
'type': raw_disk.get('type'),
'usedGiB': used_gib if used_gib > 0 else None,
'sizeGiB': size_gib if size_gib > 0 else None,
'freeGiB': free_gib if free_gib > 0 else None,
'temperature': raw_disk.get('temperature'),
})
return {'status': 'ok', 'disks': disks, 'diskWarnings': _safe_disk_warning_count(disks_raw)}
def get_cache_status(self):
payload, status = self._query([
'query { array { caches { name fsSize fsUsed fsFree size } } }',
'query { cache { usagePercent usedGiB sizeGiB } }',
'query { cache { used size } }',
'query { system { cache { usagePercent usedGiB sizeGiB used size } } }',
'query { cacheUsage }',
'query { cachePercent }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'cacheUsage': 0, 'pools': []}
cache_percent = _first_value(payload, ('cache.usagePercent', 'system.cache.usagePercent', 'cacheUsage', 'cachePercent'))
pools: list[dict[str, Any]] = []
caches = _first_value(payload, ('array.caches',))
if isinstance(caches, list):
for cache in caches:
if not isinstance(cache, dict):
continue
used_gib = _storage_value_to_gib(cache.get('fsUsed', cache.get('used', cache.get('usedGiB'))))
size_gib = _storage_value_to_gib(cache.get('fsSize', cache.get('size', cache.get('sizeGiB'))))
free_gib = _storage_value_to_gib(cache.get('fsFree', cache.get('free', cache.get('freeGiB'))))
if size_gib <= 0 and used_gib > 0 and free_gib > 0:
size_gib = used_gib + free_gib
status_value = str(cache.get('status') or cache.get('smartStatus') or 'unknown')
warning = cache.get('warning') is not None or cache.get('critical') is not None
healthy = _as_bool(cache.get('healthy', None))
if healthy is None:
healthy = not warning and status_value.lower() in {'ok', 'disk_ok', 'healthy', 'unknown'}
usage_percent = _as_int(cache.get('usagePercent'), -1)
if usage_percent < 0:
usage_percent = _percent_from_values(used_gib, size_gib)
pools.append({
'name': str(cache.get('name', 'cache')),
'status': status_value,
'healthy': healthy,
'type': cache.get('type'),
'usedGiB': used_gib if used_gib > 0 else None,
'sizeGiB': size_gib if size_gib > 0 else None,
'freeGiB': free_gib if free_gib > 0 else None,
'usagePercent': usage_percent,
})
if cache_percent is None:
if isinstance(caches, list):
used_total = 0
size_total = 0
for cache in caches:
if not isinstance(cache, dict):
continue
used_total += _as_int(cache.get('fsUsed', cache.get('used')), 0)
size_total += _as_int(cache.get('fsSize', cache.get('size')), 0)
if size_total > 0:
cache_percent = int((used_total / size_total) * 100)
if cache_percent is None:
used = _first_value(payload, ('cache.used', 'cache.usedGiB', 'system.cache.used', 'system.cache.usedGiB'))
total = _first_value(payload, ('cache.size', 'cache.sizeGiB', 'system.cache.size', 'system.cache.sizeGiB'))
if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
cache_percent = int((float(used) / float(total)) * 100)
if isinstance(cache_percent, bool):
cache_percent = 100 if cache_percent else 0
usage = _as_int(cache_percent, 0)
return {'status': 'ok', 'cacheUsage': usage, 'pools': pools}
def get_parity_status(self):
payload, status = self._query([
(
'query { array { '
'parityCheckStatus { progress speed errors status paused running correcting } '
'parities { name status warning critical } '
'} }'
),
'query { array { parities { name status warning critical } } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'parityStatus': 'invalid'}
check = _first_value(payload, ('array.parityCheckStatus',))
check = check if isinstance(check, dict) else {}
check_status = str(check.get('status') or '').strip().lower()
paused = _as_bool(check.get('paused')) is True
running = (
not paused
and (
_as_bool(check.get('running')) is True
or check_status in {'running', 'checking', 'resyncing'}
)
)
if running or paused or check_status in {'running', 'checking', 'paused', 'resyncing'}:
parity_status = 'checking'
else:
parities = _first_value(payload, ('array.parities',))
parities = parities if isinstance(parities, list) else []
healthy_statuses = {'disk_ok', 'ok', 'valid', 'healthy', 'online'}
parity_status = 'valid' if parities and all(
isinstance(disk, dict)
and not disk.get('warning')
and not disk.get('critical')
and str(disk.get('status') or '').strip().lower() in healthy_statuses
for disk in parities
) else 'invalid'
return {
'status': 'ok',
'parityStatus': parity_status,
'checkStatus': check_status or 'idle',
'running': running,
'paused': paused,
'correcting': _as_bool(check.get('correcting')) is True,
'progress': check.get('progress'),
'speed': check.get('speed'),
'errors': _as_int(check.get('errors'), 0),
}
def get_docker_service_status(self):
payload, status = self._query([
'query { docker { containers { id names image state status } networks { id name } } }',
'query { docker { running status } }',
'query { dockerService { running status } }',
'query { system { docker { running status } } }',
'query { dockerStatus }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'running': False}
docker_containers = _first_value(payload, ('docker.containers',))
raw_running = _first_value(payload, ('docker.running', 'dockerService.running', 'system.docker.running'))
if raw_running is None and isinstance(docker_containers, list):
raw_running = True
if isinstance(raw_running, str) and raw_running.lower() in {'running', 'started', 'online'}:
raw_running = True
running = bool(_as_bool(raw_running) or str(raw_running).lower() in {'running', 'started', 'online'})
return {
'status': 'ok',
'running': running,
'serviceStatus': str(_first_value(payload, ('docker.status', 'dockerService.status', 'system.docker.status', 'status')) or ('running' if running else 'stopped')),
'containers': len(docker_containers) if isinstance(docker_containers, list) else None,
}
def get_vm_summary(self):
payload, status = self._query([
'query { vms { domains { id uuid name state } } }',
'query { vms { id name state status } }',
'query { virtualMachines { id name state status } }',
'query { system { vms { id name state status } } }',
])
if status != 'ok':
reason = str(payload.get('error', 'connection_failed'))
if 'VMs are not available' in reason:
return {
'status': 'api_unavailable',
'reason': (
'De Unraid API gaf geen VM-toegang terug. VM Manager kan nog steeds actief zijn; '
'controleer of de ingestelde API-sleutel vm:read (of vm:*) heeft en of deze '
'Unraid API-versie het VM-schema beschikbaar stelt.'
),
'vms': [],
}
return {'status': status, 'reason': reason, 'vms': []}
raw_vms = _first_value(payload, ('vms.domains', 'vms', 'virtualMachines', 'system.vms'))
if not isinstance(raw_vms, list):
raw_vms = []
vms = []
for raw_vm in raw_vms:
if not isinstance(raw_vm, dict):
continue
vms.append({
'id': str(raw_vm.get('id', raw_vm.get('uuid', raw_vm.get('name', '')))),
'name': str(raw_vm.get('name', 'unknown')),
'state': str(raw_vm.get('state', raw_vm.get('status', 'unknown'))),
})
return {'status': 'ok', 'vms': vms}
def mutate_vm(self, vm_id: str, action: str) -> dict[str, Any]:
supported_actions = {'start', 'pause', 'resume'}
normalized_action = str(action).strip().lower()
normalized_vm_id = str(vm_id).strip()
if normalized_action not in supported_actions:
return {
'success': False,
'status': 'blocked',
'message': 'unsupported_or_blocked_vm_action',
'action': normalized_action,
}
if not normalized_vm_id:
return {
'success': False,
'status': 'invalid_target',
'message': 'vm_id_required',
'action': normalized_action,
}
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'action': normalized_action,
}
# json.dumps produces a valid escaped GraphQL string literal without
# interpolating an operator-controlled identifier as GraphQL syntax.
vm_id_literal = json.dumps(normalized_vm_id)
query = f'mutation AppOpsVmAction {{ vm {{ {normalized_action}(id: {vm_id_literal}) }} }}'
try:
payload = self._request(query)
except RuntimeError as exc:
reason = str(exc)
if reason == 'unauthorized':
reason = 'Unraid API key needs vm:update (or vm:*) permission.'
return {
'success': False,
'status': 'permission_denied' if 'unauthorized' in reason.lower() else 'error',
'message': reason,
'action': normalized_action,
}
changed = _first_value(payload, (f'vm.{normalized_action}',))
return {
'success': changed is True,
'status': 'ok' if changed is True else 'failed',
'message': f'vm_{normalized_action}_accepted' if changed is True else f'vm_{normalized_action}_rejected',
'action': normalized_action,
}
def get_share_summary(self):
payload, status = self._query([
'query { shares { name free used size } }',
'query { shares { name usedGiB sizeGiB critical } }',
'query { system { shares { name usedGiB sizeGiB critical } } }',
'query { shareSummary { shares { name usedGiB sizeGiB critical } } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'shares': []}
raw_shares = _first_value(payload, ('shares', 'system.shares', 'shareSummary.shares'))
if not isinstance(raw_shares, list):
raw_shares = []
shares = []
for share in raw_shares:
if not isinstance(share, dict):
continue
used = _as_int(share.get('usedGiB'), -1)
size = _as_int(share.get('sizeGiB'), -1)
if used < 0:
used = _storage_value_to_gib(share.get('used'))
if size < 0:
size = _storage_value_to_gib(share.get('size'))
if size <= 0 and share.get('free') is not None:
size = used + _storage_value_to_gib(share.get('free'))
shares.append({
'name': str(share.get('name', 'unknown')),
'usedGiB': used,
'sizeGiB': size,
'critical': bool(_as_bool(share.get('critical', False))),
})
return {'status': 'ok', 'shares': shares}
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _WidefrogConfig:
url: str
api_key: str | None
verify_tls: bool
timeout_seconds: float
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _safe_url() -> str:
value = settings.apis.get('WIDEFROG_URL')
if not value:
return ''
parsed = urlparse(str(value).rstrip('/'))
if not parsed.scheme or not parsed.netloc:
return str(value).rstrip('/')
return f'{parsed.scheme}://{parsed.netloc}'
def _port() -> int:
parsed = urlparse(_safe_url())
return parsed.port or 1207
class WidefrogConnector(BaseConnector):
name = 'Widefrog'
def is_configured(self) -> bool:
return bool(settings.apis.get('WIDEFROG_URL'))
def _config(self) -> _WidefrogConfig | None:
if not self.is_configured():
return None
return _WidefrogConfig(
url=str(settings.apis['WIDEFROG_URL']).rstrip('/'),
api_key=str(settings.apis['WIDEFROG_API_KEY']) if settings.apis.get('WIDEFROG_API_KEY') else None,
verify_tls=bool(settings.apis.get('WIDEFROG_VERIFY_TLS', False)),
timeout_seconds=_as_float(settings.apis.get('WIDEFROG_TIMEOUT_SECONDS'), 10.0),
)
def _request_json(self, method: str, path: str, **kwargs: Any) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
headers = dict(kwargs.pop('headers', {}) or {})
if config.api_key:
headers['X-Api-Key'] = config.api_key
try:
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.request(method=method, url=f'{config.url}{path}', headers=headers, **kwargs)
except (httpx.TimeoutException, httpx.ConnectError, httpx.RequestError, ValueError) as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code == 404:
raise RuntimeError('unsupported_endpoint')
if response.status_code in {401, 403}:
raise RuntimeError('unauthorized')
if response.status_code >= 500:
raise RuntimeError(f'Widefrog server error: HTTP {response.status_code}')
if response.status_code >= 400:
raise RuntimeError(redact_message(f'Widefrog API error: HTTP {response.status_code} {(response.text or "")[:256]}'))
if response.status_code == 204:
return {}
content_type = response.headers.get('content-type', '')
if 'json' not in content_type and response.text:
return {'message': response.text[:256], 'status': 'ok'}
try:
return response.json() if response.text else {}
except ValueError as exc:
raise RuntimeError(redact_message(str(exc)))
def _first_supported(self, method: str, paths: list[str], **kwargs: Any) -> tuple[Any | None, str | None, str | None]:
last_error: str | None = None
for path in paths:
try:
return self._request_json(method, path, **kwargs), path, None
except RuntimeError as exc:
reason = str(exc)
if reason == 'unsupported_endpoint':
last_error = reason
continue
return None, path, redact_message(reason)
return None, None, last_error or 'unsupported_endpoint'
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
payload, _path, error = self._first_supported('GET', ['/api/health', '/health', '/'])
if error:
return {'status': 'unreachable' if error != 'unsupported_endpoint' else 'error', 'reason': redact_message(error)}
version = payload.get('version') if isinstance(payload, dict) else None
return {'status': 'connected', 'version': str(version or 'unknown')}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': '', 'port': 1207, 'version': 'unknown', 'checks': [], 'required_env_vars': ['WIDEFROG_URL']}
payload, _path, error = self._first_supported('GET', ['/api/health', '/health', '/'])
if error:
return {'success': False, 'status': 'unreachable' if error != 'unsupported_endpoint' else 'error', 'message': redact_message(error), 'url': _safe_url(), 'port': _port(), 'version': 'unknown', 'checks': []}
data = payload if isinstance(payload, dict) else {}
return {'success': True, 'status': 'connected', 'message': 'Widefrog connector ready.', 'url': _safe_url(), 'port': _port(), 'version': str(data.get('version', 'unknown')), 'checks': data.get('checks', []) if isinstance(data.get('checks'), list) else []}
def queue_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._empty_queue('missing_config')
payload, _path, error = self._first_supported('GET', ['/api/queue', '/api/jobs'])
if error:
result = self._empty_queue('unavailable')
result['message'] = redact_message(error)
return result
items = _extract_items(payload)
return _queue_summary(items)
def failed_jobs(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'failed': 0, 'items': []}
payload, _path, error = self._first_supported('GET', ['/api/failed', '/api/jobs/failed', '/api/queue/failed', '/api/jobs'])
if error:
return {'success': False, 'status': 'unavailable', 'message': redact_message(error), 'failed': 0, 'items': []}
items = [item for item in _extract_items(payload) if str(item.get('status', '')).lower() == 'failed']
if not items:
items = _extract_items(payload)
return {'success': True, 'status': 'ok', 'failed': len(items), 'items': items}
def activity(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'recent': [], 'last_event': None}
payload, _path, error = self._first_supported('GET', ['/api/activity', '/api/events', '/api/history'])
if error:
return {'success': False, 'status': 'unavailable', 'message': redact_message(error), 'recent': [], 'last_event': None}
recent = _extract_items(payload, key='recent')
return {'success': True, 'status': 'ok', 'recent': recent, 'last_event': recent[0] if recent else None}
def download_status(self) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'active': 0, 'speed': None, 'processing': 0, 'items': []}
payload, _path, error = self._first_supported('GET', ['/api/download-status', '/api/downloads', '/api/queue/downloads'])
if error:
return {'success': False, 'status': 'unavailable', 'message': redact_message(error), 'active': 0, 'speed': None, 'processing': 0, 'items': []}
data = payload if isinstance(payload, dict) else {}
items = _extract_items(payload)
return {'success': True, 'status': 'ok', 'active': int(data.get('active', len(items)) or 0), 'speed': data.get('speed'), 'processing': int(data.get('processing', 0) or 0), 'items': items}
def retry_failed(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
result, _path, error = self._first_supported('POST', ['/api/retry-failed', '/api/jobs/retry-failed', '/api/failed/retry'], json=payload or {})
if error:
return {'success': False, 'status': 'unsupported' if error == 'unsupported_endpoint' else 'error', 'message': redact_message(error)}
return {'success': True, 'status': 'ok', 'message': 'retry_started', 'data': result if isinstance(result, dict) else {}}
def scan_library(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
if not self.is_configured():
return {'success': False, 'status': 'missing_config', 'message': 'missing_config'}
result, _path, error = self._first_supported('POST', ['/api/scan-library', '/api/library/scan', '/api/scan'], json=payload or {})
if error:
return {'success': False, 'status': 'unsupported' if error == 'unsupported_endpoint' else 'error', 'message': redact_message(error)}
return {'success': True, 'status': 'ok', 'message': 'scan_started', 'data': result if isinstance(result, dict) else {}}
@staticmethod
def _empty_queue(status: str) -> dict[str, Any]:
return {'success': False, 'status': status, 'message': status, 'total': 0, 'active': 0, 'queued': 0, 'completed': 0, 'failed': 0, 'items': []}
def _extract_items(payload: Any, key: str = 'items') -> list[dict[str, Any]]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
value = payload.get(key) or payload.get('jobs') or payload.get('queue') or []
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
def _queue_summary(items: list[dict[str, Any]]) -> dict[str, Any]:
normalized = [{**item, 'status': str(item.get('status', 'unknown')).lower()} for item in items]
return {
'success': True,
'status': 'ok',
'total': len(normalized),
'active': sum(1 for item in normalized if item['status'] in {'active', 'running', 'processing'}),
'queued': sum(1 for item in normalized if item['status'] in {'queued', 'pending'}),
'completed': sum(1 for item in normalized if item['status'] in {'completed', 'done', 'success'}),
'failed': sum(1 for item in normalized if item['status'] in {'failed', 'error'}),
'items': normalized,
}