feat: add remnawave panel dry-run mode

This commit is contained in:
3252a8
2026-06-01 10:32:38 +03:00
parent c2344824dc
commit 391487811b
6 changed files with 713 additions and 1 deletions
+6 -1
View File
@@ -11,6 +11,7 @@ from bot.services.email_auth_service import EmailAuthService
from bot.services.lknpd_service import LknpdService
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.panel_dry_run_api_service import PanelDryRunApiService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
@@ -26,7 +27,11 @@ def build_core_services(
i18n: JsonI18n,
bot_username_for_default_return: str,
):
panel_service = PanelApiService(settings)
panel_service = (
PanelDryRunApiService(settings)
if bool(getattr(settings, "panel_dry_run_enabled", False))
else PanelApiService(settings)
)
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
referral_service = ReferralService(settings, subscription_service, bot, i18n)
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
@@ -0,0 +1,482 @@
import json
import logging
import re
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
from config.settings import Settings
from .panel_api_service import PanelApiService
logger = logging.getLogger(__name__)
_USER_ACTION_RE = re.compile(
r"^/users/(?P<user_uuid>[^/]+)/actions/(?P<action>enable|disable|reset-traffic)$"
)
_INTERNAL_SQUAD_BULK_RE = re.compile(
r"^/internal-squads/(?P<squad_uuid>[^/]+)/bulk-actions/"
r"(?P<action>add-users|remove-users)$"
)
_LIVE_POST_ENDPOINTS = frozenset({"/system/tools/happ/encrypt"})
_KNOWN_TRAFFIC_STRATEGIES = frozenset({"NO_RESET", "DAY", "WEEK", "MONTH"})
@dataclass
class _DryRunValidation:
errors: List[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors
def add(self, message: str) -> None:
self.errors.append(message)
class PanelDryRunApiService(PanelApiService):
"""Panel API client that reads live data but never mutates Remnawave users."""
def __init__(self, settings: Settings):
super().__init__(settings)
self._synthetic_users: Dict[str, Dict[str, Any]] = {}
async def _request(
self, method: str, endpoint: str, log_full_response: bool = False, **kwargs
) -> Optional[Dict[str, Any]]:
method_upper = method.upper()
normalized_endpoint = self._normalize_endpoint(endpoint)
if not self._should_intercept(method_upper, normalized_endpoint):
return await super()._request(
method_upper,
endpoint,
log_full_response=log_full_response,
**kwargs,
)
validation = await self._validate_dry_run_request(
method_upper,
normalized_endpoint,
kwargs.get("json"),
)
if not validation.ok:
self._log_dry_run(
"BLOCKED",
method_upper,
normalized_endpoint,
kwargs.get("json"),
errors=validation.errors,
)
return {
"error": True,
"status_code": 400,
"errorCode": "DRY_RUN_VALIDATION_FAILED",
"message": "Panel dry-run validation failed.",
"details": {"errors": validation.errors},
}
response = await self._dry_run_response(
method_upper,
normalized_endpoint,
kwargs.get("json"),
)
self._log_dry_run("OK", method_upper, normalized_endpoint, kwargs.get("json"))
return {"response": response, "dryRun": True}
@staticmethod
def _normalize_endpoint(endpoint: str) -> str:
return f"/{str(endpoint or '').lstrip('/')}"
@staticmethod
def _payload_preview(payload: Any) -> str:
try:
text = json.dumps(payload, ensure_ascii=False, default=str, sort_keys=True)
except Exception:
text = str(payload)
if len(text) > 1200:
return f"{text[:1200]}..."
return text
def _log_dry_run(
self,
status: str,
method: str,
endpoint: str,
payload: Any,
*,
errors: Optional[List[str]] = None,
) -> None:
logger.info(
"[PANEL DRY-RUN %s] would %s %s payload=%s%s",
status,
method,
endpoint,
self._payload_preview(payload),
f" errors={errors}" if errors else "",
)
@staticmethod
def _should_intercept(method: str, endpoint: str) -> bool:
if method in PanelApiService._SAFE_METHODS:
return False
if method == "POST" and endpoint in _LIVE_POST_ENDPOINTS:
return False
return True
async def _validate_dry_run_request(
self,
method: str,
endpoint: str,
payload: Any,
) -> _DryRunValidation:
validation = _DryRunValidation()
data = payload if isinstance(payload, dict) else {}
if payload is not None and not isinstance(payload, dict):
validation.add("JSON payload must be an object.")
return validation
if method == "POST" and endpoint == "/users":
await self._validate_create_user_payload(data, validation)
return validation
if method == "PATCH" and endpoint == "/users":
await self._validate_update_user_payload(data, validation)
return validation
if method == "POST" and (match := _USER_ACTION_RE.match(endpoint)):
user_uuid = match.group("user_uuid")
self._validate_non_empty_string(user_uuid, "user uuid", validation)
await self._validate_remote_user(user_uuid, validation)
return validation
if method == "DELETE" and endpoint.startswith("/users/"):
user_uuid = endpoint.removeprefix("/users/").strip()
self._validate_non_empty_string(user_uuid, "user uuid", validation)
await self._validate_remote_user(user_uuid, validation)
return validation
if method == "POST" and endpoint == "/hwid/devices/delete":
user_uuid = self._validate_non_empty_string(
data.get("userUuid"),
"userUuid",
validation,
)
self._validate_non_empty_string(data.get("hwid"), "hwid", validation)
await self._validate_remote_user(user_uuid, validation)
return validation
if match := _INTERNAL_SQUAD_BULK_RE.match(endpoint):
squad_uuid = match.group("squad_uuid")
self._validate_non_empty_string(squad_uuid, "squad uuid", validation)
user_uuids = self._validate_string_list(data.get("userUuids"), "userUuids", validation)
if not user_uuids:
user_uuids = self._validate_string_list(data.get("users"), "users", validation)
await self._validate_remote_squads([squad_uuid], validation)
for user_uuid in user_uuids:
await self._validate_remote_user(user_uuid, validation)
return validation
if payload is None:
return validation
self._validate_json_serializable(payload, validation)
return validation
async def _validate_create_user_payload(
self,
payload: Dict[str, Any],
validation: _DryRunValidation,
) -> None:
username = self._validate_non_empty_string(payload.get("username"), "username", validation)
if username and (
not (3 <= len(username) <= 36) or not re.match(r"^[A-Za-z0-9_-]+$", username)
):
validation.add("username must be 3-36 chars and contain only A-Z, 0-9, _ or -.")
self._validate_user_mutation_payload(payload, validation, require_uuid=False)
await self._validate_remote_squads(
self._validate_string_list(
payload.get("activeInternalSquads"),
"activeInternalSquads",
validation,
required=False,
),
validation,
)
if not bool(getattr(self.settings, "PANEL_DRY_RUN_SYNTHETIC_CREATE", True)):
validation.add("PANEL_DRY_RUN_SYNTHETIC_CREATE is disabled.")
if self._remote_validation_enabled and username:
await self._validate_create_uniqueness(payload, validation)
async def _validate_update_user_payload(
self,
payload: Dict[str, Any],
validation: _DryRunValidation,
) -> None:
user_uuid = self._validate_non_empty_string(payload.get("uuid"), "uuid", validation)
self._validate_user_mutation_payload(payload, validation, require_uuid=True)
await self._validate_remote_user(user_uuid, validation)
await self._validate_remote_squads(
self._validate_string_list(
payload.get("activeInternalSquads"),
"activeInternalSquads",
validation,
required=False,
),
validation,
)
def _validate_user_mutation_payload(
self,
payload: Dict[str, Any],
validation: _DryRunValidation,
*,
require_uuid: bool,
) -> None:
if require_uuid:
self._validate_non_empty_string(payload.get("uuid"), "uuid", validation)
if "expireAt" in payload:
self._validate_datetime(payload.get("expireAt"), "expireAt", validation)
if "trafficLimitBytes" in payload:
self._validate_non_negative_int(
payload.get("trafficLimitBytes"),
"trafficLimitBytes",
validation,
)
if "trafficLimitStrategy" in payload:
strategy = self._validate_non_empty_string(
payload.get("trafficLimitStrategy"),
"trafficLimitStrategy",
validation,
)
if strategy and strategy.upper() not in _KNOWN_TRAFFIC_STRATEGIES:
validation.add(f"trafficLimitStrategy {strategy!r} is not supported.")
if "hwidDeviceLimit" in payload:
self._validate_non_negative_int(
payload.get("hwidDeviceLimit"),
"hwidDeviceLimit",
validation,
)
if "telegramId" in payload:
self._validate_positive_int(payload.get("telegramId"), "telegramId", validation)
if "email" in payload and payload.get("email") is not None:
self._validate_non_empty_string(payload.get("email"), "email", validation)
if "externalSquadUuid" in payload and payload.get("externalSquadUuid") is not None:
self._validate_non_empty_string(
payload.get("externalSquadUuid"),
"externalSquadUuid",
validation,
)
self._validate_json_serializable(payload, validation)
@property
def _remote_validation_enabled(self) -> bool:
return bool(getattr(self.settings, "PANEL_DRY_RUN_VALIDATE_REMOTE", True))
async def _validate_remote_user(
self,
user_uuid: Optional[str],
validation: _DryRunValidation,
) -> Optional[Dict[str, Any]]:
if not user_uuid or not self._remote_validation_enabled:
return self._synthetic_users.get(str(user_uuid or ""))
user = self._synthetic_users.get(str(user_uuid))
if user:
return user
try:
user = await super().get_user_by_uuid(str(user_uuid), log_response=False)
except Exception as exc:
validation.add(f"failed to validate panel user {user_uuid}: {type(exc).__name__}")
return None
if not user:
validation.add(f"panel user {user_uuid} was not found.")
return user
async def _validate_remote_squads(
self,
squad_uuids: List[str],
validation: _DryRunValidation,
) -> None:
if not squad_uuids or not self._remote_validation_enabled:
return
try:
squads = await super().get_internal_squads()
except Exception as exc:
validation.add(f"failed to validate panel squads: {type(exc).__name__}")
return
if squads is None:
validation.add("failed to validate panel squads: empty panel response.")
return
known = {
str(squad.get("uuid") or squad.get("id") or "").strip()
for squad in squads
if isinstance(squad, dict)
}
missing = sorted({squad_uuid for squad_uuid in squad_uuids if squad_uuid not in known})
if missing:
validation.add(f"panel squads were not found: {', '.join(missing)}.")
async def _validate_create_uniqueness(
self,
payload: Dict[str, Any],
validation: _DryRunValidation,
) -> None:
checks = (
("username", "username", payload.get("username")),
("telegramId", "telegram_id", payload.get("telegramId")),
("email", "email", payload.get("email")),
)
for label, argument_name, value in checks:
if value in (None, ""):
continue
try:
users = await super().get_users_by_filter(**{argument_name: value})
except Exception as exc:
validation.add(f"failed to validate unique {label}: {type(exc).__name__}")
continue
if users:
validation.add(f"panel user with {label} {value!r} already exists.")
@staticmethod
def _validate_non_empty_string(
value: Any,
name: str,
validation: _DryRunValidation,
) -> Optional[str]:
if not isinstance(value, str) or not value.strip():
validation.add(f"{name} must be a non-empty string.")
return None
return value.strip()
@staticmethod
def _validate_string_list(
value: Any,
name: str,
validation: _DryRunValidation,
*,
required: bool = True,
) -> List[str]:
if value is None:
if required:
validation.add(f"{name} must be a list of strings.")
return []
if not isinstance(value, list):
validation.add(f"{name} must be a list of strings.")
return []
result = []
for item in value:
if not isinstance(item, str) or not item.strip():
validation.add(f"{name} contains an empty or non-string value.")
continue
result.append(item.strip())
return result
@staticmethod
def _validate_non_negative_int(
value: Any,
name: str,
validation: _DryRunValidation,
) -> None:
try:
parsed = int(value)
except (TypeError, ValueError):
validation.add(f"{name} must be an integer.")
return
if parsed < 0:
validation.add(f"{name} must be >= 0.")
@staticmethod
def _validate_positive_int(value: Any, name: str, validation: _DryRunValidation) -> None:
try:
parsed = int(value)
except (TypeError, ValueError):
validation.add(f"{name} must be an integer.")
return
if parsed <= 0:
validation.add(f"{name} must be > 0.")
@staticmethod
def _validate_datetime(value: Any, name: str, validation: _DryRunValidation) -> None:
if not isinstance(value, str) or not value.strip():
validation.add(f"{name} must be an ISO datetime string.")
return
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
validation.add(f"{name} must be a valid ISO datetime string.")
@staticmethod
def _validate_json_serializable(value: Any, validation: _DryRunValidation) -> None:
try:
json.dumps(value, default=str)
except (TypeError, ValueError):
validation.add("payload must be JSON serializable.")
async def _dry_run_response(
self,
method: str,
endpoint: str,
payload: Any,
) -> Dict[str, Any]:
data = payload if isinstance(payload, dict) else {}
if method == "POST" and endpoint == "/users":
return self._dry_run_create_user_response(data)
if method == "PATCH" and endpoint == "/users":
return await self._dry_run_patch_user_response(data)
if method == "POST" and (match := _USER_ACTION_RE.match(endpoint)):
return self._dry_run_user_action_response(
match.group("user_uuid"),
match.group("action"),
)
if method == "DELETE" and endpoint.startswith("/users/"):
return {"uuid": endpoint.removeprefix("/users/"), "deleted": True, "dryRun": True}
if method == "POST" and endpoint == "/hwid/devices/delete":
return {"userUuid": data.get("userUuid"), "hwid": data.get("hwid"), "dryRun": True}
if match := _INTERNAL_SQUAD_BULK_RE.match(endpoint):
return {
"squadUuid": match.group("squad_uuid"),
"action": match.group("action"),
"users": data.get("userUuids") or data.get("users") or [],
"dryRun": True,
}
return {"dryRun": True}
def _dry_run_create_user_response(self, payload: Dict[str, Any]) -> Dict[str, Any]:
identity = ":".join(
str(payload.get(key) or "") for key in ("username", "telegramId", "email")
)
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_URL, f"remnawave-minishop:dry-run:{identity}"))
short_uuid = user_uuid.split("-")[0]
response = {
**payload,
"uuid": user_uuid,
"shortUuid": short_uuid,
"subscriptionUuid": short_uuid,
"subscriptionUrl": self._subscription_url(short_uuid),
"dryRun": True,
}
self._synthetic_users[user_uuid] = response
return response
async def _dry_run_patch_user_response(self, payload: Dict[str, Any]) -> Dict[str, Any]:
user_uuid = str(payload.get("uuid") or "")
existing = self._synthetic_users.get(user_uuid)
if not existing and self._remote_validation_enabled:
try:
existing = await super().get_user_by_uuid(user_uuid, log_response=False)
except Exception:
existing = None
response = {**(existing or {"uuid": user_uuid}), **payload, "dryRun": True}
if user_uuid in self._synthetic_users:
self._synthetic_users[user_uuid] = response
return response
@staticmethod
def _dry_run_user_action_response(user_uuid: str, action: str) -> Dict[str, Any]:
response: Dict[str, Any] = {"uuid": user_uuid, "action": action, "dryRun": True}
if action == "enable":
response["status"] = "ACTIVE"
elif action == "disable":
response["status"] = "DISABLED"
elif action == "reset-traffic":
response["userTraffic"] = {"usedTrafficBytes": 0}
return response
def _subscription_url(self, short_uuid: str) -> Optional[str]:
if not self.settings.PANEL_API_URL:
return None
return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid}"
+62
View File
@@ -287,6 +287,30 @@ class Settings(BaseSettings):
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501
)
APP_RUNTIME_MODE: str = Field(
default="production",
description="Runtime profile: production, development, staging or test.",
)
PANEL_WRITE_MODE: str = Field(
default="auto",
description=(
"Panel write behavior: auto uses dry-run in development/test runtimes, "
"live always writes to Remnawave, dry_run validates and logs mutations only."
),
)
PANEL_DRY_RUN_VALIDATE_REMOTE: bool = Field(
default=True,
description=(
"When panel dry-run is enabled, validate referenced users and squads "
"via live GET requests."
),
)
PANEL_DRY_RUN_SYNTHETIC_CREATE: bool = Field(
default=True,
description=(
"When panel dry-run is enabled, return synthetic users for create-user attempts."
),
)
PANEL_API_URL: Optional[str] = None
PANEL_API_KEY: Optional[str] = None
USER_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=0.0)
@@ -560,6 +584,17 @@ class Settings(BaseSettings):
ids = self.ADMIN_IDS
return ids[0] if ids else None
@computed_field
@property
def panel_dry_run_enabled(self) -> bool:
mode = str(self.PANEL_WRITE_MODE or "auto").strip().lower().replace("-", "_")
if mode == "dry_run":
return True
if mode == "live":
return False
runtime = str(self.APP_RUNTIME_MODE or "production").strip().lower()
return runtime in {"dev", "development", "local", "test", "testing"}
@computed_field
@property
def trial_traffic_limit_bytes(self) -> int:
@@ -1025,6 +1060,28 @@ class Settings(BaseSettings):
return None
return v
@field_validator("APP_RUNTIME_MODE", mode="before")
@classmethod
def normalize_app_runtime_mode(cls, v):
value = str(v or "production").strip().lower().replace("-", "_")
if not value:
return "production"
aliases = {
"prod": "production",
"dev": "development",
"local_dev": "development",
"testing": "test",
}
return aliases.get(value, value)
@field_validator("PANEL_WRITE_MODE", mode="before")
@classmethod
def validate_panel_write_mode(cls, v):
value = str(v or "auto").strip().lower().replace("-", "_")
if value not in {"auto", "live", "dry_run"}:
raise ValueError("PANEL_WRITE_MODE must be one of: auto, live, dry_run")
return value
# Notification types
LOG_NEW_USERS: bool = Field(
default=True, description="Send notifications for new user registrations"
@@ -1066,6 +1123,11 @@ def get_settings() -> Settings:
logging.warning(
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
)
if _settings_instance.panel_dry_run_enabled:
logging.warning(
"PANEL_WRITE_MODE dry-run is enabled: Remnawave write requests will be "
"validated and logged without changing panel users."
)
if not os.getenv("WEBAPP_SESSION_SECRET"):
logging.warning(
"WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only." # noqa: E501