Zurück zur Startseite

FastAPI + Marzban: Automatisierung des VPN-Dienstes

Dienst automatisiert Ausstellung von VPN-Schlüsseln: Registrierung, Zahlung, vless von Marzban. Verwendet FastAPI async, PostgreSQL, YUKassa-Webhooks mit Idempotenz, Cloudflare. Code-Details: Modelle, Caching, Testphase, Hintergrundaufgaben.

Aufbau eines VPN-Dienstes: FastAPI, Marzban, async SQLAlchemy
Advertisement 728x90

Automatisierter VPN-Schutzdienst mit FastAPI, Marzban und Docker-Integration

Ein FastAPI-Backend integriert Marzban für die VPN-Benutzererstellung, YooKassa für Zahlungen und Resend für E-Mails. Benutzer registrieren sich, bestätigen ihre E-Mail-Adresse, bezahlen und erhalten sofort einen vless-Schlüssel. Die asynchrone SQLAlchemy-Architektur ermöglicht die parallele Verarbeitung von Webhooks, Marzban-API-Anfragen und E-Mail-Versand ohne Blockaden.

Tech-Stack: Python 3.11, FastAPI, PostgreSQL, React 18 + Vite + Tailwind, XRay-core über Marzban, Docker Compose + Nginx + Cloudflare.

Systemarchitektur

Der Datenverkehr läuft über Cloudflare → Nginx: statisches React auf :3000, API auf :8080. FastAPI-Router (/auth, /configs, /payment) interagieren mit PostgreSQL, der Marzban-REST-API, Resend und YooKassa/Plisio.

Google AdInline article slot

Marzban läuft auf einem Host außerhalb von Docker; Container kommunizieren über eine Bridge-Schnittstelle.

Benutzer
    ↓ HTTPS
Cloudflare
    ↓
Nginx
  / → Frontend:3000
  /api/ → Backend:8080
    ↓
FastAPI → PostgreSQL | Marzban | Resend | YooKassa

Datenbankmodelle

Wichtige Entitäten:

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]

Marzban-Integration

API-Token-Caching

Der Marzban-Token läuft nach einer Stunde ab. Ein 55-minütiger Cache mit asyncio.Lock verhindert Race Conditions:

Google AdInline article slot
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

Erstellen eines VPN-Benutzers

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 gibt einen fertigen vless://... im links-Feld zurück.

Abonnementverlängerung

Für abgelaufene Abonnements beginnt die Verlängerung ab der aktuellen Zeit:

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} mit neuem expire

YooKassa-Webhook-Verarbeitung

Idempotenz ist entscheidend: YooKassa dupliziert Webhooks bei Verzögerungen über 10 Sekunden.

Google AdInline article slot
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"Payment {payment.id} already activated, skipping")
            return
        try:
            await _do_activate(fresh_payment, session)
        except Exception as e:
            await session.rollback()
            logger.error(f"Activation failed: {e}", exc_info=True)
            raise

Eine frische DB-Sitzung, unabhängig vom Webhook, wird verwendet.

IP-Whitelist für 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)

Testphase und Verifizierung

3 Tage VPN nach E-Mail-Bestätigung:

  • Benutzer registriert sich, erhält einen Token per E-Mail.
  • GET /verify-email/{token} aktiviert is_verified=True.
  • Ein Hintergrundtask erstellt einen Marzban-Benutzer mit days=3, setzt trial_used=True.

Prüfung: Kein aktives Abonnement und Testphase nicht genutzt.

Dynamische Abonnements und Geräte

Zusätzliche Gerätekäufe (+99₽ pro Stück) bleiben bei Verlängerung erhalten:

extra_devices = max(0, existing_sub.devices - plan_cfg["devices"])
existing_sub.devices = plan_cfg["devices"] + extra_devices

Preis: Basis + extra_devices * DEVICE_ADD_PRICE.

Hintergrundtasks

APScheduler für Automatisierung:

  • Täglicher Bericht (8:00).
  • Ablauf-Erinnerungen (9:00).
  • Prüfung ausstehender Zahlungen (alle 5 Minuten).
scheduler = AsyncIOScheduler(timezone="Europe/Moscow")
scheduler.add_job(send_daily_report, CronTrigger(hour=8, minute=0), id="daily_report")

Docker Compose-Probleme

v1.29 auf Ubuntu 22.04 stürzt beim Rebuild ab: KeyError: 'ContainerConfig'. Lösung:

docker-compose stop service
docker-compose rm -f service
docker-compose up -d service

Wichtige Erkenntnisse

  • Asynchrones FastAPI + SQLAlchemy gewährleistet Nebenläufigkeit ohne GIL-Probleme.
  • Webhook-Idempotenz verhindert doppelte Abonnements.
  • Marzban-Token-Caching mit Locks minimiert API-Anfragen.
  • Cloudflare erfordert explizite CF-Connecting-IP-Verifizierung für YooKassa.
  • Testphase per E-Mail-Verifizierung begrenzt Missbrauch.

— Editorial Team

Advertisement 728x90

Weiterlesen