Service de Protection VPN Automatisé avec FastAPI, Marzban et Intégration Docker
Un backend FastAPI s'intègre à Marzban pour la création d'utilisateurs VPN, YooKassa pour les paiements et Resend pour les e-mails. Les utilisateurs s'inscrivent, vérifient leur adresse e-mail, paient et reçoivent instantanément une clé vless. L'architecture asynchrone SQLAlchemy permet un traitement parallèle des webhooks, des requêtes API Marzban et de l'envoi d'e-mails sans blocage.
Stack technique : Python 3.11, FastAPI, PostgreSQL, React 18 + Vite + Tailwind, XRay-core via Marzban, Docker Compose + Nginx + Cloudflare.
Architecture du Système
Le trafic passe par Cloudflare → Nginx : React statique sur :3000, API sur :8080. Les routeurs FastAPI (/auth, /configs, /payment) interagissent avec PostgreSQL, l'API REST Marzban, Resend et YooKassa/Plisio.
Marzban s'exécute sur un hôte en dehors de Docker ; les conteneurs communiquent via une interface bridge.
Utilisateur
↓ HTTPS
Cloudflare
↓
Nginx
/ → frontend:3000
/api/ → backend:8080
↓
FastAPI → PostgreSQL | Marzban | Resend | YooKassa
Modèles de Base de Données
Entités clés :
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(unique=True, index=True)
hashed_password: Mapped[str]
is_verified: Mapped[bool] = mapped_column(default=False)
marzban_username: Mapped[str | None] = mapped_column(unique=True, nullable=True)
referral_code: Mapped[str] = mapped_column(unique=True, default=lambda: secrets.token_urlsafe(8))
trial_used: Mapped[bool] = mapped_column(default=False)
email_verify_token: Mapped[str | None] = mapped_column(nullable=True, index=True)
class Subscription(Base):
__tablename__ = "subscriptions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
plan: Mapped[str]
status: Mapped[str] # active | expired | pending
devices: Mapped[int] = mapped_column(default=1)
started_at: Mapped[datetime] = mapped_column(server_default=func.now())
expires_at: Mapped[datetime]
marzban_expire_ts: Mapped[int]
class Payment(Base):
__tablename__ = "payments"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
provider: Mapped[str] # yookassa | plisio
external_id: Mapped[str]
amount: Mapped[float]
status: Mapped[str] # pending | paid | failed
plan: Mapped[str]
months: Mapped[int]
Intégration Marzban
Mise en Cache du Jeton API
Le jeton Marzban expire après une heure. Un cache de 55 minutes avec asyncio.Lock évite les conditions de concurrence :
class MarzbanService:
def __init__(self):
self._token: str | None = None
self._token_expires_at: float = 0
self._lock = asyncio.Lock()
async def get_token(self) -> str:
async with self._lock:
if self._token and time.time() < self._token_expires_at:
return self._token
self._token = await self._fetch_token()
self._token_expires_at = time.time() + 55 * 60
return self._token
Création d'un Utilisateur VPN
async def create_user(self, username: str, months: int, days: int = 0) -> dict:
expire_ts = _months_to_timestamp(months)
if days:
expire_ts = int(datetime.now(timezone.utc).timestamp()) + days * 86400
payload = {
"username": username,
"proxies": {"vless": {"flow": "xtls-rprx-vision"}},
"inbounds": {"vless": ["VLESS Reality"]},
"expire": expire_ts,
"data_limit": 0,
"data_limit_reset_strategy": "no_reset",
}
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{settings.MARZBAN_URL}/api/user",
json=payload,
headers=await self._headers(),
)
resp.raise_for_status()
return resp.json()
Marzban renvoie un lien vless://... prêt dans le champ links.
Renouvellement d'Abonnement
Pour les abonnements expirés, le renouvellement commence à partir de l'heure actuelle :
async def extend_subscription(self, username: str, months: int) -> dict:
user = await self.get_user(username)
current_expire = user.get("expire") or 0
base = max(current_expire, int(time.time()))
base_dt = datetime.fromtimestamp(base, tz=timezone.utc)
# PUT /api/user/{username} avec nouvel expire
Gestion des Webhooks YooKassa
L'idempotence est cruciale : YooKassa duplique les webhooks en cas de délais supérieurs à 10 secondes.
async def activate_subscription(payment: Payment) -> None:
async with AsyncSessionLocal() as session:
result = await session.execute(select(Payment).where(Payment.id == payment.id))
fresh_payment = result.scalar_one()
if fresh_payment.status == "paid":
logger.info(f"Paiement {payment.id} déjà activé, ignoré")
return
try:
await _do_activate(fresh_payment, session)
except Exception as e:
await session.rollback()
logger.error(f"Échec d'activation : {e}", exc_info=True)
raise
Une session DB fraîche, indépendante du webhook, est utilisée.
Liste blanche d'IP pour Cloudflare :
client_ip = request.headers.get("CF-Connecting-IP") or request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.client.host
allowed_prefixes = ("185.71.76.", "185.71.77.", "77.75.153.", "77.75.154.", "77.75.156.")
if client_ip not in {"77.75.156.11", "77.75.156.35"} and not any(client_ip.startswith(p) for p in allowed_prefixes):
raise HTTPException(403)
Essai et Vérification
3 jours de VPN après confirmation par e-mail :
- L'utilisateur s'inscrit, reçoit un jeton par e-mail.
- GET /verify-email/{token} active
is_verified=True. - Une tâche en arrière-plan crée un utilisateur Marzban avec
days=3, marquetrial_used=True.
Vérification : pas d'abonnement actif et essai non utilisé.
Abonnements Dynamiques et Appareils
Les achats d'appareils supplémentaires (+99₽ chacun) sont préservés lors du renouvellement :
extra_devices = max(0, existing_sub.devices - plan_cfg["devices"])
existing_sub.devices = plan_cfg["devices"] + extra_devices
Prix : base + extra_devices * DEVICE_ADD_PRICE.
Tâches en Arrière-plan
APScheduler pour l'automatisation :
- Rapport quotidien (8:00).
- Rappels d'expiration (9:00).
- Vérifications des paiements en attente (toutes les 5 minutes).
scheduler = AsyncIOScheduler(timezone="Europe/Moscow")
scheduler.add_job(send_daily_report, CronTrigger(hour=8, minute=0), id="daily_report")
Problèmes Docker Compose
v1.29 sur Ubuntu 22.04 plante lors de la reconstruction : KeyError: 'ContainerConfig'. Solution :
docker-compose stop service
docker-compose rm -f service
docker-compose up -d service
Points Clés
- FastAPI asynchrone + SQLAlchemy assure la concurrence sans problèmes GIL.
- L'idempotence des webhooks évite les abonnements en double.
- La mise en cache des jetons Marzban avec verrous minimise les requêtes API.
- Cloudflare nécessite une vérification explicite de CF-Connecting-IP pour YooKassa.
- L'essai via vérification e-mail limite les abus.
— Editorial Team
Aucun commentaire pour le moment.