Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -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': []}
|
||||
@@ -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
|
||||
@@ -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': []}
|
||||
@@ -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}
|
||||
@@ -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 {}
|
||||
@@ -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}'
|
||||
@@ -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': [],
|
||||
}
|
||||
@@ -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': [],
|
||||
}
|
||||
@@ -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': [],
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user