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
This commit is contained in:
2026-09-06 20:38:16 +02:00
parent 861d89d36a
commit d9f1c8325a
10 changed files with 191 additions and 34 deletions
+1
View File
@@ -353,6 +353,7 @@ jobs:
kubectl get secret userbot-common-secrets -n default -o json \ 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"' \ | jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \
| kubectl apply -f - | 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 apply -f userbot/k8s/base/userbots.yaml
kubectl rollout restart deployment/userbot-panel -n userbot kubectl rollout restart deployment/userbot-panel -n userbot
kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s
+1
View File
@@ -353,6 +353,7 @@ jobs:
kubectl get secret userbot-common-secrets -n default -o json \ 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"' \ | jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \
| kubectl apply -f - | 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 apply -f userbot/k8s/base/userbots.yaml
kubectl rollout restart deployment/userbot-panel -n userbot kubectl rollout restart deployment/userbot-panel -n userbot
kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s
+8 -1
View File
@@ -39,13 +39,20 @@ class AuthorizedAccount:
class TelegramAuthService: 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.ttl = timedelta(seconds=ttl_seconds)
self.max_flows = max_flows
self.flows: dict[str, AuthFlow] = {} self.flows: dict[str, AuthFlow] = {}
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
async def start_phone(self, account: PhoneStart) -> str: async def start_phone(self, account: PhoneStart) -> str:
await self._cleanup_expired() 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) flow_id = secrets.token_urlsafe(24)
telegram = Client( telegram = Client(
f"auth-{flow_id}", f"auth-{flow_id}",
+48 -26
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from threading import Lock
from typing import Any from typing import Any
from kubernetes import client, config from kubernetes import client, config
@@ -10,6 +12,8 @@ from .config import Settings
from .errors import PanelError from .errors import PanelError
from .models import AccountBase, InstanceSummary from .models import AccountBase, InstanceSummary
logger = logging.getLogger(__name__)
MANAGED_LABEL = "app.kubernetes.io/name=userbot" MANAGED_LABEL = "app.kubernetes.io/name=userbot"
INSTANCE_LABEL = "app.kubernetes.io/instance" INSTANCE_LABEL = "app.kubernetes.io/instance"
MANAGED_BY_LABEL = "app.kubernetes.io/managed-by" MANAGED_BY_LABEL = "app.kubernetes.io/managed-by"
@@ -46,10 +50,17 @@ class KubernetesService:
try: try:
config.load_incluster_config() config.load_incluster_config()
except config.ConfigException: 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.core = core or client.CoreV1Api()
self.apps = apps or client.AppsV1Api() self.apps = apps or client.AppsV1Api()
self.custom = custom or client.CustomObjectsApi() self.custom = custom or client.CustomObjectsApi()
self._provision_lock = Lock()
def ensure_prerequisites(self) -> None: def ensure_prerequisites(self) -> None:
try: try:
@@ -119,29 +130,34 @@ class KubernetesService:
raise PanelError(409, f"{kind} {name} already exists") raise PanelError(409, f"{kind} {name} already exists")
def provision(self, account: AccountBase, session_string: str) -> InstanceSummary: def provision(self, account: AccountBase, session_string: str) -> InstanceSummary:
self.ensure_prerequisites() with self._provision_lock:
self.assert_available(account.instance_id) self.assert_available(account.instance_id)
names = self._resource_names(account.instance_id) names = self._resource_names(account.instance_id)
created: list[tuple[str, str]] = [] created: list[tuple[str, str]] = []
try: try:
self.core.create_namespaced_secret( self.core.create_namespaced_secret(
self.settings.namespace, self.settings.namespace,
self._secret(account, session_string, names), self._secret(account, session_string, names),
) )
created.append(("secret", names["secret"])) created.append(("secret", names["secret"]))
self.core.create_namespaced_persistent_volume_claim( self.core.create_namespaced_persistent_volume_claim(
self.settings.namespace, self.settings.namespace,
self._pvc(account, names), self._pvc(account, names),
) )
created.append(("pvc", names["pvc"])) created.append(("pvc", names["pvc"]))
self.apps.create_namespaced_deployment( self.apps.create_namespaced_deployment(
self.settings.namespace, self.settings.namespace,
self._deployment(account, names), self._deployment(account, names),
) )
created.append(("deployment", names["deployment"])) created.append(("deployment", names["deployment"]))
except ApiException as exc: except ApiException as exc:
self._rollback(created) self._rollback(created)
raise self._api_error(exc, "Could not create userbot instance") from exc 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) return self.get_instance(account.instance_id)
def scale(self, instance_id: str, replicas: int) -> InstanceSummary: def scale(self, instance_id: str, replicas: int) -> InstanceSummary:
@@ -508,8 +524,14 @@ class KubernetesService:
) )
else: else:
self.core.delete_namespaced_secret(name, self.settings.namespace) self.core.delete_namespaced_secret(name, self.settings.namespace)
except ApiException: except ApiException as exc:
pass logger.warning(
"Rollback of %s %s in %s failed: %s",
kind,
name,
self.settings.namespace,
exc,
)
@staticmethod @staticmethod
def _api_error(exc: ApiException, detail: str) -> PanelError: def _api_error(exc: ApiException, detail: str) -> PanelError:
+10 -3
View File
@@ -27,6 +27,10 @@ from .models import (
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
app.state.kubernetes = KubernetesService(settings) app.state.kubernetes = KubernetesService(settings)
app.state.telegram = TelegramAuthService(settings.auth_ttl_seconds) 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 yield
await app.state.telegram.close() await app.state.telegram.close()
@@ -136,7 +140,6 @@ async def auth_phone_start(
request: Request, request: Request,
) -> AuthResult: ) -> AuthResult:
service = kube(request) service = kube(request)
service.ensure_prerequisites()
service.assert_available(payload.instance_id) service.assert_available(payload.instance_id)
flow_id = await telegram(request).start_phone(payload) flow_id = await telegram(request).start_phone(payload)
return AuthResult(status="code_required", flow_id=flow_id) return AuthResult(status="code_required", flow_id=flow_id)
@@ -172,7 +175,6 @@ async def auth_string_session(
request: Request, request: Request,
) -> AuthResult: ) -> AuthResult:
service = kube(request) service = kube(request)
service.ensure_prerequisites()
service.assert_available(payload.instance_id) service.assert_available(payload.instance_id)
authorized = await telegram(request).validate_string_session(payload) authorized = await telegram(request).validate_string_session(payload)
instance = service.provision(authorized.account, authorized.session_string) instance = service.provision(authorized.account, authorized.session_string)
@@ -192,7 +194,12 @@ def index() -> FileResponse:
@app.get("/{path:path}", include_in_schema=False) @app.get("/{path:path}", include_in_schema=False)
def spa_fallback(path: str) -> FileResponse: def spa_fallback(path: str) -> FileResponse:
root = static_dir.resolve()
candidate = (static_dir / path).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(candidate)
return FileResponse(static_dir / "index.html") return FileResponse(static_dir / "index.html")
@@ -82,3 +82,20 @@ async def test_string_session_is_validated_and_closed(monkeypatch) -> None:
assert result.session_string == "exported-session" assert result.session_string == "exported-session"
assert FakeClient.instances[-1].disconnected is True 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
@@ -80,6 +80,27 @@ def test_partial_provision_rolls_back_only_created_resources() -> None:
kube.apps.delete_namespaced_deployment.assert_not_called() 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: def test_delete_retains_pvc_unless_explicitly_requested() -> None:
kube = service() kube = service()
deployment = SimpleNamespace( deployment = SimpleNamespace(
+48
View File
@@ -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("<html>index</html>", 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)
+28 -2
View File
@@ -26,6 +26,7 @@
let addOpen = false; let addOpen = false;
let menuOpen = false; let menuOpen = false;
let busyId = ''; let busyId = '';
let pollTimer: number | null = null;
$: visibleInstances = filterInstances(instances, query, status); $: visibleInstances = filterInstances(instances, query, status);
$: running = instances.filter((item) => item.status === 'running').length; $: running = instances.filter((item) => item.status === 'running').length;
@@ -34,10 +35,35 @@
onMount(() => { onMount(() => {
refresh(); refresh();
const timer = window.setInterval(() => refresh(true), 5000); startPolling();
return () => window.clearInterval(timer); 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) { async function refresh(silent = false) {
if (!silent) refreshing = true; if (!silent) refreshing = true;
try { try {
+9 -2
View File
@@ -3,8 +3,15 @@ import type { AccountDraft, AuthResult, Instance } from "./types";
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, init); const response = await fetch(path, init);
if (!response.ok) { if (!response.ok) {
const payload = await response.json().catch(() => ({})); let message = response.statusText || "Request failed";
throw new Error(payload.detail || 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; if (response.status === 204) return undefined as T;
return response.json(); return response.json();