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

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