Initial commit: CrowdSec Cloudflare Bouncer Manager
CrowdSec Cloudflare Worker Bouncer 도메인 관리 CLI 도구 - 도메인 CRUD (list, show, add, edit, remove) - Cloudflare 동기화 (sync, available) - 설정 백업/복원 (backup, restore, diff) - 상태 모니터링 (status, logs, decisions, metrics) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
62
CLAUDE.md
Normal file
62
CLAUDE.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 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.
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Run the CLI (via uv package manager)
|
||||
uv run python cf_bouncer.py [command] [options]
|
||||
|
||||
# Or use the wrapper script
|
||||
./cfb [command] [options]
|
||||
|
||||
# Install dependencies
|
||||
uv sync
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
**Key Components in cf_bouncer.py:**
|
||||
- **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)
|
||||
- **Cloudflare API:** Domain/zone queries with pagination support, 30s request timeout
|
||||
- **Audit Logging:** All actions logged to `~/cf-bouncer-manager/history.log`
|
||||
|
||||
**Data Flow:**
|
||||
1. CLI command → Read config from container via Incus
|
||||
2. Modify config in memory
|
||||
3. Backup existing config → Write new config → Optionally restart service via `do_apply()`
|
||||
|
||||
## 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
|
||||
|
||||
## Dependencies
|
||||
|
||||
Core: `typer`, `pyyaml`, `requests`, `rich` (see pyproject.toml)
|
||||
|
||||
## External Requirements
|
||||
|
||||
- Incus/LXD with containers: `cs-cf-worker-bouncer`, `crowdsec`
|
||||
- Cloudflare API token configured in bouncer YAML
|
||||
- Access to `/etc/crowdsec/bouncers/` directory
|
||||
907
cf_bouncer.py
Executable file
907
cf_bouncer.py
Executable file
@@ -0,0 +1,907 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CrowdSec Cloudflare Worker Bouncer 도메인 관리 CLI"""
|
||||
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from difflib import unified_diff
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import requests
|
||||
import typer
|
||||
import yaml
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
__version__ = "1.1.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
|
||||
|
||||
|
||||
class BouncerError(Exception):
|
||||
"""Bouncer 관련 에러"""
|
||||
pass
|
||||
|
||||
|
||||
def log_action(action: str, details: str = "") -> None:
|
||||
"""변경 이력 로깅"""
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with open(LOG_FILE, "a") as f:
|
||||
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
|
||||
try:
|
||||
return subprocess.run(full_cmd, capture_output=capture, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise BouncerError(f"명령 실행 시간 초과: {' '.join(cmd)}")
|
||||
except Exception as e:
|
||||
raise BouncerError(f"명령 실행 실패: {e}")
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
"""bouncer 설정 파일 읽기"""
|
||||
result = run_incus(["cat", CONFIG_PATH])
|
||||
if result.returncode != 0:
|
||||
raise BouncerError(f"설정 파일을 읽을 수 없습니다: {result.stderr}")
|
||||
try:
|
||||
return yaml.safe_load(result.stdout)
|
||||
except yaml.YAMLError as e:
|
||||
raise BouncerError(f"YAML 파싱 실패: {e}")
|
||||
|
||||
|
||||
def validate_config(config: dict) -> bool:
|
||||
"""설정 유효성 검사"""
|
||||
required_keys = ["cloudflare_config", "crowdsec_config"]
|
||||
for key in required_keys:
|
||||
if key not in config:
|
||||
raise BouncerError(f"필수 설정 누락: {key}")
|
||||
|
||||
cf_config = config.get("cloudflare_config", {})
|
||||
if not cf_config.get("accounts"):
|
||||
raise BouncerError("Cloudflare 계정 설정이 없습니다")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def backup_config(config: dict, reason: str = "manual") -> Path:
|
||||
"""설정 백업"""
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_file = BACKUP_DIR / f"config_{timestamp}_{reason}.yaml"
|
||||
|
||||
yaml_content = yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
backup_file.write_text(yaml_content)
|
||||
|
||||
# 오래된 백업 정리 (최근 20개만 유지)
|
||||
backups = sorted(BACKUP_DIR.glob("config_*.yaml"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
for old_backup in backups[20:]:
|
||||
old_backup.unlink()
|
||||
|
||||
return backup_file
|
||||
|
||||
|
||||
def save_config(config: dict, reason: str = "update") -> None:
|
||||
"""bouncer 설정 파일 저장 (백업 후)"""
|
||||
validate_config(config)
|
||||
backup_file = backup_config(config, reason)
|
||||
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"])
|
||||
if result.returncode != 0:
|
||||
raise BouncerError(f"설정 파일 저장 실패: {result.stderr}")
|
||||
|
||||
log_action(reason, f"설정 파일 업데이트")
|
||||
|
||||
|
||||
def get_cf_token(config: dict) -> str:
|
||||
"""설정에서 Cloudflare API 토큰 가져오기"""
|
||||
accounts = config.get("cloudflare_config", {}).get("accounts", [])
|
||||
if accounts:
|
||||
return accounts[0].get("token", "")
|
||||
return ""
|
||||
|
||||
|
||||
def get_zone_id(domain: str, token: str) -> Optional[str]:
|
||||
"""Cloudflare API로 도메인의 zone_id 조회"""
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"https://api.cloudflare.com/client/v4/zones?name={domain}",
|
||||
headers=headers,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("success") and data.get("result"):
|
||||
return data["result"][0]["id"]
|
||||
except requests.RequestException as e:
|
||||
raise BouncerError(f"Cloudflare API 요청 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_all_zones_from_cf(token: str) -> list[dict]:
|
||||
"""Cloudflare 계정의 모든 zone 조회"""
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
all_zones = []
|
||||
page = 1
|
||||
|
||||
try:
|
||||
while True:
|
||||
resp = requests.get(
|
||||
f"https://api.cloudflare.com/client/v4/zones?per_page=50&page={page}",
|
||||
headers=headers,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
break
|
||||
data = resp.json()
|
||||
if not data.get("success"):
|
||||
break
|
||||
zones = data.get("result", [])
|
||||
if not zones:
|
||||
break
|
||||
all_zones.extend(zones)
|
||||
if len(zones) < 50:
|
||||
break
|
||||
page += 1
|
||||
except requests.RequestException as e:
|
||||
raise BouncerError(f"Cloudflare API 요청 실패: {e}")
|
||||
|
||||
return all_zones
|
||||
|
||||
|
||||
def get_zones(config: dict) -> list[dict]:
|
||||
"""설정에서 zones 목록 가져오기"""
|
||||
accounts = config.get("cloudflare_config", {}).get("accounts", [])
|
||||
if accounts:
|
||||
return accounts[0].get("zones", [])
|
||||
return []
|
||||
|
||||
|
||||
def extract_domain(route: str) -> str:
|
||||
"""routes_to_protect에서 도메인 추출"""
|
||||
return route.strip("*").strip("/")
|
||||
|
||||
|
||||
def find_zone_by_domain(zones: list[dict], domain: str) -> tuple[int, dict] | tuple[None, None]:
|
||||
"""도메인으로 zone 찾기"""
|
||||
for i, zone in enumerate(zones):
|
||||
routes = zone.get("routes_to_protect", [])
|
||||
if routes and domain in routes[0]:
|
||||
return i, zone
|
||||
return None, None
|
||||
|
||||
|
||||
def do_apply() -> bool:
|
||||
"""bouncer 서비스 재시작"""
|
||||
result = run_incus(["systemctl", "restart", "crowdsec-cloudflare-worker-bouncer"])
|
||||
|
||||
if result.returncode != 0:
|
||||
run_incus(["pkill", "-f", "crowdsec-cloudflare-worker-bouncer"])
|
||||
run_incus([
|
||||
"sh", "-c",
|
||||
f"nohup crowdsec-cloudflare-worker-bouncer -c {CONFIG_PATH} > /var/log/bouncer.log 2>&1 &"
|
||||
])
|
||||
|
||||
# 잠시 대기 후 프로세스 확인
|
||||
import time
|
||||
time.sleep(2)
|
||||
|
||||
result = run_incus(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"])
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def version_callback(value: bool):
|
||||
if value:
|
||||
console.print(f"cfb version {__version__}")
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
# ============ CLI Commands ============
|
||||
|
||||
@app.callback()
|
||||
def main(
|
||||
version: Annotated[Optional[bool], typer.Option("--version", "-v", callback=version_callback, is_eager=True, help="버전 표시")] = None,
|
||||
):
|
||||
"""CrowdSec Cloudflare Bouncer 도메인 관리 CLI"""
|
||||
pass
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_domains():
|
||||
"""보호 중인 도메인 목록 표시"""
|
||||
try:
|
||||
config = get_config()
|
||||
zones = get_zones(config)
|
||||
|
||||
table = Table(title="보호 중인 도메인")
|
||||
table.add_column("#", style="dim", width=3)
|
||||
table.add_column("도메인", style="cyan")
|
||||
table.add_column("Zone ID", style="dim")
|
||||
table.add_column("액션", style="green")
|
||||
table.add_column("Turnstile", style="yellow")
|
||||
table.add_column("모드", style="magenta")
|
||||
|
||||
for i, zone in enumerate(zones, 1):
|
||||
routes = zone.get("routes_to_protect", [])
|
||||
domain = extract_domain(routes[0]) if routes else "N/A"
|
||||
zone_id = zone.get("zone_id", "N/A")
|
||||
action = zone.get("default_action", "N/A")
|
||||
turnstile_cfg = zone.get("turnstile", {})
|
||||
turnstile = "활성" if turnstile_cfg.get("enabled") else "비활성"
|
||||
mode = turnstile_cfg.get("mode", "N/A") if turnstile_cfg.get("enabled") else "-"
|
||||
table.add_row(str(i), domain, zone_id[:12] + "...", action, turnstile, mode)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"\n총 [bold]{len(zones)}[/bold]개 도메인 보호 중")
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def show(domain: str = typer.Argument(..., help="조회할 도메인")):
|
||||
"""특정 도메인 상세 정보"""
|
||||
try:
|
||||
config = get_config()
|
||||
zones = get_zones(config)
|
||||
idx, zone = find_zone_by_domain(zones, domain)
|
||||
|
||||
if zone is None:
|
||||
console.print(f"[red]{domain}을 찾을 수 없습니다[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
routes = zone.get("routes_to_protect", [])
|
||||
turnstile = zone.get("turnstile", {})
|
||||
|
||||
console.print(f"\n[bold cyan]{'='*60}[/bold cyan]")
|
||||
console.print(f"[bold cyan]{domain} 상세 정보[/bold cyan]")
|
||||
console.print(f"[bold cyan]{'='*60}[/bold cyan]\n")
|
||||
|
||||
console.print(f"[bold]도메인:[/bold] {extract_domain(routes[0]) if routes else 'N/A'}")
|
||||
console.print(f"[bold]Zone ID:[/bold] {zone.get('zone_id', 'N/A')}")
|
||||
console.print(f"[bold]액션:[/bold] {zone.get('default_action', 'N/A')}")
|
||||
console.print(f"[bold]가능한 액션:[/bold] {', '.join(zone.get('actions', []))}")
|
||||
console.print(f"[bold]보호 경로:[/bold] {', '.join(routes)}")
|
||||
|
||||
console.print(f"\n[bold yellow]Turnstile 설정[/bold yellow]")
|
||||
console.print(f" 활성화: {turnstile.get('enabled', False)}")
|
||||
console.print(f" 모드: {turnstile.get('mode', 'N/A')}")
|
||||
console.print(f" 키 자동 교체: {turnstile.get('rotate_secret_key', False)}")
|
||||
console.print(f" 교체 주기: {turnstile.get('rotate_secret_key_every', 'N/A')}")
|
||||
console.print()
|
||||
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def add(
|
||||
domain: Annotated[str, typer.Argument(help="추가할 도메인 (예: example.com)")],
|
||||
action: Annotated[str, typer.Option(help="기본 액션 (captcha/ban)")] = "captcha",
|
||||
turnstile: Annotated[bool, typer.Option(help="Turnstile 활성화")] = True,
|
||||
mode: Annotated[str, typer.Option(help="Turnstile 모드 (managed/invisible/visible)")] = "managed",
|
||||
auto_apply: Annotated[bool, typer.Option("--auto-apply", "-a", help="추가 후 자동 적용")] = False,
|
||||
dry_run: Annotated[bool, typer.Option("--dry-run", help="실제 변경 없이 미리보기")] = False,
|
||||
):
|
||||
"""새 도메인 추가"""
|
||||
try:
|
||||
config = get_config()
|
||||
token = get_cf_token(config)
|
||||
|
||||
if not token:
|
||||
console.print("[red]Cloudflare API 토큰을 찾을 수 없습니다[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
zones = get_zones(config)
|
||||
idx, _ = find_zone_by_domain(zones, domain)
|
||||
if idx is not None:
|
||||
console.print(f"[yellow]{domain}은 이미 보호 목록에 있습니다[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"[dim]Cloudflare에서 {domain}의 zone_id 조회 중...[/dim]")
|
||||
zone_id = get_zone_id(domain, token)
|
||||
|
||||
if not zone_id:
|
||||
console.print(f"[red]{domain}의 zone_id를 찾을 수 없습니다.[/red]")
|
||||
console.print("[dim]'cfb available' 명령으로 추가 가능한 도메인을 확인하세요.[/dim]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"[green]zone_id 확인: {zone_id}[/green]")
|
||||
|
||||
new_zone = {
|
||||
"zone_id": zone_id,
|
||||
"actions": [action],
|
||||
"default_action": action,
|
||||
"routes_to_protect": [f"*{domain}/*"],
|
||||
"turnstile": {
|
||||
"enabled": turnstile,
|
||||
"rotate_secret_key": True,
|
||||
"rotate_secret_key_every": "168h0m0s",
|
||||
"mode": mode,
|
||||
},
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
console.print("\n[yellow]== DRY RUN ==[/yellow]")
|
||||
console.print(yaml.dump(new_zone, default_flow_style=False))
|
||||
return
|
||||
|
||||
config["cloudflare_config"]["accounts"][0]["zones"].append(new_zone)
|
||||
save_config(config, reason=f"add_{domain}")
|
||||
log_action("add", domain)
|
||||
|
||||
console.print(f"[green]✓ {domain} 추가 완료[/green]")
|
||||
|
||||
if auto_apply:
|
||||
console.print("[dim]설정 적용 중...[/dim]")
|
||||
if do_apply():
|
||||
console.print("[green]✓ 적용 완료[/green]")
|
||||
else:
|
||||
console.print("[yellow]적용 실패 - 수동으로 확인하세요[/yellow]")
|
||||
else:
|
||||
console.print("[yellow]적용하려면 'cfb apply' 명령을 실행하세요[/yellow]")
|
||||
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def edit(
|
||||
domain: Annotated[str, typer.Argument(help="수정할 도메인")],
|
||||
action: Annotated[Optional[str], typer.Option(help="기본 액션 변경")] = None,
|
||||
turnstile: Annotated[Optional[bool], typer.Option(help="Turnstile 활성화/비활성화")] = None,
|
||||
mode: Annotated[Optional[str], typer.Option(help="Turnstile 모드 변경")] = None,
|
||||
auto_apply: Annotated[bool, typer.Option("--auto-apply", "-a", help="수정 후 자동 적용")] = False,
|
||||
):
|
||||
"""기존 도메인 설정 수정"""
|
||||
try:
|
||||
config = get_config()
|
||||
zones = get_zones(config)
|
||||
idx, zone = find_zone_by_domain(zones, domain)
|
||||
|
||||
if zone is None:
|
||||
console.print(f"[red]{domain}을 찾을 수 없습니다[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
changes = []
|
||||
if action is not None:
|
||||
zone["default_action"] = action
|
||||
zone["actions"] = [action]
|
||||
changes.append(f"액션: {action}")
|
||||
|
||||
if turnstile is not None:
|
||||
zone["turnstile"]["enabled"] = turnstile
|
||||
changes.append(f"Turnstile: {'활성' if turnstile else '비활성'}")
|
||||
|
||||
if mode is not None:
|
||||
zone["turnstile"]["mode"] = mode
|
||||
changes.append(f"모드: {mode}")
|
||||
|
||||
if not changes:
|
||||
console.print("[yellow]변경할 내용이 없습니다. --action, --turnstile, --mode 옵션을 사용하세요.[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
config["cloudflare_config"]["accounts"][0]["zones"][idx] = zone
|
||||
save_config(config, reason=f"edit_{domain}")
|
||||
log_action("edit", f"{domain}: {', '.join(changes)}")
|
||||
|
||||
console.print(f"[green]✓ {domain} 수정 완료[/green]")
|
||||
for change in changes:
|
||||
console.print(f" - {change}")
|
||||
|
||||
if auto_apply:
|
||||
console.print("[dim]설정 적용 중...[/dim]")
|
||||
if do_apply():
|
||||
console.print("[green]✓ 적용 완료[/green]")
|
||||
else:
|
||||
console.print("[yellow]적용 실패 - 수동으로 확인하세요[/yellow]")
|
||||
else:
|
||||
console.print("[yellow]적용하려면 'cfb apply' 명령을 실행하세요[/yellow]")
|
||||
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def remove(
|
||||
domain: Annotated[str, typer.Argument(help="제거할 도메인")],
|
||||
force: Annotated[bool, typer.Option("--force", "-f", help="확인 없이 제거")] = False,
|
||||
auto_apply: Annotated[bool, typer.Option("--auto-apply", "-a", help="제거 후 자동 적용")] = False,
|
||||
):
|
||||
"""도메인 제거"""
|
||||
try:
|
||||
config = get_config()
|
||||
zones = get_zones(config)
|
||||
idx, zone = find_zone_by_domain(zones, domain)
|
||||
|
||||
if idx is None:
|
||||
console.print(f"[red]{domain}을 찾을 수 없습니다[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not force and not typer.confirm(f"{domain}을 정말 제거하시겠습니까?"):
|
||||
console.print("[yellow]취소됨[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
del config["cloudflare_config"]["accounts"][0]["zones"][idx]
|
||||
save_config(config, reason=f"remove_{domain}")
|
||||
log_action("remove", domain)
|
||||
|
||||
console.print(f"[green]✓ {domain} 제거 완료[/green]")
|
||||
|
||||
if auto_apply:
|
||||
console.print("[dim]설정 적용 중...[/dim]")
|
||||
if do_apply():
|
||||
console.print("[green]✓ 적용 완료[/green]")
|
||||
else:
|
||||
console.print("[yellow]적용 실패 - 수동으로 확인하세요[/yellow]")
|
||||
else:
|
||||
console.print("[yellow]적용하려면 'cfb apply' 명령을 실행하세요[/yellow]")
|
||||
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def sync(
|
||||
auto_apply: Annotated[bool, typer.Option("--auto-apply", "-a", help="추가 후 자동 적용")] = False,
|
||||
dry_run: Annotated[bool, typer.Option("--dry-run", help="실제 변경 없이 미리보기")] = False,
|
||||
):
|
||||
"""Cloudflare의 모든 도메인을 보호 목록에 추가"""
|
||||
try:
|
||||
config = get_config()
|
||||
token = get_cf_token(config)
|
||||
|
||||
if not token:
|
||||
console.print("[red]Cloudflare API 토큰을 찾을 수 없습니다[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print("[dim]Cloudflare에서 도메인 목록 조회 중...[/dim]")
|
||||
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 = []
|
||||
for cf_zone in cf_zones:
|
||||
if cf_zone.get("id") not in protected_ids and cf_zone.get("status") == "active":
|
||||
to_add.append(cf_zone)
|
||||
|
||||
if not to_add:
|
||||
console.print("[green]모든 도메인이 이미 보호 목록에 있습니다[/green]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold]추가할 도메인 ({len(to_add)}개):[/bold]")
|
||||
for z in to_add:
|
||||
console.print(f" - {z.get('name')}")
|
||||
|
||||
if dry_run:
|
||||
console.print("\n[yellow]== DRY RUN - 실제 변경 없음 ==[/yellow]")
|
||||
return
|
||||
|
||||
if not typer.confirm(f"\n{len(to_add)}개 도메인을 추가하시겠습니까?"):
|
||||
console.print("[yellow]취소됨[/yellow]")
|
||||
return
|
||||
|
||||
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)}")
|
||||
|
||||
console.print(f"[green]✓ {len(added)}개 도메인 추가 완료[/green]")
|
||||
|
||||
if auto_apply:
|
||||
console.print("[dim]설정 적용 중...[/dim]")
|
||||
if do_apply():
|
||||
console.print("[green]✓ 적용 완료[/green]")
|
||||
else:
|
||||
console.print("[yellow]적용 실패 - 수동으로 확인하세요[/yellow]")
|
||||
else:
|
||||
console.print("[yellow]적용하려면 'cfb apply' 명령을 실행하세요[/yellow]")
|
||||
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def apply():
|
||||
"""설정 적용 (bouncer 서비스 재시작)"""
|
||||
console.print("[dim]bouncer 서비스 재시작 중...[/dim]")
|
||||
|
||||
try:
|
||||
if do_apply():
|
||||
console.print("[green]✓ 설정 적용 완료[/green]")
|
||||
result = run_incus(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"])
|
||||
if result.returncode == 0:
|
||||
console.print(f"[green]bouncer PID: {result.stdout.strip()}[/green]")
|
||||
log_action("apply", "Service restarted")
|
||||
else:
|
||||
console.print("[red]✗ bouncer 프로세스를 시작할 수 없습니다[/red]")
|
||||
raise typer.Exit(1)
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def status():
|
||||
"""bouncer 상태 확인"""
|
||||
try:
|
||||
result = run_incus(["pgrep", "-f", "crowdsec-cloudflare-worker-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,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
console.print("\n[bold]CrowdSec Bouncer 상태:[/bold]")
|
||||
console.print(result.stdout)
|
||||
|
||||
config = get_config()
|
||||
zones = get_zones(config)
|
||||
console.print(f"\n[bold]보호 도메인 수:[/bold] {len(zones)}")
|
||||
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def available():
|
||||
"""Cloudflare에서 추가 가능한 도메인 목록"""
|
||||
try:
|
||||
config = get_config()
|
||||
token = get_cf_token(config)
|
||||
|
||||
if not token:
|
||||
console.print("[red]Cloudflare API 토큰을 찾을 수 없습니다[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print("[dim]Cloudflare에서 도메인 목록 조회 중...[/dim]")
|
||||
cf_zones = get_all_zones_from_cf(token)
|
||||
protected_zones = get_zones(config)
|
||||
protected_ids = {z.get("zone_id") for z in protected_zones}
|
||||
|
||||
table = Table(title="Cloudflare 도메인 목록")
|
||||
table.add_column("도메인", style="cyan")
|
||||
table.add_column("Zone ID", style="dim")
|
||||
table.add_column("상태", style="green")
|
||||
table.add_column("보호", style="yellow")
|
||||
|
||||
for zone in cf_zones:
|
||||
zone_id = zone.get("id", "")
|
||||
is_protected = "✓ 보호중" if zone_id in protected_ids else "-"
|
||||
status_style = "green" if zone.get("status") == "active" else "yellow"
|
||||
table.add_row(
|
||||
zone.get("name", "N/A"),
|
||||
zone_id[:12] + "...",
|
||||
f"[{status_style}]{zone.get('status', 'N/A')}[/{status_style}]",
|
||||
is_protected,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"\n총 [bold]{len(cf_zones)}[/bold]개 도메인, [bold]{len(protected_ids)}[/bold]개 보호 중")
|
||||
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def logs(
|
||||
lines: Annotated[int, typer.Option("-n", "--lines", help="표시할 줄 수")] = 50,
|
||||
follow: Annotated[bool, typer.Option("-f", "--follow", help="실시간 로그 추적")] = False,
|
||||
):
|
||||
"""bouncer 로그 조회"""
|
||||
try:
|
||||
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,
|
||||
)
|
||||
else:
|
||||
# journalctl 시도, 실패시 syslog에서 grep
|
||||
result = run_incus(["journalctl", "-u", "crowdsec-cloudflare-worker-bouncer", "-n", str(lines), "--no-pager"])
|
||||
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}"])
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
console.print(result.stdout)
|
||||
else:
|
||||
console.print("[yellow]로그를 찾을 수 없습니다[/yellow]")
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]로그 추적 종료[/dim]")
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def decisions(
|
||||
limit: Annotated[int, typer.Option("-n", "--limit", help="표시할 개수")] = 20,
|
||||
):
|
||||
"""CrowdSec 현재 차단 결정 조회"""
|
||||
try:
|
||||
result = run_incus(
|
||||
["cscli", "decisions", "list", "-o", "raw", "--limit", str(limit)],
|
||||
container=CROWDSEC_CONTAINER,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
if result.stdout.strip():
|
||||
console.print("[bold]현재 차단 결정:[/bold]\n")
|
||||
console.print(result.stdout)
|
||||
else:
|
||||
console.print("[green]현재 활성 차단 결정이 없습니다[/green]")
|
||||
else:
|
||||
console.print(f"[yellow]결정 조회 실패: {result.stderr}[/yellow]")
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def metrics():
|
||||
"""bouncer Prometheus 메트릭 조회"""
|
||||
try:
|
||||
config = get_config()
|
||||
prometheus_cfg = config.get("prometheus", {})
|
||||
|
||||
if not prometheus_cfg.get("enabled"):
|
||||
console.print("[yellow]Prometheus 메트릭이 비활성화되어 있습니다[/yellow]")
|
||||
return
|
||||
|
||||
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"])
|
||||
if result.returncode == 0:
|
||||
# 주요 메트릭만 필터링
|
||||
lines = result.stdout.split('\n')
|
||||
important_metrics = [l for l in lines if l and not l.startswith('#') and 'crowdsec' in l.lower()]
|
||||
|
||||
if important_metrics:
|
||||
console.print("[bold]Bouncer 메트릭:[/bold]\n")
|
||||
for line in important_metrics[:20]:
|
||||
console.print(line)
|
||||
else:
|
||||
console.print("[dim]메트릭 수집 중... 잠시 후 다시 시도하세요[/dim]")
|
||||
else:
|
||||
console.print("[yellow]메트릭을 가져올 수 없습니다[/yellow]")
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def backup():
|
||||
"""현재 설정 백업"""
|
||||
try:
|
||||
config = get_config()
|
||||
backup_file = backup_config(config, reason="manual")
|
||||
log_action("backup", str(backup_file))
|
||||
console.print(f"[green]✓ 백업 완료: {backup_file}[/green]")
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def restore(
|
||||
backup_file: Annotated[Optional[str], typer.Argument(help="복원할 백업 파일 경로")] = None,
|
||||
):
|
||||
"""백업에서 설정 복원"""
|
||||
try:
|
||||
if backup_file is None:
|
||||
backups = sorted(BACKUP_DIR.glob("config_*.yaml"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if not backups:
|
||||
console.print("[yellow]백업 파일이 없습니다[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
table = Table(title="백업 파일 목록")
|
||||
table.add_column("#", style="dim", width=3)
|
||||
table.add_column("파일명", style="cyan")
|
||||
table.add_column("날짜", style="green")
|
||||
|
||||
for i, b in enumerate(backups[:10], 1):
|
||||
mtime = datetime.fromtimestamp(b.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
table.add_row(str(i), b.name, mtime)
|
||||
|
||||
console.print(table)
|
||||
console.print("\n[dim]복원하려면: cfb restore <파일명>[/dim]")
|
||||
return
|
||||
|
||||
backup_path = Path(backup_file)
|
||||
if not backup_path.exists():
|
||||
backup_path = BACKUP_DIR / backup_file
|
||||
if not backup_path.exists():
|
||||
console.print(f"[red]백업 파일을 찾을 수 없습니다: {backup_file}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config = yaml.safe_load(backup_path.read_text())
|
||||
validate_config(config)
|
||||
|
||||
if not typer.confirm(f"{backup_path.name}에서 복원하시겠습니까?"):
|
||||
console.print("[yellow]취소됨[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
current_config = get_config()
|
||||
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"])
|
||||
if result.returncode != 0:
|
||||
raise BouncerError(f"복원 실패: {result.stderr}")
|
||||
|
||||
log_action("restore", backup_path.name)
|
||||
console.print(f"[green]✓ 복원 완료: {backup_path.name}[/green]")
|
||||
console.print("[yellow]적용하려면 'cfb apply' 명령을 실행하세요[/yellow]")
|
||||
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except yaml.YAMLError as e:
|
||||
console.print(f"[red]백업 파일 파싱 실패: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def diff(
|
||||
backup_file: Annotated[Optional[str], typer.Argument(help="비교할 백업 파일")] = None,
|
||||
):
|
||||
"""현재 설정과 백업 비교"""
|
||||
try:
|
||||
if backup_file is None:
|
||||
backups = sorted(BACKUP_DIR.glob("config_*.yaml"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if not backups:
|
||||
console.print("[yellow]백업 파일이 없습니다[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
backup_path = backups[0]
|
||||
console.print(f"[dim]최근 백업과 비교: {backup_path.name}[/dim]\n")
|
||||
else:
|
||||
backup_path = Path(backup_file)
|
||||
if not backup_path.exists():
|
||||
backup_path = BACKUP_DIR / backup_file
|
||||
if not backup_path.exists():
|
||||
console.print(f"[red]백업 파일을 찾을 수 없습니다: {backup_file}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config = get_config()
|
||||
current_yaml = yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
backup_yaml = backup_path.read_text()
|
||||
|
||||
diff_lines = list(unified_diff(
|
||||
backup_yaml.splitlines(keepends=True),
|
||||
current_yaml.splitlines(keepends=True),
|
||||
fromfile=f"백업: {backup_path.name}",
|
||||
tofile="현재 설정",
|
||||
))
|
||||
|
||||
if diff_lines:
|
||||
for line in diff_lines:
|
||||
if line.startswith('+') and not line.startswith('+++'):
|
||||
console.print(f"[green]{line.rstrip()}[/green]")
|
||||
elif line.startswith('-') and not line.startswith('---'):
|
||||
console.print(f"[red]{line.rstrip()}[/red]")
|
||||
elif line.startswith('@@'):
|
||||
console.print(f"[cyan]{line.rstrip()}[/cyan]")
|
||||
else:
|
||||
console.print(line.rstrip())
|
||||
else:
|
||||
console.print("[green]변경사항 없음[/green]")
|
||||
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("export")
|
||||
def export_config(
|
||||
output: Annotated[Optional[str], typer.Option("-o", "--output", help="출력 파일 경로")] = None,
|
||||
format: Annotated[str, typer.Option("-f", "--format", help="출력 형식 (yaml/json)")] = "yaml",
|
||||
):
|
||||
"""설정을 파일로 내보내기"""
|
||||
try:
|
||||
config = get_config()
|
||||
zones = get_zones(config)
|
||||
|
||||
# 도메인 목록만 추출
|
||||
export_data = {
|
||||
"domains": [
|
||||
{
|
||||
"domain": extract_domain(z.get("routes_to_protect", [""])[0]),
|
||||
"zone_id": z.get("zone_id"),
|
||||
"action": z.get("default_action"),
|
||||
"turnstile_enabled": z.get("turnstile", {}).get("enabled"),
|
||||
"turnstile_mode": z.get("turnstile", {}).get("mode"),
|
||||
}
|
||||
for z in zones
|
||||
],
|
||||
"exported_at": datetime.now().isoformat(),
|
||||
"total": len(zones),
|
||||
}
|
||||
|
||||
if format == "json":
|
||||
import json
|
||||
content = json.dumps(export_data, indent=2, ensure_ascii=False)
|
||||
else:
|
||||
content = yaml.dump(export_data, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
if output:
|
||||
Path(output).write_text(content)
|
||||
console.print(f"[green]✓ 내보내기 완료: {output}[/green]")
|
||||
else:
|
||||
console.print(content)
|
||||
|
||||
except BouncerError as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def history(
|
||||
lines: Annotated[int, typer.Option("-n", "--lines", help="표시할 줄 수")] = 20,
|
||||
):
|
||||
"""변경 이력 조회"""
|
||||
try:
|
||||
if not LOG_FILE.exists():
|
||||
console.print("[yellow]변경 이력이 없습니다[/yellow]")
|
||||
return
|
||||
|
||||
content = LOG_FILE.read_text()
|
||||
history_lines = content.strip().split('\n')
|
||||
|
||||
console.print("[bold]변경 이력:[/bold]\n")
|
||||
for line in history_lines[-lines:]:
|
||||
console.print(line)
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]에러: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
4
cfb
Executable file
4
cfb
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# CrowdSec Cloudflare Bouncer Manager
|
||||
cd /home/kaffa/cf-bouncer-manager
|
||||
/home/kaffa/.local/bin/uv run python cf_bouncer.py "$@"
|
||||
6
main.py
Normal file
6
main.py
Normal file
@@ -0,0 +1,6 @@
|
||||
def main():
|
||||
print("Hello from cf-bouncer-manager!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
12
pyproject.toml
Normal file
12
pyproject.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
name = "cf-bouncer-manager"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"pyyaml>=6.0.3",
|
||||
"requests>=2.32.5",
|
||||
"rich>=14.3.2",
|
||||
"typer>=0.21.1",
|
||||
]
|
||||
238
uv.lock
generated
Normal file
238
uv.lock
generated
Normal file
@@ -0,0 +1,238 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cf-bouncer-manager"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
{ name = "requests", specifier = ">=2.32.5" },
|
||||
{ name = "rich", specifier = ">=14.3.2" },
|
||||
{ name = "typer", specifier = ">=0.21.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
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 = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
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 = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
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 = "typer"
|
||||
version = "0.21.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
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 = "urllib3"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
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" },
|
||||
]
|
||||
Reference in New Issue
Block a user