feat(userbot): prereqs at startup, SPA path guard, provision lock
ci / lint-prettier (push) Failing after 8s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Failing after 7s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
deploy / redeploy (push) Failing after 0s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
ci / lint-prettier (push) Failing after 8s
ci / lint-ruff (push) Successful in 4s
ci / lint-yaml (push) Failing after 7s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 5s
deploy / redeploy (push) Failing after 0s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
- 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:
1 parent
861d89d36a
commit
d9f1c8325a
10 files changed
+191
-34
No files matched your search
@@ -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}",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user