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:
151
cf_bouncer.py
151
cf_bouncer.py
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CrowdSec Cloudflare Worker Bouncer 도메인 관리 CLI"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from difflib import unified_diff
|
||||
@@ -13,20 +15,51 @@ import yaml
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
__version__ = "1.1.0"
|
||||
__version__ = "1.3.0"
|
||||
|
||||
app = typer.Typer(help="CrowdSec Cloudflare Bouncer 도메인 관리")
|
||||
console = Console()
|
||||
|
||||
# 설정
|
||||
CONFIG_PATH = "/etc/crowdsec/bouncers/crowdsec-cloudflare-worker-bouncer.yaml"
|
||||
CONTAINER_NAME = "cs-cf-worker-bouncer"
|
||||
CROWDSEC_CONTAINER = "crowdsec"
|
||||
BACKUP_DIR = Path.home() / "cf-bouncer-manager" / "backups"
|
||||
LOG_FILE = Path.home() / "cf-bouncer-manager" / "history.log"
|
||||
REQUEST_TIMEOUT = 30
|
||||
SUBPROCESS_TIMEOUT = 60
|
||||
|
||||
# 서버 식별자 (논리적 이름)
|
||||
SERVER_BOUNCER = "bouncer"
|
||||
SERVER_CROWDSEC = "crowdsec"
|
||||
|
||||
# SSH 설정 (환경변수) - 각 서버별 설정
|
||||
# Bouncer 서버 (crowdsec-cloudflare-worker-bouncer가 실행되는 서버)
|
||||
SSH_BOUNCER_HOST = os.environ.get("CFB_BOUNCER_HOST") # user@hostname 또는 hostname
|
||||
SSH_BOUNCER_PORT = os.environ.get("CFB_BOUNCER_PORT", "22")
|
||||
SSH_BOUNCER_KEY = os.environ.get("CFB_BOUNCER_KEY") # SSH 키 경로 (선택)
|
||||
SSH_BOUNCER_USER = os.environ.get("CFB_BOUNCER_USER", "root") # SSH 사용자
|
||||
|
||||
# CrowdSec 서버 (CrowdSec 엔진이 실행되는 서버)
|
||||
SSH_CROWDSEC_HOST = os.environ.get("CFB_CROWDSEC_HOST")
|
||||
SSH_CROWDSEC_PORT = os.environ.get("CFB_CROWDSEC_PORT", "22")
|
||||
SSH_CROWDSEC_KEY = os.environ.get("CFB_CROWDSEC_KEY")
|
||||
SSH_CROWDSEC_USER = os.environ.get("CFB_CROWDSEC_USER", "root")
|
||||
|
||||
# SSH 설정 매핑
|
||||
SSH_CONFIGS = {
|
||||
SERVER_BOUNCER: {
|
||||
"host": SSH_BOUNCER_HOST,
|
||||
"port": SSH_BOUNCER_PORT,
|
||||
"key": SSH_BOUNCER_KEY,
|
||||
"user": SSH_BOUNCER_USER,
|
||||
},
|
||||
SERVER_CROWDSEC: {
|
||||
"host": SSH_CROWDSEC_HOST,
|
||||
"port": SSH_CROWDSEC_PORT,
|
||||
"key": SSH_CROWDSEC_KEY,
|
||||
"user": SSH_CROWDSEC_USER,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class BouncerError(Exception):
|
||||
"""Bouncer 관련 에러"""
|
||||
@@ -41,20 +74,62 @@ def log_action(action: str, details: str = "") -> None:
|
||||
f.write(f"[{timestamp}] {action}: {details}\n")
|
||||
|
||||
|
||||
def run_incus(cmd: list[str], container: str = CONTAINER_NAME, capture: bool = True, timeout: int = SUBPROCESS_TIMEOUT) -> subprocess.CompletedProcess:
|
||||
"""incus exec 명령 실행"""
|
||||
full_cmd = ["incus", "exec", container, "--"] + cmd
|
||||
def get_ssh_config(server: str) -> dict:
|
||||
"""서버별 SSH 설정 반환"""
|
||||
config = SSH_CONFIGS.get(server)
|
||||
if not config or not config.get("host"):
|
||||
raise BouncerError(f"SSH 설정이 없습니다: {server}\n환경변수 CFB_{server.upper()}_HOST를 설정하세요.")
|
||||
return config
|
||||
|
||||
|
||||
def run_ssh(cmd: list[str], server: str = SERVER_BOUNCER, capture: bool = True, timeout: int = SUBPROCESS_TIMEOUT) -> subprocess.CompletedProcess:
|
||||
"""SSH를 통해 원격 서버에서 명령 실행
|
||||
|
||||
Args:
|
||||
cmd: 실행할 명령어 리스트
|
||||
server: 서버 식별자 (SERVER_BOUNCER 또는 SERVER_CROWDSEC)
|
||||
capture: 출력 캡처 여부
|
||||
timeout: 타임아웃 (초)
|
||||
|
||||
Returns:
|
||||
subprocess.CompletedProcess 결과
|
||||
"""
|
||||
config = get_ssh_config(server)
|
||||
|
||||
# SSH 호스트 구성 (user@host 형태 또는 host만)
|
||||
host = config["host"]
|
||||
if "@" not in host:
|
||||
host = f"{config['user']}@{host}"
|
||||
|
||||
# SSH 명령어 구성
|
||||
ssh_cmd = [
|
||||
"ssh",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"-p", config["port"],
|
||||
]
|
||||
|
||||
if config.get("key"):
|
||||
ssh_cmd.extend(["-i", config["key"]])
|
||||
|
||||
# 원격 명령어 구성
|
||||
remote_cmd = " ".join(shlex.quote(c) for c in cmd)
|
||||
ssh_cmd.extend([host, remote_cmd])
|
||||
|
||||
try:
|
||||
return subprocess.run(full_cmd, capture_output=capture, text=True, timeout=timeout)
|
||||
return subprocess.run(ssh_cmd, capture_output=capture, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise BouncerError(f"명령 실행 시간 초과: {' '.join(cmd)}")
|
||||
raise BouncerError(f"명령 실행 시간 초과 ({server}): {' '.join(cmd)}")
|
||||
except Exception as e:
|
||||
raise BouncerError(f"명령 실행 실패: {e}")
|
||||
raise BouncerError(f"SSH 명령 실행 실패 ({server}): {e}")
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
"""bouncer 설정 파일 읽기"""
|
||||
result = run_incus(["cat", CONFIG_PATH])
|
||||
result = run_ssh(["cat", CONFIG_PATH], server=SERVER_BOUNCER)
|
||||
if result.returncode != 0:
|
||||
raise BouncerError(f"설정 파일을 읽을 수 없습니다: {result.stderr}")
|
||||
try:
|
||||
@@ -101,7 +176,7 @@ def save_config(config: dict, reason: str = "update") -> None:
|
||||
console.print(f"[dim]백업 저장: {backup_file}[/dim]")
|
||||
|
||||
yaml_content = yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
result = run_incus(["sh", "-c", f"cat > {CONFIG_PATH} << 'EOFCONFIG'\n{yaml_content}\nEOFCONFIG"])
|
||||
result = run_ssh(["sh", "-c", f"cat > {CONFIG_PATH} << 'EOFCONFIG'\n{yaml_content}\nEOFCONFIG"], server=SERVER_BOUNCER)
|
||||
if result.returncode != 0:
|
||||
raise BouncerError(f"설정 파일 저장 실패: {result.stderr}")
|
||||
|
||||
@@ -189,20 +264,20 @@ def find_zone_by_domain(zones: list[dict], domain: str) -> tuple[int, dict] | tu
|
||||
|
||||
def do_apply() -> bool:
|
||||
"""bouncer 서비스 재시작"""
|
||||
result = run_incus(["systemctl", "restart", "crowdsec-cloudflare-worker-bouncer"])
|
||||
result = run_ssh(["systemctl", "restart", "crowdsec-cloudflare-worker-bouncer"], server=SERVER_BOUNCER)
|
||||
|
||||
if result.returncode != 0:
|
||||
run_incus(["pkill", "-f", "crowdsec-cloudflare-worker-bouncer"])
|
||||
run_incus([
|
||||
run_ssh(["pkill", "-f", "crowdsec-cloudflare-worker-bouncer"], server=SERVER_BOUNCER)
|
||||
run_ssh([
|
||||
"sh", "-c",
|
||||
f"nohup crowdsec-cloudflare-worker-bouncer -c {CONFIG_PATH} > /var/log/bouncer.log 2>&1 &"
|
||||
])
|
||||
], server=SERVER_BOUNCER)
|
||||
|
||||
# 잠시 대기 후 프로세스 확인
|
||||
import time
|
||||
time.sleep(2)
|
||||
|
||||
result = run_incus(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"])
|
||||
result = run_ssh(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"], server=SERVER_BOUNCER)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
@@ -546,7 +621,7 @@ def apply():
|
||||
try:
|
||||
if do_apply():
|
||||
console.print("[green]✓ 설정 적용 완료[/green]")
|
||||
result = run_incus(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"])
|
||||
result = run_ssh(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"], server=SERVER_BOUNCER)
|
||||
if result.returncode == 0:
|
||||
console.print(f"[green]bouncer PID: {result.stdout.strip()}[/green]")
|
||||
log_action("apply", "Service restarted")
|
||||
@@ -562,19 +637,14 @@ def apply():
|
||||
def status():
|
||||
"""bouncer 상태 확인"""
|
||||
try:
|
||||
result = run_incus(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"])
|
||||
result = run_ssh(["pgrep", "-f", "crowdsec-cloudflare-worker-bouncer"], server=SERVER_BOUNCER)
|
||||
if result.returncode == 0:
|
||||
pids = result.stdout.strip().split('\n')
|
||||
console.print(f"[green]✓ bouncer 실행 중 (PID: {', '.join(pids)})[/green]")
|
||||
else:
|
||||
console.print("[red]✗ bouncer가 실행되지 않음[/red]")
|
||||
|
||||
result = subprocess.run(
|
||||
["incus", "exec", CROWDSEC_CONTAINER, "--", "cscli", "bouncers", "list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=SUBPROCESS_TIMEOUT,
|
||||
)
|
||||
result = run_ssh(["cscli", "bouncers", "list"], server=SERVER_CROWDSEC)
|
||||
if result.returncode == 0:
|
||||
console.print("\n[bold]CrowdSec Bouncer 상태:[/bold]")
|
||||
console.print(result.stdout)
|
||||
@@ -636,19 +706,32 @@ def logs(
|
||||
):
|
||||
"""bouncer 로그 조회"""
|
||||
try:
|
||||
config = get_ssh_config(SERVER_BOUNCER)
|
||||
host = config["host"]
|
||||
if "@" not in host:
|
||||
host = f"{config['user']}@{host}"
|
||||
|
||||
if follow:
|
||||
console.print("[dim]로그 추적 중... (Ctrl+C로 종료)[/dim]\n")
|
||||
subprocess.run(
|
||||
["incus", "exec", CONTAINER_NAME, "--", "journalctl", "-u", "crowdsec-cloudflare-worker-bouncer", "-f", "--no-pager"],
|
||||
timeout=None,
|
||||
)
|
||||
ssh_cmd = [
|
||||
"ssh",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-p", config["port"],
|
||||
]
|
||||
if config.get("key"):
|
||||
ssh_cmd.extend(["-i", config["key"]])
|
||||
ssh_cmd.extend([host, "journalctl -u crowdsec-cloudflare-worker-bouncer -f --no-pager"])
|
||||
subprocess.run(ssh_cmd, timeout=None)
|
||||
else:
|
||||
# journalctl 시도, 실패시 syslog에서 grep
|
||||
result = run_incus(["journalctl", "-u", "crowdsec-cloudflare-worker-bouncer", "-n", str(lines), "--no-pager"])
|
||||
result = run_ssh(["journalctl", "-u", "crowdsec-cloudflare-worker-bouncer", "-n", str(lines), "--no-pager"], server=SERVER_BOUNCER)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
console.print(result.stdout)
|
||||
else:
|
||||
result = run_incus(["sh", "-c", f"grep -i bouncer /var/log/syslog | tail -n {lines}"])
|
||||
result = run_ssh(["sh", "-c", f"grep -i bouncer /var/log/syslog | tail -n {lines}"], server=SERVER_BOUNCER)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
console.print(result.stdout)
|
||||
else:
|
||||
@@ -666,9 +749,9 @@ def decisions(
|
||||
):
|
||||
"""CrowdSec 현재 차단 결정 조회"""
|
||||
try:
|
||||
result = run_incus(
|
||||
result = run_ssh(
|
||||
["cscli", "decisions", "list", "-o", "raw", "--limit", str(limit)],
|
||||
container=CROWDSEC_CONTAINER,
|
||||
server=SERVER_CROWDSEC,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
if result.stdout.strip():
|
||||
@@ -697,7 +780,7 @@ def metrics():
|
||||
addr = prometheus_cfg.get("listen_addr", "127.0.0.1")
|
||||
port = prometheus_cfg.get("listen_port", "2112")
|
||||
|
||||
result = run_incus(["curl", "-s", f"http://{addr}:{port}/metrics"])
|
||||
result = run_ssh(["curl", "-s", f"http://{addr}:{port}/metrics"], server=SERVER_BOUNCER)
|
||||
if result.returncode == 0:
|
||||
# 주요 메트릭만 필터링
|
||||
lines = result.stdout.split('\n')
|
||||
@@ -772,7 +855,7 @@ def restore(
|
||||
backup_config(current_config, reason="before_restore")
|
||||
|
||||
yaml_content = yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
result = run_incus(["sh", "-c", f"cat > {CONFIG_PATH} << 'EOFCONFIG'\n{yaml_content}\nEOFCONFIG"])
|
||||
result = run_ssh(["sh", "-c", f"cat > {CONFIG_PATH} << 'EOFCONFIG'\n{yaml_content}\nEOFCONFIG"], server=SERVER_BOUNCER)
|
||||
if result.returncode != 0:
|
||||
raise BouncerError(f"복원 실패: {result.stderr}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user