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 <noreply@anthropic.com>
This commit is contained in:
kaffa
2026-02-09 11:51:03 +09:00
parent fee4636363
commit 6a26c0c4e4
11 changed files with 1219 additions and 56 deletions

25
.dockerignore Normal file
View File

@@ -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/

View File

@@ -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}

5
.gitignore vendored
View File

@@ -11,3 +11,8 @@ wheels/
# Python version file
.python-version
# Local data
.env
backups/
*.log

118
CLAUDE.md
View File

@@ -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 <domain>` | 특정 도메인 상세 정보 |
| `add <domain>` | 새 도메인 추가 |
| `edit <domain>` | 기존 도메인 설정 수정 |
| `remove <domain>` | 도메인 제거 |
| `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

53
Dockerfile Normal file
View File

@@ -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"]

177
README.md
View File

@@ -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 <domain> # 도메인 상세 정보
cfb add <domain> # 새 도메인 추가
cfb edit <domain> # 도메인 설정 수정
cfb remove <domain> # 도메인 제거
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

474
api_server.py Normal file
View File

@@ -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)

View File

@@ -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}")

47
docker-compose.yaml Normal file
View File

@@ -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

View File

@@ -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",
]

165
uv.lock generated
View File

@@ -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" },
]