From d9f1c8325a1e58a23a3fb790fc75652a01c1dc44 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Sun, 6 Sep 2026 20:38:16 +0200 Subject: [PATCH] feat(userbot): prereqs at startup, SPA path guard, provision lock - ensure_prerequisites runs on startup, not per-request; kube config errors surface as 503 PanelError - serialize provisioning with a lock; drop per-endpoint prereq checks - guard SPA fallback against path traversal (relative_to) - add backend tests for auth flow, k8s service, spa routing; ci comment for legacy userbot deployments --- .gitea/workflows/ci.yaml | 1 + .github/workflows/ci.yaml | 1 + userbot/panel/backend/app/auth_service.py | 9 ++- .../panel/backend/app/kubernetes_service.py | 74 ++++++++++++------- userbot/panel/backend/app/main.py | 13 +++- .../panel/backend/tests/test_auth_service.py | 17 +++++ .../backend/tests/test_kubernetes_service.py | 21 ++++++ userbot/panel/backend/tests/test_spa.py | 48 ++++++++++++ userbot/panel/frontend/src/App.svelte | 30 +++++++- userbot/panel/frontend/src/lib/api.ts | 11 ++- 10 files changed, 191 insertions(+), 34 deletions(-) create mode 100644 userbot/panel/backend/tests/test_spa.py diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index e9214a9..5ed0185 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -353,6 +353,7 @@ jobs: kubectl get secret userbot-common-secrets -n default -o json \ | jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \ | kubectl apply -f - + # Keep legacy deployments (forust/anna) in sync with manifests; they have no replicas field, so apply leaves scaling to the user manager only. kubectl apply -f userbot/k8s/base/userbots.yaml kubectl rollout restart deployment/userbot-panel -n userbot kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e9214a9..5ed0185 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -353,6 +353,7 @@ jobs: kubectl get secret userbot-common-secrets -n default -o json \ | jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \ | kubectl apply -f - + # Keep legacy deployments (forust/anna) in sync with manifests; they have no replicas field, so apply leaves scaling to the user manager only. kubectl apply -f userbot/k8s/base/userbots.yaml kubectl rollout restart deployment/userbot-panel -n userbot kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s diff --git a/userbot/panel/backend/app/auth_service.py b/userbot/panel/backend/app/auth_service.py index b820c0f..08b35f2 100644 --- a/userbot/panel/backend/app/auth_service.py +++ b/userbot/panel/backend/app/auth_service.py @@ -39,13 +39,20 @@ class AuthorizedAccount: class TelegramAuthService: - def __init__(self, ttl_seconds: int = 600) -> None: + def __init__(self, ttl_seconds: int = 600, max_flows: int = 50) -> None: self.ttl = timedelta(seconds=ttl_seconds) + self.max_flows = max_flows self.flows: dict[str, AuthFlow] = {} self._lock = asyncio.Lock() async def start_phone(self, account: PhoneStart) -> str: await self._cleanup_expired() + async with self._lock: + if len(self.flows) >= self.max_flows: + raise PanelError( + 429, + "Too many pending authorization flows; try again later", + ) flow_id = secrets.token_urlsafe(24) telegram = Client( f"auth-{flow_id}", diff --git a/userbot/panel/backend/app/kubernetes_service.py b/userbot/panel/backend/app/kubernetes_service.py index fde97d2..3c84e02 100644 --- a/userbot/panel/backend/app/kubernetes_service.py +++ b/userbot/panel/backend/app/kubernetes_service.py @@ -1,6 +1,8 @@ from __future__ import annotations +import logging from datetime import UTC, datetime +from threading import Lock from typing import Any from kubernetes import client, config @@ -10,6 +12,8 @@ from .config import Settings from .errors import PanelError from .models import AccountBase, InstanceSummary +logger = logging.getLogger(__name__) + MANAGED_LABEL = "app.kubernetes.io/name=userbot" INSTANCE_LABEL = "app.kubernetes.io/instance" MANAGED_BY_LABEL = "app.kubernetes.io/managed-by" @@ -46,10 +50,17 @@ class KubernetesService: try: config.load_incluster_config() except config.ConfigException: - config.load_kube_config() + try: + config.load_kube_config() + except Exception as exc: + raise PanelError( + 503, + "No in-cluster or kubeconfig configuration is available", + ) from exc self.core = core or client.CoreV1Api() self.apps = apps or client.AppsV1Api() self.custom = custom or client.CustomObjectsApi() + self._provision_lock = Lock() def ensure_prerequisites(self) -> None: try: @@ -119,29 +130,34 @@ class KubernetesService: raise PanelError(409, f"{kind} {name} already exists") def provision(self, account: AccountBase, session_string: str) -> InstanceSummary: - self.ensure_prerequisites() - self.assert_available(account.instance_id) - names = self._resource_names(account.instance_id) - created: list[tuple[str, str]] = [] - try: - self.core.create_namespaced_secret( - self.settings.namespace, - self._secret(account, session_string, names), - ) - created.append(("secret", names["secret"])) - self.core.create_namespaced_persistent_volume_claim( - self.settings.namespace, - self._pvc(account, names), - ) - created.append(("pvc", names["pvc"])) - self.apps.create_namespaced_deployment( - self.settings.namespace, - self._deployment(account, names), - ) - created.append(("deployment", names["deployment"])) - except ApiException as exc: - self._rollback(created) - raise self._api_error(exc, "Could not create userbot instance") from exc + with self._provision_lock: + self.assert_available(account.instance_id) + names = self._resource_names(account.instance_id) + created: list[tuple[str, str]] = [] + try: + self.core.create_namespaced_secret( + self.settings.namespace, + self._secret(account, session_string, names), + ) + created.append(("secret", names["secret"])) + self.core.create_namespaced_persistent_volume_claim( + self.settings.namespace, + self._pvc(account, names), + ) + created.append(("pvc", names["pvc"])) + self.apps.create_namespaced_deployment( + self.settings.namespace, + self._deployment(account, names), + ) + created.append(("deployment", names["deployment"])) + except ApiException as exc: + self._rollback(created) + if exc.status == 409: + raise PanelError( + 409, + f"Instance {account.instance_id} already exists", + ) from exc + raise self._api_error(exc, "Could not create userbot instance") from exc return self.get_instance(account.instance_id) def scale(self, instance_id: str, replicas: int) -> InstanceSummary: @@ -508,8 +524,14 @@ class KubernetesService: ) else: self.core.delete_namespaced_secret(name, self.settings.namespace) - except ApiException: - pass + except ApiException as exc: + logger.warning( + "Rollback of %s %s in %s failed: %s", + kind, + name, + self.settings.namespace, + exc, + ) @staticmethod def _api_error(exc: ApiException, detail: str) -> PanelError: diff --git a/userbot/panel/backend/app/main.py b/userbot/panel/backend/app/main.py index f077739..712ee3b 100644 --- a/userbot/panel/backend/app/main.py +++ b/userbot/panel/backend/app/main.py @@ -27,6 +27,10 @@ from .models import ( async def lifespan(app: FastAPI): app.state.kubernetes = KubernetesService(settings) app.state.telegram = TelegramAuthService(settings.auth_ttl_seconds) + try: + app.state.kubernetes.ensure_prerequisites() + except PanelError as exc: + print(f"WARNING: userbot prerequisites check failed at startup: {exc.detail}") yield await app.state.telegram.close() @@ -136,7 +140,6 @@ async def auth_phone_start( request: Request, ) -> AuthResult: service = kube(request) - service.ensure_prerequisites() service.assert_available(payload.instance_id) flow_id = await telegram(request).start_phone(payload) return AuthResult(status="code_required", flow_id=flow_id) @@ -172,7 +175,6 @@ async def auth_string_session( request: Request, ) -> AuthResult: service = kube(request) - service.ensure_prerequisites() service.assert_available(payload.instance_id) authorized = await telegram(request).validate_string_session(payload) instance = service.provision(authorized.account, authorized.session_string) @@ -192,7 +194,12 @@ def index() -> FileResponse: @app.get("/{path:path}", include_in_schema=False) def spa_fallback(path: str) -> FileResponse: + root = static_dir.resolve() candidate = (static_dir / path).resolve() - if candidate.is_file() and static_dir.resolve() in candidate.parents: + try: + candidate.relative_to(root) + except ValueError: + return FileResponse(static_dir / "index.html") + if candidate.is_file(): return FileResponse(candidate) return FileResponse(static_dir / "index.html") diff --git a/userbot/panel/backend/tests/test_auth_service.py b/userbot/panel/backend/tests/test_auth_service.py index a17af68..33feec1 100644 --- a/userbot/panel/backend/tests/test_auth_service.py +++ b/userbot/panel/backend/tests/test_auth_service.py @@ -82,3 +82,20 @@ async def test_string_session_is_validated_and_closed(monkeypatch) -> None: assert result.session_string == "exported-session" assert FakeClient.instances[-1].disconnected is True + + +@pytest.mark.asyncio +async def test_start_phone_rejects_overflow(monkeypatch) -> None: + from app.errors import PanelError + + monkeypatch.setattr("app.auth_service.Client", FakeClient) + auth = TelegramAuthService(ttl_seconds=3600, max_flows=2) + + await auth.start_phone(phone_payload()) + await auth.start_phone(phone_payload()) + + with pytest.raises(PanelError) as error: + await auth.start_phone(phone_payload()) + + assert error.value.status_code == 429 + assert "Too many pending" in error.value.detail diff --git a/userbot/panel/backend/tests/test_kubernetes_service.py b/userbot/panel/backend/tests/test_kubernetes_service.py index c782f41..3bf3651 100644 --- a/userbot/panel/backend/tests/test_kubernetes_service.py +++ b/userbot/panel/backend/tests/test_kubernetes_service.py @@ -80,6 +80,27 @@ def test_partial_provision_rolls_back_only_created_resources() -> None: kube.apps.delete_namespaced_deployment.assert_not_called() +def test_provision_conflict_reports_existing_instance() -> None: + kube = service() + kube.assert_available = Mock() + kube.apps.create_namespaced_deployment.side_effect = ApiException(status=409) + + with pytest.raises(PanelError) as error: + kube.provision(account(), "SESSION") + + assert error.value.status_code == 409 + assert "already exists" in error.value.detail + # Partial resources created before the 409 must be rolled back. + kube.core.delete_namespaced_secret.assert_called_once_with( + "userbot-test-account-credentials", + "userbot", + ) + kube.core.delete_namespaced_persistent_volume_claim.assert_called_once_with( + "userbot-test-account-data", + "userbot", + ) + + def test_delete_retains_pvc_unless_explicitly_requested() -> None: kube = service() deployment = SimpleNamespace( diff --git a/userbot/panel/backend/tests/test_spa.py b/userbot/panel/backend/tests/test_spa.py new file mode 100644 index 0000000..a954200 --- /dev/null +++ b/userbot/panel/backend/tests/test_spa.py @@ -0,0 +1,48 @@ +from pathlib import Path + +import pytest +from app.main import spa_fallback + + +@pytest.fixture +def static_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + static = tmp_path / "static" + static.mkdir() + (static / "index.html").write_text("index", encoding="utf-8") + (static / "app.js").write_text("console.log('app')", encoding="utf-8") + secret = tmp_path / "secret.txt" + secret.write_text("TOP SECRET", encoding="utf-8") + monkeypatch.setattr("app.main.static_dir", static) + return static + + +def static_index(static_dir: Path) -> Path: + return static_dir / "index.html" + + +def test_returns_existing_file(static_dir: Path) -> None: + response = spa_fallback("app.js") + assert response.path == static_dir / "app.js" + + +def test_unknown_path_falls_back_to_index(static_dir: Path) -> None: + response = spa_fallback("does/not/exist.js") + assert response.path == static_index(static_dir) + + +def test_traversal_does_not_leak_outside_static(static_dir: Path) -> None: + response = spa_fallback("../secret.txt") + assert response.path == static_index(static_dir) + + response = spa_fallback("%2e%2e/secret.txt") + assert response.path == static_index(static_dir) + + +def test_symlink_outside_static_is_blocked(static_dir: Path, tmp_path: Path) -> None: + target = tmp_path / "outside.txt" + target.write_text("secret", encoding="utf-8") + link = static_dir / "leak.txt" + link.symlink_to(target) + + response = spa_fallback("leak.txt") + assert response.path == static_index(static_dir) diff --git a/userbot/panel/frontend/src/App.svelte b/userbot/panel/frontend/src/App.svelte index 4fd83b5..0f09f22 100644 --- a/userbot/panel/frontend/src/App.svelte +++ b/userbot/panel/frontend/src/App.svelte @@ -26,6 +26,7 @@ let addOpen = false; let menuOpen = false; let busyId = ''; + let pollTimer: number | null = null; $: visibleInstances = filterInstances(instances, query, status); $: running = instances.filter((item) => item.status === 'running').length; @@ -34,10 +35,35 @@ onMount(() => { refresh(); - const timer = window.setInterval(() => refresh(true), 5000); - return () => window.clearInterval(timer); + startPolling(); + document.addEventListener('visibilitychange', handleVisibility); + return () => { + stopPolling(); + document.removeEventListener('visibilitychange', handleVisibility); + }; }); + function startPolling() { + stopPolling(); + pollTimer = window.setInterval(() => refresh(true), 5000); + } + + function stopPolling() { + if (pollTimer !== null) { + window.clearInterval(pollTimer); + pollTimer = null; + } + } + + function handleVisibility() { + if (document.visibilityState === 'visible') { + refresh(true); + startPolling(); + } else { + stopPolling(); + } + } + async function refresh(silent = false) { if (!silent) refreshing = true; try { diff --git a/userbot/panel/frontend/src/lib/api.ts b/userbot/panel/frontend/src/lib/api.ts index 44d8b12..c7cb641 100644 --- a/userbot/panel/frontend/src/lib/api.ts +++ b/userbot/panel/frontend/src/lib/api.ts @@ -3,8 +3,15 @@ import type { AccountDraft, AuthResult, Instance } from "./types"; async function request(path: string, init?: RequestInit): Promise { const response = await fetch(path, init); if (!response.ok) { - const payload = await response.json().catch(() => ({})); - throw new Error(payload.detail || response.statusText || "Request failed"); + let message = response.statusText || "Request failed"; + try { + const payload = await response.json(); + if (payload?.detail) message = typeof payload.detail === "string" ? payload.detail : JSON.stringify(payload.detail); + } catch { + const text = await response.text().catch(() => ""); + if (text) message = text; + } + throw new Error(message); } if (response.status === 204) return undefined as T; return response.json();