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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user