From 6a26c0c4e44f784793f52f0b8c7422376a460325 Mon Sep 17 00:00:00 2001 From: kaffa Date: Mon, 9 Feb 2026 11:51:03 +0900 Subject: [PATCH] Add REST API server, Docker support, and CI pipeline - Add FastAPI-based REST API server (api_server.py) - Add Dockerfile and docker-compose.yaml for containerized deployment - Add Gitea Actions CI workflow for building and pushing images - Refactor CLI to support dual-server SSH (bouncer + crowdsec) - Update dependencies with FastAPI and uvicorn - Update CLAUDE.md and README.md with full documentation Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 25 ++ .gitea/workflows/build.yaml | 54 ++++ .gitignore | 5 + CLAUDE.md | 118 +++++++-- Dockerfile | 53 ++++ README.md | 177 ++++++++++++++ api_server.py | 474 ++++++++++++++++++++++++++++++++++++ cf_bouncer.py | 151 +++++++++--- docker-compose.yaml | 47 ++++ pyproject.toml | 6 +- uv.lock | 165 ++++++++++++- 11 files changed, 1219 insertions(+), 56 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/build.yaml create mode 100644 Dockerfile create mode 100644 api_server.py create mode 100644 docker-compose.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b9339eb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*.egg-info +.venv +.python-version + +# IDE +.vscode +.idea + +# Documentation +*.md +!README.md + +# Claude +.claude + +# Local files +*.log +backups/ diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..8c5703b --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,54 @@ +name: Build and Push Container Image + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + +env: + REGISTRY: gitea.anvil.it.com + IMAGE_NAME: ${{ gitea.repository }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build image + run: | + podman build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }} . + podman tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + - name: Tag version + if: startsWith(gitea.ref, 'refs/tags/v') + run: | + VERSION=${GITEA_REF#refs/tags/} + podman tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION} + + - name: Login to Registry + if: gitea.event_name != 'pull_request' + run: | + podman login ${{ env.REGISTRY }} \ + -u ${{ gitea.actor }} \ + -p ${{ secrets.GITEA_TOKEN }} + + - name: Push image + if: gitea.event_name != 'pull_request' + run: | + podman push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }} + podman push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + - name: Push version tag + if: startsWith(gitea.ref, 'refs/tags/v') + run: | + VERSION=${GITEA_REF#refs/tags/} + podman push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION} diff --git a/.gitignore b/.gitignore index 2079493..c9a171e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,8 @@ wheels/ # Python version file .python-version + +# Local data +.env +backups/ +*.log diff --git a/CLAUDE.md b/CLAUDE.md index 062df09..b2e09bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -CrowdSec Cloudflare Bouncer Manager (`cf-bouncer-manager`) is a Korean-language CLI tool for managing the CrowdSec Cloudflare Worker Bouncer. It manages protected domains, Turnstile CAPTCHA settings, and bouncer configuration through Incus/LXD containers. +CrowdSec Cloudflare Bouncer Manager (`cf-bouncer-manager`) is a Korean-language CLI tool for managing the CrowdSec Cloudflare Worker Bouncer. It manages protected domains, Turnstile CAPTCHA settings, and bouncer configuration via SSH connection to remote servers. ## Development Commands @@ -17,39 +17,119 @@ uv run python cf_bouncer.py [command] [options] # Install dependencies uv sync + +# Build Podman/Docker image +podman build -t cf-bouncer-manager:latest . + +# Run API server locally +uv run uvicorn api_server:app --host 0.0.0.0 --port 8000 ``` ## Architecture **Runtime Environment:** - Python 3.13+ with `uv` as the package manager -- Interacts with Incus/LXD containers: `cs-cf-worker-bouncer` (bouncer service) and `crowdsec` (security engine) -- Configuration stored at `/etc/crowdsec/bouncers/crowdsec-cloudflare-worker-bouncer.yaml` inside the container +- Connects to 2 remote servers via SSH: + - **Bouncer 서버**: crowdsec-cloudflare-worker-bouncer가 실행되는 서버 + - **CrowdSec 서버**: CrowdSec 보안 엔진이 실행되는 서버 +- Configuration stored at `/etc/crowdsec/bouncers/crowdsec-cloudflare-worker-bouncer.yaml` on the bouncer server -**Key Components in cf_bouncer.py:** +**Key Components:** + +*cf_bouncer.py (CLI):* - **CLI Framework:** Typer with Rich console output -- **Container Interaction:** `run_incus()` wrapper for all container commands with 60s timeout -- **Config Management:** YAML read/write via Incus exec, automatic backup before writes (keeps 20) +- **Remote Access:** `run_ssh()` wrapper for SSH-based remote command execution with 60s timeout +- **Config Management:** YAML read/write via SSH, automatic backup before writes (keeps 20) - **Cloudflare API:** Domain/zone queries with pagination support, 30s request timeout - **Audit Logging:** All actions logged to `~/cf-bouncer-manager/history.log` +*api_server.py (REST API):* +- **Framework:** FastAPI with automatic OpenAPI/Swagger documentation +- **Endpoints:** RESTful API for all CLI operations +- **Docs:** Swagger UI available at `/docs` + **Data Flow:** -1. CLI command → Read config from container via Incus +1. CLI command → Read config from bouncer server via SSH 2. Modify config in memory 3. Backup existing config → Write new config → Optionally restart service via `do_apply()` +## SSH 환경변수 설정 + +프로그램 실행 전 아래 환경변수를 설정해야 합니다: + +```bash +# Bouncer 서버 (필수) +export CFB_BOUNCER_HOST="hostname" # SSH 호스트 (예: 192.168.1.10 또는 user@host) +export CFB_BOUNCER_PORT="22" # SSH 포트 (기본값: 22) +export CFB_BOUNCER_USER="root" # SSH 사용자 (기본값: root) +export CFB_BOUNCER_KEY="/path/to/key" # SSH 키 경로 (선택) + +# CrowdSec 서버 (필수) +export CFB_CROWDSEC_HOST="hostname" # SSH 호스트 +export CFB_CROWDSEC_PORT="22" # SSH 포트 (기본값: 22) +export CFB_CROWDSEC_USER="root" # SSH 사용자 (기본값: root) +export CFB_CROWDSEC_KEY="/path/to/key" # SSH 키 경로 (선택) +``` + +## 서버 요구사항 + +각 서버에 sshd가 설치 및 실행되어야 합니다: + +```bash +# Debian/Ubuntu +apt install openssh-server +systemctl enable --now ssh + +# SSH 키 기반 인증 설정 (권장) +ssh-copy-id user@bouncer-server +ssh-copy-id user@crowdsec-server +``` + +## Container 실행 + +```bash +# Podman/Docker 이미지 빌드 +podman build -t cf-bouncer-manager:latest . + +# CLI 모드 +podman run --rm \ + -v ~/.ssh:/root/.ssh:ro \ + -e CFB_BOUNCER_HOST="10.253.100.131" \ + -e CFB_CROWDSEC_HOST="10.253.100.240" \ + cf-bouncer-manager:latest [command] + +# API 서버 모드 +podman run -d --rm --name cfb-api \ + --network host \ + -v ~/.ssh:/root/.ssh:ro \ + -e CFB_BOUNCER_HOST="10.253.100.131" \ + -e CFB_CROWDSEC_HOST="10.253.100.240" \ + --entrypoint uv \ + cf-bouncer-manager:latest \ + run uvicorn api_server:app --host 0.0.0.0 --port 8000 +``` + ## CLI Commands -`list`, `show`, `add`, `edit`, `remove` - Domain CRUD operations -`sync` - Bulk import all Cloudflare zones -`apply` - Restart bouncer service to apply changes -`status` - Check bouncer process and CrowdSec status -`available` - List unprotected Cloudflare domains -`logs [-f]` - View bouncer logs (with optional follow) -`decisions`, `metrics` - CrowdSec data queries -`backup`, `restore`, `diff` - Configuration backup management -`export` - Export domain list to YAML/JSON -`history` - View action history +| 명령어 | 설명 | +|--------|------| +| `list` | 보호 중인 도메인 목록 표시 | +| `show ` | 특정 도메인 상세 정보 | +| `add ` | 새 도메인 추가 | +| `edit ` | 기존 도메인 설정 수정 | +| `remove ` | 도메인 제거 | +| `sync` | Cloudflare의 모든 도메인을 보호 목록에 추가 | +| `apply` | 설정 적용 (bouncer 서비스 재시작) | +| `status` | bouncer 상태 확인 | +| `available` | Cloudflare에서 추가 가능한 도메인 목록 | +| `logs [-f]` | bouncer 로그 조회 (실시간 추적 옵션) | +| `decisions` | CrowdSec 현재 차단 결정 조회 | +| `metrics` | bouncer Prometheus 메트릭 조회 | +| `backup` | 현재 설정 백업 | +| `restore [file]` | 백업에서 설정 복원 | +| `diff [file]` | 현재 설정과 백업 비교 | +| `export` | 설정을 파일로 내보내기 | +| `history` | 변경 이력 조회 | ## Dependencies @@ -57,6 +137,6 @@ Core: `typer`, `pyyaml`, `requests`, `rich` (see pyproject.toml) ## External Requirements -- Incus/LXD with containers: `cs-cf-worker-bouncer`, `crowdsec` +- SSH access to bouncer and CrowdSec servers - Cloudflare API token configured in bouncer YAML -- Access to `/etc/crowdsec/bouncers/` directory +- Access to `/etc/crowdsec/bouncers/` directory on bouncer server diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f981ca7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1 +FROM python:3.13-slim + +# Install SSH client +RUN apt-get update && apt-get install -y --no-install-recommends \ + openssh-client \ + && rm -rf /var/lib/apt/lists/* + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Set working directory +WORKDIR /app + +# Copy dependency files first for better caching +COPY pyproject.toml uv.lock ./ + +# Install dependencies +RUN uv sync --frozen --no-dev + +# Copy application code +COPY cf_bouncer.py api_server.py cfb ./ + +# Create directories for backups and logs +RUN mkdir -p /root/cf-bouncer-manager/backups + +# SSH key mount point +VOLUME ["/root/.ssh"] + +# Environment variables for SSH connection (2 servers) +# Bouncer server (crowdsec-cloudflare-worker-bouncer) +ENV CFB_BOUNCER_HOST="" +ENV CFB_BOUNCER_PORT="22" +ENV CFB_BOUNCER_USER="root" +ENV CFB_BOUNCER_KEY="" + +# CrowdSec server +ENV CFB_CROWDSEC_HOST="" +ENV CFB_CROWDSEC_PORT="22" +ENV CFB_CROWDSEC_USER="root" +ENV CFB_CROWDSEC_KEY="" + +# API server settings +ENV CFB_API_HOST="0.0.0.0" +ENV CFB_API_PORT="8000" + +# Expose API port +EXPOSE 8000 + +# Default: CLI mode +# For API server mode, use: --entrypoint uvicorn ... api_server:app +ENTRYPOINT ["uv", "run", "python", "cf_bouncer.py"] +CMD ["--help"] diff --git a/README.md b/README.md index e69de29..eba9f48 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,177 @@ +# CrowdSec Cloudflare Bouncer Manager + +CrowdSec Cloudflare Worker Bouncer를 관리하는 CLI/API 도구입니다. + +## 기능 + +- 보호 도메인 CRUD (추가/조회/수정/삭제) +- Cloudflare 도메인 자동 동기화 +- Turnstile CAPTCHA 설정 관리 +- CrowdSec 차단 결정 조회 +- 설정 백업/복원 +- **REST API 서버 모드 지원** + +## 요구사항 + +- Python 3.13+ +- SSH 접근 가능한 2개의 서버: + - Bouncer 서버 (crowdsec-cloudflare-worker-bouncer 실행) + - CrowdSec 서버 (CrowdSec 엔진 실행) + +## 설치 + +### 로컬 실행 + +```bash +# uv 설치 (없는 경우) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# 의존성 설치 +uv sync + +# 환경변수 설정 +export CFB_BOUNCER_HOST="10.253.100.131" +export CFB_CROWDSEC_HOST="10.253.100.240" + +# CLI 실행 +uv run python cf_bouncer.py list + +# API 서버 실행 +uv run uvicorn api_server:app --host 0.0.0.0 --port 8000 +``` + +### Container 실행 (권장) + +```bash +# 이미지 빌드 +podman build -t cf-bouncer-manager:latest . + +# CLI 모드 +podman run --rm \ + -v ~/.ssh:/root/.ssh:ro \ + -e CFB_BOUNCER_HOST="10.253.100.131" \ + -e CFB_CROWDSEC_HOST="10.253.100.240" \ + cf-bouncer-manager:latest list + +# API 서버 모드 +podman run -d --rm --name cfb-api \ + --network host \ + -v ~/.ssh:/root/.ssh:ro \ + -e CFB_BOUNCER_HOST="10.253.100.131" \ + -e CFB_CROWDSEC_HOST="10.253.100.240" \ + --entrypoint uv \ + cf-bouncer-manager:latest \ + run uvicorn api_server:app --host 0.0.0.0 --port 8000 +``` + +### Alias 설정 (편의용) + +```bash +# ~/.bashrc 또는 ~/.zshrc에 추가 +alias cfb='podman run --rm -v ~/.ssh:/root/.ssh:ro -e CFB_BOUNCER_HOST="10.253.100.131" -e CFB_CROWDSEC_HOST="10.253.100.240" cf-bouncer-manager:latest' + +# 사용 +cfb list +cfb status +cfb add example.com +``` + +## 환경변수 + +| 변수 | 필수 | 기본값 | 설명 | +|------|------|--------|------| +| `CFB_BOUNCER_HOST` | O | - | Bouncer 서버 호스트 | +| `CFB_BOUNCER_PORT` | X | 22 | Bouncer 서버 SSH 포트 | +| `CFB_BOUNCER_USER` | X | root | Bouncer 서버 SSH 사용자 | +| `CFB_BOUNCER_KEY` | X | - | Bouncer 서버 SSH 키 경로 | +| `CFB_CROWDSEC_HOST` | O | - | CrowdSec 서버 호스트 | +| `CFB_CROWDSEC_PORT` | X | 22 | CrowdSec 서버 SSH 포트 | +| `CFB_CROWDSEC_USER` | X | root | CrowdSec 서버 SSH 사용자 | +| `CFB_CROWDSEC_KEY` | X | - | CrowdSec 서버 SSH 키 경로 | +| `CFB_API_HOST` | X | 0.0.0.0 | API 서버 바인드 호스트 | +| `CFB_API_PORT` | X | 8000 | API 서버 포트 | + +## CLI 명령어 + +``` +cfb list # 보호 중인 도메인 목록 +cfb show # 도메인 상세 정보 +cfb add # 새 도메인 추가 +cfb edit # 도메인 설정 수정 +cfb remove # 도메인 제거 +cfb sync # Cloudflare 도메인 동기화 +cfb apply # 설정 적용 (서비스 재시작) +cfb status # bouncer 상태 확인 +cfb available # 추가 가능한 도메인 목록 +cfb logs [-f] # 로그 조회 (-f: 실시간) +cfb decisions # CrowdSec 차단 결정 조회 +cfb metrics # Prometheus 메트릭 조회 +cfb backup # 설정 백업 +cfb restore [file] # 설정 복원 +cfb diff [file] # 설정 비교 +cfb export # 설정 내보내기 +cfb history # 변경 이력 조회 +``` + +## REST API + +API 서버 실행 후 `http://localhost:8000/docs`에서 Swagger UI를 확인할 수 있습니다. + +### 주요 엔드포인트 + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/health` | 헬스 체크 | +| GET | `/domains` | 보호 중인 도메인 목록 | +| GET | `/domains/{domain}` | 도메인 상세 정보 | +| POST | `/domains` | 새 도메인 추가 | +| PUT | `/domains/{domain}` | 도메인 설정 수정 | +| DELETE | `/domains/{domain}` | 도메인 제거 | +| POST | `/apply` | 설정 적용 | +| GET | `/status` | bouncer 상태 확인 | +| GET | `/available` | 추가 가능한 도메인 목록 | +| GET | `/decisions` | CrowdSec 차단 결정 조회 | +| POST | `/sync` | Cloudflare 도메인 동기화 | +| POST | `/backup` | 설정 백업 | +| GET | `/history` | 변경 이력 조회 | + +### API 사용 예시 + +```bash +# 도메인 목록 조회 +curl http://localhost:8000/domains + +# 도메인 추가 +curl -X POST http://localhost:8000/domains \ + -H "Content-Type: application/json" \ + -d '{"domain": "example.com", "action": "captcha", "turnstile_enabled": true}' + +# 도메인 수정 +curl -X PUT http://localhost:8000/domains/example.com \ + -H "Content-Type: application/json" \ + -d '{"action": "ban"}' + +# 설정 적용 +curl -X POST http://localhost:8000/apply + +# 상태 확인 +curl http://localhost:8000/status +``` + +## 서버 설정 + +각 서버에 sshd가 설치되어 있어야 합니다: + +```bash +# Debian/Ubuntu +apt install openssh-server +systemctl enable --now ssh + +# SSH 키 복사 +ssh-copy-id root@bouncer-server +ssh-copy-id root@crowdsec-server +``` + +## 라이선스 + +MIT diff --git a/api_server.py b/api_server.py new file mode 100644 index 0000000..3177b4a --- /dev/null +++ b/api_server.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +"""CrowdSec Cloudflare Worker Bouncer 도메인 관리 API 서버""" + +import os +from contextlib import asynccontextmanager +from typing import Optional + +from fastapi import FastAPI, HTTPException, Query +from pydantic import BaseModel, Field + +# cf_bouncer 모듈에서 핵심 함수들 import +from cf_bouncer import ( + __version__, + SERVER_BOUNCER, + SERVER_CROWDSEC, + SSH_CONFIGS, + BouncerError, + get_config, + get_zones, + get_cf_token, + get_zone_id, + get_all_zones_from_cf, + find_zone_by_domain, + extract_domain, + save_config, + backup_config, + do_apply, + run_ssh, + log_action, + BACKUP_DIR, + LOG_FILE, +) + + +# ============ Pydantic Models ============ + +class DomainBase(BaseModel): + domain: str + action: str = "captcha" + turnstile_enabled: bool = True + turnstile_mode: str = "managed" + + +class DomainCreate(DomainBase): + pass + + +class DomainUpdate(BaseModel): + action: Optional[str] = None + turnstile_enabled: Optional[bool] = None + turnstile_mode: Optional[str] = None + + +class DomainResponse(BaseModel): + domain: str + zone_id: str + action: str + turnstile_enabled: bool + turnstile_mode: str + routes: list[str] + + +class StatusResponse(BaseModel): + bouncer_running: bool + bouncer_pids: list[str] + protected_domains: int + crowdsec_status: Optional[str] = None + + +class MessageResponse(BaseModel): + message: str + success: bool = True + + +class ErrorResponse(BaseModel): + detail: str + success: bool = False + + +# ============ FastAPI App ============ + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: SSH 설정 확인 + bouncer_host = SSH_CONFIGS[SERVER_BOUNCER].get("host") + crowdsec_host = SSH_CONFIGS[SERVER_CROWDSEC].get("host") + + if not bouncer_host or not crowdsec_host: + print("WARNING: SSH 설정이 완료되지 않았습니다.") + print(f" CFB_BOUNCER_HOST: {bouncer_host or '(not set)'}") + print(f" CFB_CROWDSEC_HOST: {crowdsec_host or '(not set)'}") + else: + print(f"SSH 설정 확인:") + print(f" Bouncer: {bouncer_host}") + print(f" CrowdSec: {crowdsec_host}") + + yield + # Shutdown + print("API 서버 종료") + + +app = FastAPI( + title="CrowdSec Cloudflare Bouncer Manager API", + description="CrowdSec Cloudflare Worker Bouncer 도메인 관리 API", + version=__version__, + lifespan=lifespan, +) + + +# ============ API Endpoints ============ + +@app.get("/", response_model=MessageResponse) +async def root(): + """API 상태 확인""" + return MessageResponse(message=f"CrowdSec Cloudflare Bouncer Manager API v{__version__}") + + +@app.get("/health") +async def health(): + """헬스 체크""" + return {"status": "healthy", "version": __version__} + + +@app.get("/domains", response_model=list[DomainResponse]) +async def list_domains(): + """보호 중인 도메인 목록""" + try: + config = get_config() + zones = get_zones(config) + + result = [] + for zone in zones: + routes = zone.get("routes_to_protect", []) + turnstile = zone.get("turnstile", {}) + result.append(DomainResponse( + domain=extract_domain(routes[0]) if routes else "N/A", + zone_id=zone.get("zone_id", ""), + action=zone.get("default_action", ""), + turnstile_enabled=turnstile.get("enabled", False), + turnstile_mode=turnstile.get("mode", ""), + routes=routes, + )) + + return result + except BouncerError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/domains/{domain}", response_model=DomainResponse) +async def get_domain(domain: str): + """특정 도메인 상세 정보""" + try: + config = get_config() + zones = get_zones(config) + idx, zone = find_zone_by_domain(zones, domain) + + if zone is None: + raise HTTPException(status_code=404, detail=f"{domain}을 찾을 수 없습니다") + + routes = zone.get("routes_to_protect", []) + turnstile = zone.get("turnstile", {}) + + return DomainResponse( + domain=extract_domain(routes[0]) if routes else "N/A", + zone_id=zone.get("zone_id", ""), + action=zone.get("default_action", ""), + turnstile_enabled=turnstile.get("enabled", False), + turnstile_mode=turnstile.get("mode", ""), + routes=routes, + ) + except BouncerError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/domains", response_model=MessageResponse) +async def add_domain(data: DomainCreate, auto_apply: bool = Query(False)): + """새 도메인 추가""" + try: + config = get_config() + token = get_cf_token(config) + + if not token: + raise HTTPException(status_code=400, detail="Cloudflare API 토큰을 찾을 수 없습니다") + + zones = get_zones(config) + idx, _ = find_zone_by_domain(zones, data.domain) + if idx is not None: + raise HTTPException(status_code=409, detail=f"{data.domain}은 이미 보호 목록에 있습니다") + + zone_id = get_zone_id(data.domain, token) + if not zone_id: + raise HTTPException(status_code=404, detail=f"{data.domain}의 zone_id를 찾을 수 없습니다") + + new_zone = { + "zone_id": zone_id, + "actions": [data.action], + "default_action": data.action, + "routes_to_protect": [f"*{data.domain}/*"], + "turnstile": { + "enabled": data.turnstile_enabled, + "rotate_secret_key": True, + "rotate_secret_key_every": "168h0m0s", + "mode": data.turnstile_mode, + }, + } + + config["cloudflare_config"]["accounts"][0]["zones"].append(new_zone) + save_config(config, reason=f"add_{data.domain}") + log_action("add", data.domain) + + if auto_apply: + do_apply() + return MessageResponse(message=f"{data.domain} 추가 및 적용 완료") + + return MessageResponse(message=f"{data.domain} 추가 완료. 적용하려면 /apply 호출 필요") + + except HTTPException: + raise + except BouncerError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/domains/{domain}", response_model=MessageResponse) +async def update_domain(domain: str, data: DomainUpdate, auto_apply: bool = Query(False)): + """도메인 설정 수정""" + try: + config = get_config() + zones = get_zones(config) + idx, zone = find_zone_by_domain(zones, domain) + + if zone is None: + raise HTTPException(status_code=404, detail=f"{domain}을 찾을 수 없습니다") + + changes = [] + if data.action is not None: + zone["default_action"] = data.action + zone["actions"] = [data.action] + changes.append(f"action={data.action}") + + if data.turnstile_enabled is not None: + zone["turnstile"]["enabled"] = data.turnstile_enabled + changes.append(f"turnstile={'enabled' if data.turnstile_enabled else 'disabled'}") + + if data.turnstile_mode is not None: + zone["turnstile"]["mode"] = data.turnstile_mode + changes.append(f"mode={data.turnstile_mode}") + + if not changes: + raise HTTPException(status_code=400, detail="변경할 내용이 없습니다") + + config["cloudflare_config"]["accounts"][0]["zones"][idx] = zone + save_config(config, reason=f"edit_{domain}") + log_action("edit", f"{domain}: {', '.join(changes)}") + + if auto_apply: + do_apply() + return MessageResponse(message=f"{domain} 수정 및 적용 완료: {', '.join(changes)}") + + return MessageResponse(message=f"{domain} 수정 완료: {', '.join(changes)}") + + except HTTPException: + raise + except BouncerError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.delete("/domains/{domain}", response_model=MessageResponse) +async def remove_domain(domain: str, auto_apply: bool = Query(False)): + """도메인 제거""" + try: + config = get_config() + zones = get_zones(config) + idx, zone = find_zone_by_domain(zones, domain) + + if idx is None: + raise HTTPException(status_code=404, detail=f"{domain}을 찾을 수 없습니다") + + del config["cloudflare_config"]["accounts"][0]["zones"][idx] + save_config(config, reason=f"remove_{domain}") + log_action("remove", domain) + + if auto_apply: + do_apply() + return MessageResponse(message=f"{domain} 제거 및 적용 완료") + + return MessageResponse(message=f"{domain} 제거 완료") + + except HTTPException: + raise + except BouncerError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/apply", response_model=MessageResponse) +async def apply_config(): + """설정 적용 (bouncer 서비스 재시작)""" + try: + if do_apply(): + log_action("apply", "Service restarted via API") + return MessageResponse(message="설정 적용 완료") + else: + raise HTTPException(status_code=500, detail="bouncer 프로세스를 시작할 수 없습니다") + except BouncerError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/status", response_model=StatusResponse) +async def get_status(): + """bouncer 상태 확인""" + try: + # Bouncer 프로세스 확인 + result = run_ssh(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"], server=SERVER_BOUNCER) + bouncer_running = result.returncode == 0 + pids = result.stdout.strip().split('\n') if bouncer_running else [] + + # CrowdSec bouncer 목록 + cs_result = run_ssh(["cscli", "bouncers", "list"], server=SERVER_CROWDSEC) + cs_status = cs_result.stdout if cs_result.returncode == 0 else None + + # 보호 도메인 수 + config = get_config() + zones = get_zones(config) + + return StatusResponse( + bouncer_running=bouncer_running, + bouncer_pids=pids, + protected_domains=len(zones), + crowdsec_status=cs_status, + ) + except BouncerError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/available") +async def available_domains(): + """Cloudflare에서 추가 가능한 도메인 목록""" + try: + config = get_config() + token = get_cf_token(config) + + if not token: + raise HTTPException(status_code=400, detail="Cloudflare API 토큰을 찾을 수 없습니다") + + cf_zones = get_all_zones_from_cf(token) + protected_zones = get_zones(config) + protected_ids = {z.get("zone_id") for z in protected_zones} + + result = [] + for zone in cf_zones: + zone_id = zone.get("id", "") + result.append({ + "domain": zone.get("name"), + "zone_id": zone_id, + "status": zone.get("status"), + "protected": zone_id in protected_ids, + }) + + return { + "total": len(cf_zones), + "protected": len(protected_ids), + "zones": result, + } + except BouncerError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/decisions") +async def get_decisions(limit: int = Query(20, ge=1, le=100)): + """CrowdSec 현재 차단 결정 조회""" + try: + result = run_ssh( + ["cscli", "decisions", "list", "-o", "json", "--limit", str(limit)], + server=SERVER_CROWDSEC, + ) + + if result.returncode == 0: + import json + try: + decisions = json.loads(result.stdout) if result.stdout.strip() else [] + return {"decisions": decisions} + except json.JSONDecodeError: + return {"decisions": [], "raw": result.stdout} + else: + raise HTTPException(status_code=500, detail=f"결정 조회 실패: {result.stderr}") + except BouncerError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/sync", response_model=MessageResponse) +async def sync_domains(auto_apply: bool = Query(False)): + """Cloudflare의 모든 도메인을 보호 목록에 추가""" + try: + config = get_config() + token = get_cf_token(config) + + if not token: + raise HTTPException(status_code=400, detail="Cloudflare API 토큰을 찾을 수 없습니다") + + cf_zones = get_all_zones_from_cf(token) + protected_zones = get_zones(config) + protected_ids = {z.get("zone_id") for z in protected_zones} + + # 추가할 도메인 찾기 + to_add = [z for z in cf_zones if z.get("id") not in protected_ids and z.get("status") == "active"] + + if not to_add: + return MessageResponse(message="모든 도메인이 이미 보호 목록에 있습니다") + + added = [] + for cf_zone in to_add: + new_zone = { + "zone_id": cf_zone.get("id"), + "actions": ["captcha"], + "default_action": "captcha", + "routes_to_protect": [f"*{cf_zone.get('name')}/*"], + "turnstile": { + "enabled": True, + "rotate_secret_key": True, + "rotate_secret_key_every": "168h0m0s", + "mode": "managed", + }, + } + config["cloudflare_config"]["accounts"][0]["zones"].append(new_zone) + added.append(cf_zone.get("name")) + + save_config(config, reason="sync") + log_action("sync", f"Added {len(added)} domains: {', '.join(added)}") + + if auto_apply: + do_apply() + return MessageResponse(message=f"{len(added)}개 도메인 추가 및 적용 완료: {', '.join(added)}") + + return MessageResponse(message=f"{len(added)}개 도메인 추가 완료: {', '.join(added)}") + + except BouncerError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/backup", response_model=MessageResponse) +async def create_backup(): + """현재 설정 백업""" + try: + config = get_config() + backup_file = backup_config(config, reason="api_backup") + log_action("backup", str(backup_file)) + return MessageResponse(message=f"백업 완료: {backup_file.name}") + except BouncerError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/history") +async def get_history(lines: int = Query(20, ge=1, le=100)): + """변경 이력 조회""" + try: + if not LOG_FILE.exists(): + return {"history": []} + + content = LOG_FILE.read_text() + history_lines = content.strip().split('\n') + + return {"history": history_lines[-lines:]} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============ Main ============ + +if __name__ == "__main__": + import uvicorn + + port = int(os.environ.get("CFB_API_PORT", "8000")) + host = os.environ.get("CFB_API_HOST", "0.0.0.0") + + uvicorn.run(app, host=host, port=port) diff --git a/cf_bouncer.py b/cf_bouncer.py index 2b38e71..f663187 100755 --- a/cf_bouncer.py +++ b/cf_bouncer.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """CrowdSec Cloudflare Worker Bouncer 도메인 관리 CLI""" +import os +import shlex import subprocess from datetime import datetime from difflib import unified_diff @@ -13,20 +15,51 @@ import yaml from rich.console import Console from rich.table import Table -__version__ = "1.1.0" +__version__ = "1.3.0" app = typer.Typer(help="CrowdSec Cloudflare Bouncer 도메인 관리") console = Console() # 설정 CONFIG_PATH = "/etc/crowdsec/bouncers/crowdsec-cloudflare-worker-bouncer.yaml" -CONTAINER_NAME = "cs-cf-worker-bouncer" -CROWDSEC_CONTAINER = "crowdsec" BACKUP_DIR = Path.home() / "cf-bouncer-manager" / "backups" LOG_FILE = Path.home() / "cf-bouncer-manager" / "history.log" REQUEST_TIMEOUT = 30 SUBPROCESS_TIMEOUT = 60 +# 서버 식별자 (논리적 이름) +SERVER_BOUNCER = "bouncer" +SERVER_CROWDSEC = "crowdsec" + +# SSH 설정 (환경변수) - 각 서버별 설정 +# Bouncer 서버 (crowdsec-cloudflare-worker-bouncer가 실행되는 서버) +SSH_BOUNCER_HOST = os.environ.get("CFB_BOUNCER_HOST") # user@hostname 또는 hostname +SSH_BOUNCER_PORT = os.environ.get("CFB_BOUNCER_PORT", "22") +SSH_BOUNCER_KEY = os.environ.get("CFB_BOUNCER_KEY") # SSH 키 경로 (선택) +SSH_BOUNCER_USER = os.environ.get("CFB_BOUNCER_USER", "root") # SSH 사용자 + +# CrowdSec 서버 (CrowdSec 엔진이 실행되는 서버) +SSH_CROWDSEC_HOST = os.environ.get("CFB_CROWDSEC_HOST") +SSH_CROWDSEC_PORT = os.environ.get("CFB_CROWDSEC_PORT", "22") +SSH_CROWDSEC_KEY = os.environ.get("CFB_CROWDSEC_KEY") +SSH_CROWDSEC_USER = os.environ.get("CFB_CROWDSEC_USER", "root") + +# SSH 설정 매핑 +SSH_CONFIGS = { + SERVER_BOUNCER: { + "host": SSH_BOUNCER_HOST, + "port": SSH_BOUNCER_PORT, + "key": SSH_BOUNCER_KEY, + "user": SSH_BOUNCER_USER, + }, + SERVER_CROWDSEC: { + "host": SSH_CROWDSEC_HOST, + "port": SSH_CROWDSEC_PORT, + "key": SSH_CROWDSEC_KEY, + "user": SSH_CROWDSEC_USER, + }, +} + class BouncerError(Exception): """Bouncer 관련 에러""" @@ -41,20 +74,62 @@ def log_action(action: str, details: str = "") -> None: f.write(f"[{timestamp}] {action}: {details}\n") -def run_incus(cmd: list[str], container: str = CONTAINER_NAME, capture: bool = True, timeout: int = SUBPROCESS_TIMEOUT) -> subprocess.CompletedProcess: - """incus exec 명령 실행""" - full_cmd = ["incus", "exec", container, "--"] + cmd +def get_ssh_config(server: str) -> dict: + """서버별 SSH 설정 반환""" + config = SSH_CONFIGS.get(server) + if not config or not config.get("host"): + raise BouncerError(f"SSH 설정이 없습니다: {server}\n환경변수 CFB_{server.upper()}_HOST를 설정하세요.") + return config + + +def run_ssh(cmd: list[str], server: str = SERVER_BOUNCER, capture: bool = True, timeout: int = SUBPROCESS_TIMEOUT) -> subprocess.CompletedProcess: + """SSH를 통해 원격 서버에서 명령 실행 + + Args: + cmd: 실행할 명령어 리스트 + server: 서버 식별자 (SERVER_BOUNCER 또는 SERVER_CROWDSEC) + capture: 출력 캡처 여부 + timeout: 타임아웃 (초) + + Returns: + subprocess.CompletedProcess 결과 + """ + config = get_ssh_config(server) + + # SSH 호스트 구성 (user@host 형태 또는 host만) + host = config["host"] + if "@" not in host: + host = f"{config['user']}@{host}" + + # SSH 명령어 구성 + ssh_cmd = [ + "ssh", + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-o", "ConnectTimeout=10", + "-p", config["port"], + ] + + if config.get("key"): + ssh_cmd.extend(["-i", config["key"]]) + + # 원격 명령어 구성 + remote_cmd = " ".join(shlex.quote(c) for c in cmd) + ssh_cmd.extend([host, remote_cmd]) + try: - return subprocess.run(full_cmd, capture_output=capture, text=True, timeout=timeout) + return subprocess.run(ssh_cmd, capture_output=capture, text=True, timeout=timeout) except subprocess.TimeoutExpired: - raise BouncerError(f"명령 실행 시간 초과: {' '.join(cmd)}") + raise BouncerError(f"명령 실행 시간 초과 ({server}): {' '.join(cmd)}") except Exception as e: - raise BouncerError(f"명령 실행 실패: {e}") + raise BouncerError(f"SSH 명령 실행 실패 ({server}): {e}") def get_config() -> dict: """bouncer 설정 파일 읽기""" - result = run_incus(["cat", CONFIG_PATH]) + result = run_ssh(["cat", CONFIG_PATH], server=SERVER_BOUNCER) if result.returncode != 0: raise BouncerError(f"설정 파일을 읽을 수 없습니다: {result.stderr}") try: @@ -101,7 +176,7 @@ def save_config(config: dict, reason: str = "update") -> None: console.print(f"[dim]백업 저장: {backup_file}[/dim]") yaml_content = yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False) - result = run_incus(["sh", "-c", f"cat > {CONFIG_PATH} << 'EOFCONFIG'\n{yaml_content}\nEOFCONFIG"]) + result = run_ssh(["sh", "-c", f"cat > {CONFIG_PATH} << 'EOFCONFIG'\n{yaml_content}\nEOFCONFIG"], server=SERVER_BOUNCER) if result.returncode != 0: raise BouncerError(f"설정 파일 저장 실패: {result.stderr}") @@ -189,20 +264,20 @@ def find_zone_by_domain(zones: list[dict], domain: str) -> tuple[int, dict] | tu def do_apply() -> bool: """bouncer 서비스 재시작""" - result = run_incus(["systemctl", "restart", "crowdsec-cloudflare-worker-bouncer"]) + result = run_ssh(["systemctl", "restart", "crowdsec-cloudflare-worker-bouncer"], server=SERVER_BOUNCER) if result.returncode != 0: - run_incus(["pkill", "-f", "crowdsec-cloudflare-worker-bouncer"]) - run_incus([ + run_ssh(["pkill", "-f", "crowdsec-cloudflare-worker-bouncer"], server=SERVER_BOUNCER) + run_ssh([ "sh", "-c", f"nohup crowdsec-cloudflare-worker-bouncer -c {CONFIG_PATH} > /var/log/bouncer.log 2>&1 &" - ]) + ], server=SERVER_BOUNCER) # 잠시 대기 후 프로세스 확인 import time time.sleep(2) - result = run_incus(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"]) + result = run_ssh(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"], server=SERVER_BOUNCER) return result.returncode == 0 @@ -546,7 +621,7 @@ def apply(): try: if do_apply(): console.print("[green]✓ 설정 적용 완료[/green]") - result = run_incus(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"]) + result = run_ssh(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"], server=SERVER_BOUNCER) if result.returncode == 0: console.print(f"[green]bouncer PID: {result.stdout.strip()}[/green]") log_action("apply", "Service restarted") @@ -562,19 +637,14 @@ def apply(): def status(): """bouncer 상태 확인""" try: - result = run_incus(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"]) + result = run_ssh(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"], server=SERVER_BOUNCER) if result.returncode == 0: pids = result.stdout.strip().split('\n') console.print(f"[green]✓ bouncer 실행 중 (PID: {', '.join(pids)})[/green]") else: console.print("[red]✗ bouncer가 실행되지 않음[/red]") - result = subprocess.run( - ["incus", "exec", CROWDSEC_CONTAINER, "--", "cscli", "bouncers", "list"], - capture_output=True, - text=True, - timeout=SUBPROCESS_TIMEOUT, - ) + result = run_ssh(["cscli", "bouncers", "list"], server=SERVER_CROWDSEC) if result.returncode == 0: console.print("\n[bold]CrowdSec Bouncer 상태:[/bold]") console.print(result.stdout) @@ -636,19 +706,32 @@ def logs( ): """bouncer 로그 조회""" try: + config = get_ssh_config(SERVER_BOUNCER) + host = config["host"] + if "@" not in host: + host = f"{config['user']}@{host}" + if follow: console.print("[dim]로그 추적 중... (Ctrl+C로 종료)[/dim]\n") - subprocess.run( - ["incus", "exec", CONTAINER_NAME, "--", "journalctl", "-u", "crowdsec-cloudflare-worker-bouncer", "-f", "--no-pager"], - timeout=None, - ) + ssh_cmd = [ + "ssh", + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-p", config["port"], + ] + if config.get("key"): + ssh_cmd.extend(["-i", config["key"]]) + ssh_cmd.extend([host, "journalctl -u crowdsec-cloudflare-worker-bouncer -f --no-pager"]) + subprocess.run(ssh_cmd, timeout=None) else: # journalctl 시도, 실패시 syslog에서 grep - result = run_incus(["journalctl", "-u", "crowdsec-cloudflare-worker-bouncer", "-n", str(lines), "--no-pager"]) + result = run_ssh(["journalctl", "-u", "crowdsec-cloudflare-worker-bouncer", "-n", str(lines), "--no-pager"], server=SERVER_BOUNCER) if result.returncode == 0 and result.stdout.strip(): console.print(result.stdout) else: - result = run_incus(["sh", "-c", f"grep -i bouncer /var/log/syslog | tail -n {lines}"]) + result = run_ssh(["sh", "-c", f"grep -i bouncer /var/log/syslog | tail -n {lines}"], server=SERVER_BOUNCER) if result.returncode == 0 and result.stdout.strip(): console.print(result.stdout) else: @@ -666,9 +749,9 @@ def decisions( ): """CrowdSec 현재 차단 결정 조회""" try: - result = run_incus( + result = run_ssh( ["cscli", "decisions", "list", "-o", "raw", "--limit", str(limit)], - container=CROWDSEC_CONTAINER, + server=SERVER_CROWDSEC, ) if result.returncode == 0: if result.stdout.strip(): @@ -697,7 +780,7 @@ def metrics(): addr = prometheus_cfg.get("listen_addr", "127.0.0.1") port = prometheus_cfg.get("listen_port", "2112") - result = run_incus(["curl", "-s", f"http://{addr}:{port}/metrics"]) + result = run_ssh(["curl", "-s", f"http://{addr}:{port}/metrics"], server=SERVER_BOUNCER) if result.returncode == 0: # 주요 메트릭만 필터링 lines = result.stdout.split('\n') @@ -772,7 +855,7 @@ def restore( backup_config(current_config, reason="before_restore") yaml_content = yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False) - result = run_incus(["sh", "-c", f"cat > {CONFIG_PATH} << 'EOFCONFIG'\n{yaml_content}\nEOFCONFIG"]) + result = run_ssh(["sh", "-c", f"cat > {CONFIG_PATH} << 'EOFCONFIG'\n{yaml_content}\nEOFCONFIG"], server=SERVER_BOUNCER) if result.returncode != 0: raise BouncerError(f"복원 실패: {result.stderr}") diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..46f1518 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,47 @@ +services: + # CLI 모드 (일회성 명령 실행) + cli: + build: . + image: cf-bouncer-manager:latest + volumes: + - ~/.ssh:/root/.ssh:ro + - ./data:/root/cf-bouncer-manager + environment: + - CFB_BOUNCER_HOST=${CFB_BOUNCER_HOST:-} + - CFB_BOUNCER_PORT=${CFB_BOUNCER_PORT:-22} + - CFB_BOUNCER_USER=${CFB_BOUNCER_USER:-root} + - CFB_BOUNCER_KEY=${CFB_BOUNCER_KEY:-} + - CFB_CROWDSEC_HOST=${CFB_CROWDSEC_HOST:-} + - CFB_CROWDSEC_PORT=${CFB_CROWDSEC_PORT:-22} + - CFB_CROWDSEC_USER=${CFB_CROWDSEC_USER:-root} + - CFB_CROWDSEC_KEY=${CFB_CROWDSEC_KEY:-} + entrypoint: ["uv", "run", "python", "cf_bouncer.py"] + command: ["--help"] + + # API 서버 모드 + api: + build: . + image: cf-bouncer-manager:latest + ports: + - "8000:8000" + volumes: + - ~/.ssh:/root/.ssh:ro + - ./data:/root/cf-bouncer-manager + environment: + - CFB_BOUNCER_HOST=${CFB_BOUNCER_HOST:-} + - CFB_BOUNCER_PORT=${CFB_BOUNCER_PORT:-22} + - CFB_BOUNCER_USER=${CFB_BOUNCER_USER:-root} + - CFB_BOUNCER_KEY=${CFB_BOUNCER_KEY:-} + - CFB_CROWDSEC_HOST=${CFB_CROWDSEC_HOST:-} + - CFB_CROWDSEC_PORT=${CFB_CROWDSEC_PORT:-22} + - CFB_CROWDSEC_USER=${CFB_CROWDSEC_USER:-root} + - CFB_CROWDSEC_KEY=${CFB_CROWDSEC_KEY:-} + - CFB_API_HOST=0.0.0.0 + - CFB_API_PORT=8000 + entrypoint: ["uv", "run", "uvicorn", "api_server:app", "--host", "0.0.0.0", "--port", "8000"] + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 diff --git a/pyproject.toml b/pyproject.toml index 12186f1..4f22c60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "cf-bouncer-manager" -version = "0.1.0" -description = "Add your description here" +version = "1.3.0" +description = "CrowdSec Cloudflare Worker Bouncer 도메인 관리 CLI/API" readme = "README.md" requires-python = ">=3.13" dependencies = [ @@ -9,4 +9,6 @@ dependencies = [ "requests>=2.32.5", "rich>=14.3.2", "typer>=0.21.1", + "fastapi>=0.115.0", + "uvicorn>=0.34.0", ] diff --git a/uv.lock b/uv.lock index 27c620a..d423e27 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,36 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -13,21 +43,25 @@ wheels = [ [[package]] name = "cf-bouncer-manager" -version = "0.1.0" +version = "1.3.0" source = { virtual = "." } dependencies = [ + { name = "fastapi" }, { name = "pyyaml" }, { name = "requests" }, { name = "rich" }, { name = "typer" }, + { name = "uvicorn" }, ] [package.metadata] requires-dist = [ + { name = "fastapi", specifier = ">=0.115.0" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "requests", specifier = ">=2.32.5" }, { name = "rich", specifier = ">=14.3.2" }, { name = "typer", specifier = ">=0.21.1" }, + { name = "uvicorn", specifier = ">=0.34.0" }, ] [[package]] @@ -92,6 +126,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "fastapi" +version = "0.128.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/59/28bde150415783ff084334e3de106eb7461a57864cf69f343950ad5a5ddd/fastapi-0.128.1.tar.gz", hash = "sha256:ce5be4fa26d4ce6f54debcc873d1fb8e0e248f5c48d7502ba6c61457ab2dc766", size = 374260, upload-time = "2026-02-04T17:35:10.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/08/3953db1979ea131c68279b997c6465080118b407f0800445b843f8e164b3/fastapi-0.128.1-py3-none-any.whl", hash = "sha256:ee82146bbf91ea5bbf2bb8629e4c6e056c4fbd997ea6068501b11b15260b50fb", size = 103810, upload-time = "2026-02-04T17:35:08.02Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -122,6 +180,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -204,6 +330,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + [[package]] name = "typer" version = "0.21.1" @@ -228,6 +366,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -236,3 +386,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6 wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +]