Publish curated Unraid-AppOps-Gateway source from ba66029223865607deddc234e283c11127324eb8

This commit is contained in:
Public Source Publisher committed 2026-10-07 01:56:22 +00:00
1 parent 820244dbb3
commit 8dd4b40252
52 files changed
+11814 -288

No files matched your search

+355 -49
View File
@@ -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)
+195
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import re
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Any
@@ -136,6 +137,200 @@ class PortainerConnector(BaseConnector):
return {'status': 'ok', 'containers': containers}
def forge_route_network_readiness(self) -> dict[str, Any]:
"""Check only the fixed NPM and Forge frontdoor network membership."""
try:
config = self._config()
except Exception: # noqa: BLE001 - fixed projection never returns config diagnostics.
config = None
try:
parsed_url = urlsplit(config.url) if config else None
parsed_port = parsed_url.port if parsed_url else None
base_url = _safe_portainer_base_url(config.url) if config else None
except (TypeError, ValueError):
parsed_url = None
parsed_port = None
base_url = None
if (
not config
or not isinstance(config.url, str)
or len(config.url) > 2048
or config.url != config.url.strip()
or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in config.url)
or "\\" in config.url
or "?" in config.url
or "#" in config.url
or settings.apis.get('PORTAINER_VERIFY_TLS', settings.verify_tls) is not True
or config.verify_tls is not True
or parsed_url is None
or parsed_url.scheme != 'https'
or not parsed_url.hostname
or (parsed_port is not None and not 1 <= parsed_port <= 65535)
or parsed_url.username is not None
or parsed_url.password is not None
or parsed_url.query
or parsed_url.fragment
or '@' in parsed_url.netloc
or '%' in parsed_url.netloc
or any(segment in {".", ".."} for segment in parsed_url.path.split("/"))
or base_url != config.url
or not isinstance(config.api_key, str)
or not config.api_key
or re.fullmatch(r'[1-9][0-9]{0,9}', config.endpoint_id) is None
):
return {
'status': 'unknown',
'network': 'itworx-mcp-hub-npm',
'npmAttached': None,
'frontdoorAttached': None,
}
try:
with httpx.Client(
timeout=_PORTAINER_DEFAULT_TIMEOUT_SECONDS,
verify=True,
follow_redirects=False,
) as client:
response = client.get(
f'{config.url}/api/endpoints/{config.endpoint_id}/docker/containers/json?all=1',
headers={'Accept': 'application/json', 'X-API-Key': config.api_key},
follow_redirects=False,
)
if type(getattr(response, 'status_code', None)) is not int or response.status_code != 200:
return {
'status': 'unknown',
'network': 'itworx-mcp-hub-npm',
'npmAttached': None,
'frontdoorAttached': None,
}
payload = response.json()
except Exception: # noqa: BLE001 - never expose a Docker/API credential or response.
return {
'status': 'unknown',
'network': 'itworx-mcp-hub-npm',
'npmAttached': None,
'frontdoorAttached': None,
}
if not isinstance(payload, list):
return {
'status': 'unknown',
'network': 'itworx-mcp-hub-npm',
'npmAttached': None,
'frontdoorAttached': None,
}
npm = []
frontdoor = []
for raw in payload:
if not isinstance(raw, dict):
return {
'status': 'unknown',
'network': 'itworx-mcp-hub-npm',
'npmAttached': None,
'frontdoorAttached': None,
}
names = raw.get('Names')
if not isinstance(names, list) or any(not isinstance(name, str) for name in names):
return {
'status': 'unknown',
'network': 'itworx-mcp-hub-npm',
'npmAttached': None,
'frontdoorAttached': None,
}
name_matches = any(name.lstrip('/') == 'NginxProxyManager' for name in names)
image = raw.get('Image')
if not isinstance(image, str) or not image:
return {
'status': 'unknown',
'network': 'itworx-mcp-hub-npm',
'npmAttached': None,
'frontdoorAttached': None,
}
image_repository = image.rsplit('@', 1)[0]
last_component = image_repository.rsplit('/', 1)[-1]
if ':' in last_component:
image_repository = image_repository.rsplit(':', 1)[0]
image_repository = image_repository.lower()
if name_matches:
if image_repository != 'jlesage/nginx-proxy-manager':
return {
'status': 'unknown',
'network': 'itworx-mcp-hub-npm',
'npmAttached': None,
'frontdoorAttached': None,
}
npm.append(raw)
labels = raw.get('Labels')
if (
isinstance(labels, dict)
and labels.get('com.docker.compose.project') == 'itworx-forge'
and labels.get('com.docker.compose.service') == 'itworx-forge'
):
frontdoor.append(raw)
if len(npm) != 1 or len(frontdoor) != 1:
return {
'status': 'unknown',
'network': 'itworx-mcp-hub-npm',
'npmAttached': None,
'frontdoorAttached': None,
}
npm_id = npm[0].get('Id')
frontdoor_id = frontdoor[0].get('Id')
if (
not isinstance(npm_id, str)
or not re.fullmatch(r'[0-9a-f]{64}', npm_id)
or not isinstance(frontdoor_id, str)
or not re.fullmatch(r'[0-9a-f]{64}', frontdoor_id)
or npm_id == frontdoor_id
):
return {
'status': 'unknown',
'network': 'itworx-mcp-hub-npm',
'npmAttached': None,
'frontdoorAttached': None,
}
def _attached(raw: dict[str, Any]) -> tuple[str, str] | bool | None:
if raw.get('State') != 'running':
return False
network_settings = raw.get('NetworkSettings')
networks = network_settings.get('Networks') if isinstance(network_settings, dict) else None
if not isinstance(networks, dict):
return None
if 'itworx-mcp-hub-npm' not in networks:
return False
network = networks.get('itworx-mcp-hub-npm')
if not isinstance(network, dict):
return None
network_id = network.get('NetworkID')
endpoint_id = network.get('EndpointID')
if (
not isinstance(network_id, str)
or re.fullmatch(r'[0-9a-f]{64}', network_id) is None
or not isinstance(endpoint_id, str)
or re.fullmatch(r'[0-9a-f]{64}', endpoint_id) is None
):
return None
return network_id, endpoint_id
npm_membership = _attached(npm[0])
frontdoor_membership = _attached(frontdoor[0])
if npm_membership is None or frontdoor_membership is None:
status = 'unknown'
elif npm_membership is False or frontdoor_membership is False:
status = 'not_ready'
elif npm_membership[0] != frontdoor_membership[0]:
status = 'unknown'
else:
status = 'ready'
npm_attached = None if npm_membership is None else npm_membership is not False
frontdoor_attached = None if frontdoor_membership is None else frontdoor_membership is not False
return {
'status': status,
'network': 'itworx-mcp-hub-npm',
'npmAttached': npm_attached,
'frontdoorAttached': frontdoor_attached,
}
def _attach_container_stats(self, containers: list[dict[str, Any]], stats_limit: int) -> None:
candidates = [
container