Publish curated Unraid-AppOps-Gateway source from ba66029223865607deddc234e283c11127324eb8
This commit is contained in:
1 parent
820244dbb3
commit
8dd4b40252
52 files changed
+11814
-288
No files matched your search
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -23,12 +25,172 @@ class _NpmConfig:
|
||||
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'}
|
||||
class _NpmForgeRoutePermissionDenied(RuntimeError):
|
||||
"""Fixed marker for a failed route-only permission preflight."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("npm_forge_route_permission_denied")
|
||||
|
||||
|
||||
class _ForgeRouteNpmSession:
|
||||
"""Route-only transport pinned to one verified NPM endpoint/config snapshot."""
|
||||
|
||||
def __init__(self, connector: NginxProxyManagerConnector, config: _NpmConfig):
|
||||
self._connector = connector
|
||||
self._config_snapshot = config
|
||||
self._token: str | None = None
|
||||
self.verified_tls = config.verify_tls is True
|
||||
self.endpoint_sha256 = _npm_endpoint_sha256(config.url)
|
||||
|
||||
def _assert_current(self) -> None:
|
||||
try:
|
||||
current = self._connector._config()
|
||||
except Exception: # noqa: BLE001 - never surface config diagnostics.
|
||||
current = None
|
||||
if current != self._config_snapshot:
|
||||
raise RuntimeError("npm_configuration_changed") from None
|
||||
|
||||
def _login(self) -> str:
|
||||
if self._token:
|
||||
return self._token
|
||||
self._assert_current()
|
||||
config = self._config_snapshot
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=config.timeout_seconds,
|
||||
verify=True,
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
response = client.post(
|
||||
f"{config.url}/api/tokens",
|
||||
json={"identity": config.identity, "secret": config.secret},
|
||||
headers={"Accept": "application/json"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
except (httpx.RequestError, ValueError):
|
||||
raise RuntimeError("npm_request_failed") from None
|
||||
if type(response.status_code) is not int or response.status_code != 200:
|
||||
raise RuntimeError("npm_login_failed") from None
|
||||
try:
|
||||
payload = response.json()
|
||||
except (ValueError, TypeError):
|
||||
raise RuntimeError("npm_invalid_response") from None
|
||||
token = payload.get("token") if isinstance(payload, dict) else None
|
||||
if not isinstance(token, str) or not token:
|
||||
raise RuntimeError("npm_login_failed") from None
|
||||
self._assert_current()
|
||||
self._token = token
|
||||
return token
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
if (method, path) not in {
|
||||
("GET", "/users/me"),
|
||||
("GET", "/nginx/proxy-hosts"),
|
||||
("POST", "/nginx/proxy-hosts"),
|
||||
}:
|
||||
raise RuntimeError("npm_forge_route_request_invalid") from None
|
||||
if path == "/users/me":
|
||||
if kwargs != {"params": {"expand": "permissions"}}:
|
||||
raise RuntimeError("npm_forge_route_request_invalid") from None
|
||||
elif method == "GET":
|
||||
if kwargs:
|
||||
raise RuntimeError("npm_forge_route_request_invalid") from None
|
||||
elif set(kwargs) != {"json"} or type(kwargs.get("json")) is not dict:
|
||||
raise RuntimeError("npm_forge_route_request_invalid") from None
|
||||
self._assert_current()
|
||||
token = self._login()
|
||||
self._assert_current()
|
||||
config = self._config_snapshot
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=config.timeout_seconds,
|
||||
verify=True,
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
response = client.request(
|
||||
method,
|
||||
f"{config.url}/api{path}",
|
||||
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
|
||||
follow_redirects=False,
|
||||
**kwargs,
|
||||
)
|
||||
except (httpx.RequestError, ValueError):
|
||||
raise RuntimeError("npm_request_failed") from None
|
||||
if type(response.status_code) is not int or not (
|
||||
response.status_code == 200 if method == "GET" else response.status_code in {200, 201}
|
||||
):
|
||||
raise RuntimeError("npm_api_error") from None
|
||||
try:
|
||||
return response.json()
|
||||
except (ValueError, TypeError):
|
||||
raise RuntimeError("npm_invalid_response") from None
|
||||
|
||||
def _has_forge_route_permissions(self) -> bool:
|
||||
try:
|
||||
user = self._request("GET", "/users/me", params={"expand": "permissions"})
|
||||
except RuntimeError as exc:
|
||||
if str(exc) == "npm_configuration_changed":
|
||||
raise
|
||||
return False
|
||||
except Exception: # noqa: BLE001 - permission response details stay private.
|
||||
return False
|
||||
if type(user) is not dict:
|
||||
return False
|
||||
user_id = user.get("id")
|
||||
disabled = user.get("is_disabled")
|
||||
permissions = user.get("permissions")
|
||||
return bool(
|
||||
type(user_id) is int
|
||||
and user_id > 0
|
||||
and type(disabled) is bool
|
||||
and disabled is False
|
||||
and type(permissions) is dict
|
||||
and type(permissions.get("visibility")) is str
|
||||
and permissions["visibility"] == "all"
|
||||
and type(permissions.get("proxy_hosts")) is str
|
||||
and permissions["proxy_hosts"] == "manage"
|
||||
)
|
||||
|
||||
def _require_forge_route_permissions(self) -> None:
|
||||
if not self._has_forge_route_permissions():
|
||||
raise _NpmForgeRoutePermissionDenied() from None
|
||||
|
||||
def list_forge_route_hosts(self) -> list[dict[str, Any]]:
|
||||
self._require_forge_route_permissions()
|
||||
payload = self._request("GET", "/nginx/proxy-hosts")
|
||||
return self._connector._parse_forge_route_hosts(payload)
|
||||
|
||||
def create_forge_disabled_proxy_host(self, ownership_id: str) -> dict[str, Any]:
|
||||
if not re.fullmatch(r"froute-[0-9a-f]{32}", ownership_id):
|
||||
raise RuntimeError("npm_forge_route_ownership_invalid") from None
|
||||
self._require_forge_route_permissions()
|
||||
payload = self._request(
|
||||
"POST",
|
||||
"/nginx/proxy-hosts",
|
||||
json={
|
||||
"domain_names": ["forge.example.com"],
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "itworx-forge",
|
||||
"forward_port": 8080,
|
||||
"access_list_id": 0,
|
||||
"certificate_id": 0,
|
||||
"enabled": False,
|
||||
"ssl_forced": False,
|
||||
"http2_support": False,
|
||||
"hsts_enabled": False,
|
||||
"hsts_subdomains": False,
|
||||
"caching_enabled": False,
|
||||
"block_exploits": True,
|
||||
"allow_websocket_upgrade": False,
|
||||
"trust_forwarded_proto": False,
|
||||
"locations": [],
|
||||
"meta": {"appops_forge_route_id": ownership_id},
|
||||
"advanced_config": "",
|
||||
},
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("npm_invalid_response") from None
|
||||
return payload
|
||||
|
||||
|
||||
def _as_float(value: Any, default: float) -> float:
|
||||
@@ -54,15 +216,95 @@ def _as_list(value: Any) -> list[Any]:
|
||||
|
||||
|
||||
def _safe_url(value: Any = None) -> str:
|
||||
raw = str(value or settings.apis.get('NPM_URL') or '').strip().rstrip('/')
|
||||
if not raw:
|
||||
raw = value if value is not None else settings.apis.get('NPM_URL')
|
||||
try:
|
||||
url = _validated_npm_url(raw)
|
||||
_verified_tls_setting(settings.apis.get('NPM_VERIFY_TLS'))
|
||||
return url
|
||||
except RuntimeError:
|
||||
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 _validated_npm_url(value: Any) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > 2048:
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
if value != value.strip() or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value):
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
if '\\' in value or '?' in value or '#' in value:
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
try:
|
||||
parsed = urlparse(value)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
raise RuntimeError('npm_url_invalid') from None
|
||||
if parsed.scheme.lower() == 'http':
|
||||
raise RuntimeError('npm_https_required')
|
||||
if parsed.scheme.lower() != 'https' or not parsed.netloc:
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
if '@' in parsed.netloc or '%' in parsed.netloc or parsed.username is not None or parsed.password is not None:
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
if parsed.netloc.endswith(':'):
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname or hostname.endswith('.') or (port is not None and not 1 <= port <= 65535):
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
try:
|
||||
host_ascii = hostname.encode('idna').decode('ascii').lower()
|
||||
except UnicodeError:
|
||||
raise RuntimeError('npm_url_invalid') from None
|
||||
if ':' in host_ascii:
|
||||
try:
|
||||
ipaddress.IPv6Address(host_ascii)
|
||||
except ValueError:
|
||||
raise RuntimeError('npm_url_invalid') from None
|
||||
authority_host = f'[{host_ascii}]'
|
||||
else:
|
||||
if len(host_ascii) > 253 or any(
|
||||
not re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', label)
|
||||
for label in host_ascii.split('.')
|
||||
):
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
authority_host = host_ascii
|
||||
authority = authority_host + (f':{port}' if port is not None else '')
|
||||
|
||||
path = parsed.path
|
||||
if path and (
|
||||
not path.startswith('/')
|
||||
or not re.fullmatch(r"/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*(?:/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*)*", path)
|
||||
or re.search(r'%(?![0-9A-Fa-f]{2})', path)
|
||||
):
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
decoded_path = unquote(path)
|
||||
if '\\' in decoded_path or any(segment in {'.', '..'} for segment in decoded_path.split('/')):
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
return f'https://{authority}{path}'.rstrip('/')
|
||||
|
||||
|
||||
def _npm_endpoint_sha256(value: str) -> str:
|
||||
"""Hash the normalized HTTPS API base without persisting its URL."""
|
||||
normalized = _validated_npm_url(value)
|
||||
return hashlib.sha256(normalized.encode("ascii")).hexdigest()
|
||||
|
||||
|
||||
def _verified_tls_setting(value: Any) -> bool:
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, bool):
|
||||
enabled = value
|
||||
elif isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {'1', 'true', 'yes', 'on'}:
|
||||
enabled = True
|
||||
elif normalized in {'0', 'false', 'no', 'off'}:
|
||||
enabled = False
|
||||
else:
|
||||
raise RuntimeError('npm_config_invalid')
|
||||
else:
|
||||
raise RuntimeError('npm_config_invalid') # noqa: TRY004 - use a fixed public connector reason
|
||||
if not enabled:
|
||||
raise RuntimeError('npm_tls_verification_required')
|
||||
return True
|
||||
|
||||
|
||||
def _parse_date(value: Any) -> datetime | None:
|
||||
@@ -111,57 +353,107 @@ class NginxProxyManagerConnector(BaseConnector):
|
||||
super().__init__()
|
||||
self._runtime_token: str | None = None
|
||||
self._read_cache: dict[str, dict[str, Any]] = {}
|
||||
self._credential_binding: tuple[str, str, str, bool] | None = None
|
||||
|
||||
def _clear_runtime_context(self) -> None:
|
||||
self._runtime_token = None
|
||||
self._credential_binding = None
|
||||
self._read_cache.clear()
|
||||
|
||||
def _memo(self, key: str, factory: Any) -> dict[str, Any]:
|
||||
try:
|
||||
config = self._config()
|
||||
except RuntimeError as exc:
|
||||
return self._error_payload(exc, items=[], total=0)
|
||||
if config is None:
|
||||
return factory()
|
||||
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'))
|
||||
configured = bool(settings.apis.get('NPM_URL') and settings.apis.get('NPM_IDENTITY') and settings.apis.get('NPM_SECRET'))
|
||||
if not configured:
|
||||
self._clear_runtime_context()
|
||||
return configured
|
||||
|
||||
def _config(self) -> _NpmConfig | None:
|
||||
if not self.is_configured():
|
||||
self._clear_runtime_context()
|
||||
return None
|
||||
try:
|
||||
url = _validated_npm_url(settings.apis.get('NPM_URL'))
|
||||
verify_tls = _verified_tls_setting(settings.apis.get('NPM_VERIFY_TLS'))
|
||||
identity = settings.apis.get('NPM_IDENTITY')
|
||||
secret = settings.apis.get('NPM_SECRET')
|
||||
if not isinstance(identity, str) or not identity.strip() or not isinstance(secret, str) or not secret:
|
||||
raise RuntimeError('missing_config')
|
||||
config = _NpmConfig(
|
||||
url=url,
|
||||
identity=identity,
|
||||
secret=secret,
|
||||
verify_tls=verify_tls,
|
||||
timeout_seconds=_as_float(settings.apis.get('NPM_TIMEOUT_SECONDS'), 10.0),
|
||||
)
|
||||
except RuntimeError:
|
||||
self._clear_runtime_context()
|
||||
raise
|
||||
|
||||
binding = (config.url, config.identity, config.secret, config.verify_tls)
|
||||
if binding != self._credential_binding:
|
||||
self._runtime_token = None
|
||||
self._read_cache.clear()
|
||||
self._credential_binding = binding
|
||||
return config
|
||||
|
||||
def forge_route_session(self) -> _ForgeRouteNpmSession | None:
|
||||
"""Capture one strict endpoint/config for the entire Forge route operation."""
|
||||
try:
|
||||
config = self._config()
|
||||
if config is None or config.verify_tls is not True:
|
||||
return None
|
||||
if _validated_npm_url(config.url) != config.url:
|
||||
return None
|
||||
return _ForgeRouteNpmSession(self, config)
|
||||
except Exception: # noqa: BLE001 - never expose connector settings or diagnostics.
|
||||
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')
|
||||
if self._runtime_token:
|
||||
return self._runtime_token
|
||||
try:
|
||||
self.clear_error()
|
||||
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
|
||||
with httpx.Client(
|
||||
timeout=config.timeout_seconds,
|
||||
verify=config.verify_tls,
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
response = client.post(
|
||||
f'{config.url}/api/tokens',
|
||||
json={'identity': config.identity, 'secret': config.secret},
|
||||
headers={'Accept': 'application/json'},
|
||||
follow_redirects=False,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
message = redact_message(str(exc))
|
||||
self.set_error(message)
|
||||
raise RuntimeError(message)
|
||||
except httpx.RequestError:
|
||||
self.set_error('npm_request_failed')
|
||||
raise RuntimeError('npm_request_failed') from None
|
||||
if 300 <= response.status_code < 400:
|
||||
self.set_error('npm_redirect_denied')
|
||||
raise RuntimeError('npm_redirect_denied')
|
||||
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)
|
||||
self.set_error('npm_api_error')
|
||||
raise RuntimeError('npm_api_error')
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
message = redact_message(str(exc))
|
||||
self.set_error(message)
|
||||
raise RuntimeError(message)
|
||||
except ValueError:
|
||||
self.set_error('npm_invalid_response')
|
||||
raise RuntimeError('npm_invalid_response') from None
|
||||
token = payload.get('token') if isinstance(payload, dict) else None
|
||||
if not token:
|
||||
self.set_error('auth_failed')
|
||||
@@ -174,31 +466,39 @@ class NginxProxyManagerConnector(BaseConnector):
|
||||
if not config:
|
||||
raise RuntimeError('missing_config')
|
||||
token = self._login()
|
||||
if self._credential_binding != (config.url, config.identity, config.secret, config.verify_tls):
|
||||
self.set_error('npm_configuration_changed')
|
||||
raise RuntimeError('npm_configuration_changed')
|
||||
try:
|
||||
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
|
||||
with httpx.Client(
|
||||
timeout=config.timeout_seconds,
|
||||
verify=config.verify_tls,
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
kwargs['follow_redirects'] = False
|
||||
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)
|
||||
except httpx.RequestError:
|
||||
self.set_error('npm_request_failed')
|
||||
raise RuntimeError('npm_request_failed') from None
|
||||
if 300 <= response.status_code < 400:
|
||||
self.set_error('npm_redirect_denied')
|
||||
raise RuntimeError('npm_redirect_denied')
|
||||
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)
|
||||
self.set_error('npm_api_error')
|
||||
raise RuntimeError('npm_api_error')
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
message = redact_message(str(exc))
|
||||
self.set_error(message)
|
||||
raise RuntimeError(message)
|
||||
except ValueError:
|
||||
self.set_error('npm_invalid_response')
|
||||
raise RuntimeError('npm_invalid_response') from None
|
||||
|
||||
def test_connection(self) -> dict[str, Any]:
|
||||
if not self.is_configured():
|
||||
@@ -215,6 +515,12 @@ class NginxProxyManagerConnector(BaseConnector):
|
||||
return {'status': 'unreachable', 'reason': reason}
|
||||
return {'status': 'connected', 'proxyHostCount': len(hosts)}
|
||||
|
||||
@staticmethod
|
||||
def _parse_forge_route_hosts(payload: Any) -> list[dict[str, Any]]:
|
||||
if type(payload) is not list or len(payload) > 1000 or any(type(item) is not dict for item in payload):
|
||||
raise RuntimeError('npm_forge_route_inventory_invalid') from None
|
||||
return payload
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
if not self.is_configured():
|
||||
return self._missing(proxyHostCount=0, certificateCount=0)
|
||||
|
||||
Reference in new issue
Block a user