Add CDN monitoring script for Edge Script usage, traffic stats, and WAF logs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
289
monitor.py
Normal file
289
monitor.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Bunny CDN 모니터링: Edge Script 사용량, 에러 통계, WAF 상태 조회."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
BUNNY_API_BASE = "https://api.bunny.net"
|
||||||
|
BUNNY_API_KEY = os.environ.get("BUNNY_API_KEY", "")
|
||||||
|
PULL_ZONE_ID = os.environ.get("BUNNY_PULL_ZONE_ID", "5316471")
|
||||||
|
SCRIPT_ID = os.environ.get("BUNNY_SCRIPT_ID", "64811")
|
||||||
|
|
||||||
|
FREE_REQUEST_LIMIT = 25_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def api(path: str) -> dict:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{BUNNY_API_BASE}{path}",
|
||||||
|
headers={"AccessKey": BUNNY_API_KEY},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_num(n: int) -> str:
|
||||||
|
if n >= 1_000_000:
|
||||||
|
return f"{n / 1_000_000:.2f}M"
|
||||||
|
if n >= 1_000:
|
||||||
|
return f"{n / 1_000:.1f}K"
|
||||||
|
return str(n)
|
||||||
|
|
||||||
|
|
||||||
|
def edge_script_usage():
|
||||||
|
"""Edge Script 월간 사용량 조회."""
|
||||||
|
print("=" * 50)
|
||||||
|
print(" Edge Script 월간 사용량")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
data = api(f"/compute/script/{SCRIPT_ID}")
|
||||||
|
reqs = data.get("MonthlyRequestCount", 0)
|
||||||
|
cpu = data.get("MonthlyCpuTime", 0)
|
||||||
|
cost = data.get("MonthlyCost", 0)
|
||||||
|
pct = reqs / FREE_REQUEST_LIMIT * 100
|
||||||
|
|
||||||
|
print(f" 요청 수: {fmt_num(reqs):>10} / {fmt_num(FREE_REQUEST_LIMIT)} ({pct:.4f}%)")
|
||||||
|
print(f" CPU 시간: {cpu:>10} ms")
|
||||||
|
print(f" 비용: ${cost:.2f}")
|
||||||
|
if reqs > 0:
|
||||||
|
print(f" 평균 CPU: {cpu / reqs:.1f} ms/req")
|
||||||
|
|
||||||
|
if pct > 80:
|
||||||
|
print(f"\n ⚠ 무료 한도의 {pct:.1f}% 사용 중 — 주의 필요!")
|
||||||
|
elif pct > 50:
|
||||||
|
print(f"\n △ 무료 한도의 {pct:.1f}% 사용 중")
|
||||||
|
else:
|
||||||
|
print(f"\n ✓ 여유 충분")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def cdn_statistics(days: int = 30):
|
||||||
|
"""CDN 트래픽 및 에러 통계."""
|
||||||
|
print("=" * 50)
|
||||||
|
print(f" CDN 통계 (최근 {days}일)")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
date_from = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
|
date_to = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
data = api(f"/statistics?pullZone={PULL_ZONE_ID}&dateFrom={date_from}&dateTo={date_to}")
|
||||||
|
|
||||||
|
total = data.get("TotalRequestsServed", 0)
|
||||||
|
cache_rate = data.get("CacheHitRate", 0)
|
||||||
|
bw = data.get("TotalBandwidthUsed", 0)
|
||||||
|
origin_bw = data.get("TotalOriginTraffic", 0)
|
||||||
|
avg_origin_rt = data.get("AverageOriginResponseTime", 0)
|
||||||
|
|
||||||
|
print(f" 총 요청: {fmt_num(total)}")
|
||||||
|
print(f" 캐시 히트율: {cache_rate:.1f}%")
|
||||||
|
print(f" 총 대역폭: {bw / 1024 / 1024:.1f} MB")
|
||||||
|
print(f" 오리진 트래픽: {origin_bw / 1024 / 1024:.1f} MB")
|
||||||
|
print(f" 오리진 응답시간: {avg_origin_rt:.0f} ms (평균)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 에러 통계
|
||||||
|
e3 = sum(data.get("Error3xxChart", {}).values())
|
||||||
|
e4 = sum(data.get("Error4xxChart", {}).values())
|
||||||
|
e5 = sum(data.get("Error5xxChart", {}).values())
|
||||||
|
|
||||||
|
print(" --- 응답 코드 ---")
|
||||||
|
print(f" 3xx: {fmt_num(e3):>8}", end="")
|
||||||
|
print(f" ({e3 / total * 100:.2f}%)" if total else "")
|
||||||
|
print(f" 4xx: {fmt_num(e4):>8}", end="")
|
||||||
|
print(f" ({e4 / total * 100:.2f}%)" if total else "")
|
||||||
|
print(f" 5xx: {fmt_num(e5):>8}", end="")
|
||||||
|
print(f" ({e5 / total * 100:.2f}%)" if total else "")
|
||||||
|
|
||||||
|
if e4 > 0 and total > 0:
|
||||||
|
print(f"\n ⚠ 4xx 에러 발생 중 (429 Rate Limit 포함 가능)")
|
||||||
|
else:
|
||||||
|
print(f"\n ✓ 4xx/5xx 에러 없음 — Rate Limit 정상")
|
||||||
|
|
||||||
|
# 일별 에러 상세
|
||||||
|
e4_chart = data.get("Error4xxChart", {})
|
||||||
|
e5_chart = data.get("Error5xxChart", {})
|
||||||
|
error_days = {k[:10]: v for k, v in e4_chart.items() if v > 0}
|
||||||
|
error_days_5 = {k[:10]: v for k, v in e5_chart.items() if v > 0}
|
||||||
|
|
||||||
|
if error_days or error_days_5:
|
||||||
|
print("\n --- 일별 에러 상세 ---")
|
||||||
|
all_dates = sorted(set(list(error_days.keys()) + list(error_days_5.keys())))
|
||||||
|
for d in all_dates:
|
||||||
|
parts = []
|
||||||
|
if d in error_days:
|
||||||
|
parts.append(f"4xx={error_days[d]}")
|
||||||
|
if d in error_days_5:
|
||||||
|
parts.append(f"5xx={error_days_5[d]}")
|
||||||
|
print(f" {d}: {', '.join(parts)}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def waf_status():
|
||||||
|
"""WAF/Shield 상태 및 이벤트 로그 조회."""
|
||||||
|
print("=" * 50)
|
||||||
|
print(" WAF / Bunny Shield 상태")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Pull Zone에서 Shield 활성 상태 확인
|
||||||
|
try:
|
||||||
|
pz = api(f"/pullzone/{PULL_ZONE_ID}")
|
||||||
|
shield_ddos = pz.get("ShieldDDosProtectionEnabled", False)
|
||||||
|
zone_sec = pz.get("ZoneSecurityEnabled", False)
|
||||||
|
print(f" Shield DDoS: {'✓ 활성' if shield_ddos else '✗ 비활성'}")
|
||||||
|
print(f" Zone Security: {'✓ 활성' if zone_sec else '✗ 비활성'}")
|
||||||
|
except requests.HTTPError:
|
||||||
|
shield_ddos = False
|
||||||
|
zone_sec = False
|
||||||
|
|
||||||
|
# Shield Zone ID 조회
|
||||||
|
shield_zone_id = None
|
||||||
|
shield_data = None
|
||||||
|
try:
|
||||||
|
sz = api(f"/shield/shield-zone/get-by-pullzone/{PULL_ZONE_ID}")
|
||||||
|
shield_data = sz.get("data", {}) if isinstance(sz, dict) else {}
|
||||||
|
shield_zone_id = shield_data.get("shieldZoneId")
|
||||||
|
if shield_zone_id:
|
||||||
|
plan_names = {0: "Free", 1: "Premium", 2: "Business", 3: "Enterprise"}
|
||||||
|
plan = plan_names.get(shield_data.get("planType", -1), "?")
|
||||||
|
waf_on = shield_data.get("wafEnabled", False)
|
||||||
|
learning = shield_data.get("learningMode", False)
|
||||||
|
print(f" Shield Zone: #{shield_zone_id} ({plan})")
|
||||||
|
print(f" WAF: {'✓ 활성' if waf_on else '✗ 비활성'}")
|
||||||
|
if learning:
|
||||||
|
until = shield_data.get("learningModeUntil", "?")[:10]
|
||||||
|
print(f" Learning Mode: ✓ ({until}까지)")
|
||||||
|
except requests.HTTPError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not shield_zone_id:
|
||||||
|
print(f"\n Shield Zone 미생성 — WAF 이벤트 로그 조회 불가")
|
||||||
|
print(f" 활성화: https://dash.bunny.net/cdn/{PULL_ZONE_ID}/security")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# WAF 룰 카탈로그
|
||||||
|
try:
|
||||||
|
data = api(f"/shield/waf/rules?pullZoneId={PULL_ZONE_ID}")
|
||||||
|
except requests.HTTPError:
|
||||||
|
print(f" WAF 룰 조회 불가")
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
|
||||||
|
total_groups = 0
|
||||||
|
total_rules = 0
|
||||||
|
active_categories = []
|
||||||
|
|
||||||
|
for category in data:
|
||||||
|
for group in category.get("ruleGroups", []):
|
||||||
|
total_groups += 1
|
||||||
|
rules = group.get("rules", [])
|
||||||
|
rule_count = len(rules)
|
||||||
|
total_rules += rule_count
|
||||||
|
if rule_count > 0:
|
||||||
|
active_categories.append((group.get("name", "?"), rule_count))
|
||||||
|
|
||||||
|
print(f" WAF 룰 그룹: {total_groups}개")
|
||||||
|
print(f" WAF 활성 룰: {total_rules}개")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if active_categories:
|
||||||
|
print(" --- 활성 룰 그룹 (상위 10) ---")
|
||||||
|
for name, count in sorted(active_categories, key=lambda x: -x[1])[:10]:
|
||||||
|
print(f" {name:<45} {count:>3}개")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Shield가 활성이면 이벤트 로그 조회
|
||||||
|
if shield_zone_id:
|
||||||
|
_fetch_waf_logs(shield_zone_id)
|
||||||
|
else:
|
||||||
|
print(f" WAF 이벤트 로그: https://dash.bunny.net/cdn/{PULL_ZONE_ID}/security/waf-log")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_waf_logs(shield_zone_id: int):
|
||||||
|
"""Shield 이벤트 로그 조회 및 요약."""
|
||||||
|
today = datetime.now(timezone.utc).strftime("%m-%d-%Y")
|
||||||
|
print(" --- WAF 이벤트 로그 (오늘) ---")
|
||||||
|
try:
|
||||||
|
data = api(f"/shield/event-logs/{shield_zone_id}/{today}/")
|
||||||
|
logs = data.get("logs") or []
|
||||||
|
if not logs:
|
||||||
|
print(" ✓ 트리거된 WAF 이벤트 없음")
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
|
||||||
|
statuses = Counter()
|
||||||
|
messages = Counter()
|
||||||
|
countries = Counter()
|
||||||
|
ips = Counter()
|
||||||
|
|
||||||
|
for entry in logs:
|
||||||
|
labels = entry.get("labels", {})
|
||||||
|
statuses[labels.get("status", "?")] += 1
|
||||||
|
countries[labels.get("country", "?")] += 1
|
||||||
|
log_data = json.loads(entry.get("log", "{}"))
|
||||||
|
ips[log_data.get("RemoteIp", "?")] += 1
|
||||||
|
messages[log_data.get("Message", "?")] += 1
|
||||||
|
|
||||||
|
print(f" 총 {len(logs)}건", end="")
|
||||||
|
if data.get("hasMoreData"):
|
||||||
|
print(" (추가 데이터 있음)", end="")
|
||||||
|
print()
|
||||||
|
|
||||||
|
blocked = statuses.get("Blocked", 0)
|
||||||
|
logged = statuses.get("Logged", 0)
|
||||||
|
print(f" 차단: {blocked}건 | 로그만: {logged}건")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(" 공격 유형:")
|
||||||
|
for msg, cnt in messages.most_common(5):
|
||||||
|
print(f" {msg:<50} {cnt:>3}건")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(" IP (상위 5):")
|
||||||
|
for ip, cnt in ips.most_common(5):
|
||||||
|
print(f" {ip:<20} {cnt:>3}건")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(" 국가:")
|
||||||
|
for cc, cnt in countries.most_common(5):
|
||||||
|
print(f" {cc:<5} {cnt:>3}건")
|
||||||
|
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
print(f" 이벤트 로그 조회 실패: {e}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not BUNNY_API_KEY:
|
||||||
|
print("ERROR: BUNNY_API_KEY 환경변수가 필요합니다.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
days = 30
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
try:
|
||||||
|
days = int(sys.argv[1])
|
||||||
|
except ValueError:
|
||||||
|
print(f"사용법: {sys.argv[0]} [일수] (기본: 30)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" Bunny CDN 모니터링 — {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||||||
|
print(f" Pull Zone: {PULL_ZONE_ID} | Script: {SCRIPT_ID}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
edge_script_usage()
|
||||||
|
cdn_statistics(days)
|
||||||
|
waf_status()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user