홈으로 돌아가기

FastAPI + Marzban: VPN 서비스 자동화

서비스가 VPN 키 발급 자동화: 등록, 결제, Marzban의 vless. FastAPI async, PostgreSQL, 멱등성을 가진 YUKassa 웹훅, Cloudflare 사용. 코드 세부사항: 모델, 캐싱, 트라이얼, 백그라운드 작업.

VPN 서비스 구축: FastAPI, Marzban, async SQLAlchemy
Advertisement 728x90

FastAPI, Marzban, Docker로 자동화된 VPN 보호 서비스 구축하기

FastAPI 백엔드가 Marzban과 연동되어 VPN 사용자 생성, YooKassa로 결제 처리, Resend로 이메일 발송을 담당합니다. 사용자는 회원가입 후 이메일 인증을 거쳐 결제하면 즉시 vless 키를 받을 수 있습니다. 비동기 SQLAlchemy 아키텍처는 웹훅 처리, Marzban API 요청, 이메일 발송을 병렬로 처리하며 블로킹 없이 운영됩니다.

기술 스택: Python 3.11, FastAPI, PostgreSQL, React 18 + Vite + Tailwind, Marzban을 통한 XRay-core, Docker Compose + Nginx + Cloudflare.

시스템 아키텍처

트래픽은 Cloudflare → Nginx 순으로 흐릅니다: 정적 React는 :3000 포트, API는 :8080 포트에서 서비스됩니다. FastAPI 라우터(/auth, /configs, /payment)는 PostgreSQL, Marzban REST API, Resend, YooKassa/Plisio와 상호작용합니다.

Google AdInline article slot

Marzban은 Docker 외부의 호스트에서 실행되며, 컨테이너들은 브리지 인터페이스를 통해 통신합니다.

사용자
    ↓ HTTPS
Cloudflare
    ↓
Nginx
  / → 프론트엔드:3000
  /api/ → 백엔드:8080
    ↓
FastAPI → PostgreSQL | Marzban | Resend | YooKassa

데이터베이스 모델

주요 엔티티:

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 연동

API 토큰 캐싱

Marzban 토큰은 1시간 후 만료됩니다. asyncio.Lock을 사용한 55분 캐시로 경쟁 조건을 방지합니다:

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

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은 links 필드에 바로 사용 가능한 vless://...를 반환합니다.

구독 갱신

만료된 구독의 경우, 갱신은 현재 시간부터 시작됩니다:

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} with new expire

YooKassa 웹훅 처리

멱등성은 매우 중요합니다: YooKassa는 지연이 10초를 초과하면 웹훅을 중복 발송합니다.

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

웹훅과 독립적인 새로운 DB 세션이 사용됩니다.

Cloudflare용 IP 화이트리스트:

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)

체험판 및 인증

이메일 확인 후 3일간 VPN 사용 가능:

  • 사용자가 등록하면 이메일로 토큰을 받습니다.
  • GET /verify-email/{token}으로 is_verified=True를 활성화합니다.
  • 백그라운드 작업이 days=3으로 Marzban 사용자를 생성하고 trial_used=True로 표시합니다.

확인: 활성 구독이 없고 체험판을 사용하지 않은 경우.

동적 구독 및 디바이스

추가 디바이스 구매(+99₽ 각각)는 갱신 시 유지됩니다:

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

가격: 기본 가격 + extra_devices * DEVICE_ADD_PRICE.

백그라운드 작업

자동화를 위한 APScheduler:

  • 일일 리포트 (8:00).
  • 만료 알림 (9:00).
  • 보류 중인 결제 확인 (5분마다).
scheduler = AsyncIOScheduler(timezone="Europe/Moscow")
scheduler.add_job(send_daily_report, CronTrigger(hour=8, minute=0), id="daily_report")

Docker Compose 문제

Ubuntu 22.04의 v1.29은 재빌드 시 KeyError: 'ContainerConfig'로 충돌합니다. 해결책:

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

핵심 요약

  • 비동기 FastAPI + SQLAlchemy는 GIL 문제 없이 동시성을 보장합니다.
  • 웹훅 멱등성으로 중복 구독을 방지합니다.
  • 락을 사용한 Marzban 토큰 캐싱으로 API 요청을 최소화합니다.
  • Cloudflare는 YooKassa에 대해 명시적인 CF-Connecting-IP 검증이 필요합니다.
  • 이메일 인증을 통한 체험판으로 남용을 제한합니다.

— Editorial Team

Advertisement 728x90

다음 읽기