From 91b57b298ef5b222917824605410e54008281669 Mon Sep 17 00:00:00 2001 From: qorgh529 Date: Wed, 15 Apr 2026 19:28:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Discord/Gmail=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + backend/main.py | 69 +++++++++++++++++++- backend/monitor.py | 136 +++++++++++++++++++++++++++++++++++++++ backend/notifier.py | 85 ++++++++++++++++++++++++ backend/requirements.txt | 3 + k8s/04-backend.yaml | 25 +++++++ k8s/12-monitor-rbac.yaml | 59 +++++++++++++++++ 7 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 .gitignore create mode 100755 backend/monitor.py create mode 100755 backend/notifier.py create mode 100755 k8s/12-monitor-rbac.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1959e35 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +k8s/11-notify-secrets.yaml diff --git a/backend/main.py b/backend/main.py index a0fbce8..57fdecd 100755 --- a/backend/main.py +++ b/backend/main.py @@ -1,4 +1,5 @@ from fastapi import FastAPI, HTTPException, Depends, status +from contextlib import asynccontextmanager from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel @@ -12,7 +13,16 @@ import secrets import string from datetime import datetime, timedelta -app = FastAPI(title="Web Portal API") +from notifier import notify_both, notify_email_only, notify_discord_only +from monitor import start_scheduler + +@asynccontextmanager +async def lifespan(app: FastAPI): + scheduler = start_scheduler() + yield + scheduler.shutdown() + +app = FastAPI(title="Web Portal API", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -170,6 +180,16 @@ def login(req: LoginRequest, conn=Depends(get_db)): ) conn.commit() if locked: + import asyncio + asyncio.create_task(notify_discord_only( + title="πŸ”’ 계정 잠금 λ°œμƒ", + message=( + f"μ‚¬μš©μž: `{req.username}`\n" + f"μ‚¬μœ : λΉ„λ°€λ²ˆν˜Έ {MAX_LOGIN_ATTEMPTS}회 였λ₯˜λ‘œ 계정이 μž κ²ΌμŠ΅λ‹ˆλ‹€.\n" + f"κ΄€λ¦¬μž νŽ˜μ΄μ§€μ—μ„œ 잠금 ν•΄μ œ λ˜λŠ” μž„μ‹œ λΉ„λ°€λ²ˆν˜Έλ₯Ό λ°œκΈ‰ν•΄μ£Όμ„Έμš”." + ), + color=0xe74c3c + )) raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts. Please contact admin.") remaining = MAX_LOGIN_ATTEMPTS - attempts raise HTTPException(status_code=401, detail=f"Invalid credentials. {remaining} attempts remaining.") @@ -338,10 +358,12 @@ def admin_change_password(user_id: int, data: AdminPasswordChange, token=Depends # ─── Admin: μž„μ‹œ λΉ„λ°€λ²ˆν˜Έ λ°œκΈ‰ ─────────────────────────── @app.post("/api/admin/users/{user_id}/reset-password") -def reset_password(user_id: int, token=Depends(require_admin), conn=Depends(get_db)): +async def reset_password(user_id: int, token=Depends(require_admin), conn=Depends(get_db)): temp_pw = generate_temp_password() hashed = bcrypt.hashpw(temp_pw.encode(), bcrypt.gensalt()).decode() - cur = conn.cursor() + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT username FROM users WHERE id=%s", (user_id,)) + user = cur.fetchone() cur.execute( """UPDATE users SET password_hash=%s, must_change_password=TRUE, login_attempts=0, is_locked=FALSE, password_change_requested=FALSE @@ -349,6 +371,16 @@ def reset_password(user_id: int, token=Depends(require_admin), conn=Depends(get_ (hashed, user_id) ) conn.commit() + if user: + import asyncio + asyncio.create_task(notify_discord_only( + title="πŸ”‘ μž„μ‹œ λΉ„λ°€λ²ˆν˜Έ λ°œκΈ‰", + message=( + f"κ΄€λ¦¬μž `{token['username']}` 이(κ°€) μž„μ‹œ λΉ„λ°€λ²ˆν˜Έλ₯Ό λ°œκΈ‰ν–ˆμŠ΅λ‹ˆλ‹€.\n" + f"λŒ€μƒ μ‚¬μš©μž: `{user['username']}`" + ), + color=0x3498db + )) return {"ok": True, "temp_password": temp_pw} # ─── Admin: 계정 잠금 ν•΄μ œ ─────────────────────────────── @@ -390,6 +422,37 @@ def update_user_pages(user_id: int, data: AccessUpdate, token=Depends(require_ad conn.commit() return {"ok": True} +@app.get("/api/admin/notify-test") +async def notify_test(token=Depends(require_admin)): + # Discord + Gmail (Pod 이상/볡ꡬ ν…ŒμŠ€νŠΈ) + await notify_both( + title="βœ… [Discord+Gmail] μ•Œλ¦Ό ν…ŒμŠ€νŠΈ", + message=( + f"κ΄€λ¦¬μž `{token['username']}` 이(κ°€) μ•Œλ¦Ό ν…ŒμŠ€νŠΈλ₯Ό μ‹€ν–‰ν–ˆμŠ΅λ‹ˆλ‹€.\n" + f"Pod 이상/볡ꡬ μ•Œλ¦Ό μ±„λ„μž…λ‹ˆλ‹€." + ), + color=0x2ecc71 + ) + # Gmail only (μΈμ¦μ„œ 만료 ν…ŒμŠ€νŠΈ) + await notify_email_only( + title="βœ… [Gmail μ „μš©] μ•Œλ¦Ό ν…ŒμŠ€νŠΈ", + message=( + f"μΈμ¦μ„œ 만료 μž„λ°• μ•Œλ¦Ό μ±„λ„μž…λ‹ˆλ‹€.\n" + f"Gmail둜만 λ°œμ†‘λ©λ‹ˆλ‹€." + ), + color=0xf39c12 + ) + # Discord only (계정 잠금/μž„μ‹œPW ν…ŒμŠ€νŠΈ) + await notify_discord_only( + title="βœ… [Discord μ „μš©] μ•Œλ¦Ό ν…ŒμŠ€νŠΈ", + message=( + f"계정 잠금/μž„μ‹œ λΉ„λ°€λ²ˆν˜Έ λ°œκΈ‰ μ•Œλ¦Ό μ±„λ„μž…λ‹ˆλ‹€.\n" + f"Discord둜만 λ°œμ†‘λ©λ‹ˆλ‹€." + ), + color=0x3498db + ) + return {"ok": True, "message": "채널별 μ•Œλ¦Ό ν…ŒμŠ€νŠΈ μ™„λ£Œ (Discord+Gmail / Gmailμ „μš© / Discordμ „μš©)"} + @app.get("/health") def health(): return {"status": "ok"} diff --git a/backend/monitor.py b/backend/monitor.py new file mode 100755 index 0000000..48bed59 --- /dev/null +++ b/backend/monitor.py @@ -0,0 +1,136 @@ +""" +주기적으둜 μ‹€ν–‰λ˜λŠ” λͺ¨λ‹ˆν„°λ§ μž‘μ—… +- Pod μƒνƒœ 체크 (1λΆ„λ§ˆλ‹€) β†’ Discord + Gmail +- μΈμ¦μ„œ 만료 μž„λ°• 체크 (1μΌλ§ˆλ‹€) β†’ Gmail만 +""" +import os +import asyncio +from datetime import datetime, timezone +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from notifier import notify_both, notify_email_only + +NAMESPACE = os.getenv("NAMESPACE", "web-portal") +ALERT_CERT_DAYS = int(os.getenv("ALERT_CERT_DAYS", "30")) + +# 쀑볡 μ•Œλ¦Ό λ°©μ§€ μΊμ‹œ +_alerted_pods = set() +_alerted_certs = set() + +# ── K8s ν΄λΌμ΄μ–ΈνŠΈ ──────────────────────────────────────── +def get_k8s_clients(): + try: + from kubernetes import client, config + try: + config.load_incluster_config() + except Exception: + config.load_kube_config() + return client.CoreV1Api(), client.CustomObjectsApi() + except Exception as e: + print(f"[MONITOR] K8s client init failed: {e}") + return None, None + +# ── Pod λͺ¨λ‹ˆν„°λ§ β†’ Discord + Gmail ─────────────────────── +async def check_pods(): + v1, _ = get_k8s_clients() + if not v1: + return + try: + pods = v1.list_namespaced_pod(namespace=NAMESPACE) + for pod in pods.items: + name = pod.metadata.name + phase = pod.status.phase + reason = "" + + if pod.status.container_statuses: + for cs in pod.status.container_statuses: + if cs.state.waiting and cs.state.waiting.reason: + reason = cs.state.waiting.reason + if cs.restart_count and cs.restart_count >= 5: + reason = f"RestartCount={cs.restart_count}" + + is_unhealthy = ( + phase in ("Failed", "Unknown") or + reason in ("CrashLoopBackOff", "OOMKilled", "Error", "ImagePullBackOff") + ) + + if is_unhealthy and name not in _alerted_pods: + _alerted_pods.add(name) + await notify_both( + title="🚨 Pod 이상 감지", + message=( + f"λ„€μž„μŠ€νŽ˜μ΄μŠ€: `{NAMESPACE}`\n" + f"Pod: `{name}`\n" + f"μƒνƒœ: `{phase}`\n" + f"원인: `{reason or 'μ•Œ 수 μ—†μŒ'}`\n\n" + f"μ¦‰μ‹œ 확인이 ν•„μš”ν•©λ‹ˆλ‹€." + ), + color=0xe74c3c + ) + elif not is_unhealthy and name in _alerted_pods: + _alerted_pods.discard(name) + await notify_both( + title="βœ… Pod 볡ꡬ됨", + message=( + f"λ„€μž„μŠ€νŽ˜μ΄μŠ€: `{NAMESPACE}`\n" + f"Pod: `{name}` 이 정상 μƒνƒœλ‘œ λ³΅κ΅¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€." + ), + color=0x2ecc71 + ) + except Exception as e: + print(f"[MONITOR] Pod check error: {e}") + +# ── μΈμ¦μ„œ 만료 λͺ¨λ‹ˆν„°λ§ β†’ Gmail만 ─────────────────────── +async def check_certificates(): + _, custom = get_k8s_clients() + if not custom: + return + try: + namespaces = ["web-portal", "gitea", "argocd"] + for ns in namespaces: + try: + certs = custom.list_namespaced_custom_object( + group="cert-manager.io", + version="v1", + namespace=ns, + plural="certificates" + ) + for cert in certs.get("items", []): + name = cert["metadata"]["name"] + not_after = cert.get("status", {}).get("notAfter", "") + if not not_after: + continue + + expiry = datetime.fromisoformat(not_after.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + days_left = (expiry - now).days + alert_key = f"{ns}/{name}" + + if days_left <= ALERT_CERT_DAYS and alert_key not in _alerted_certs: + _alerted_certs.add(alert_key) + await notify_email_only( + title="⚠️ μΈμ¦μ„œ 만료 μž„λ°•", + message=( + f"λ„€μž„μŠ€νŽ˜μ΄μŠ€: `{ns}`\n" + f"μΈμ¦μ„œ: `{name}`\n" + f"λ§Œλ£ŒκΉŒμ§€: `{days_left}일 λ‚¨μŒ`\n" + f"만료일: `{expiry.strftime('%Y-%m-%d')}`\n\n" + f"cert-managerκ°€ μžλ™ 갱신을 μ‹œλ„ν•©λ‹ˆλ‹€.\n" + f"κ°±μ‹  μ‹€νŒ¨ μ‹œ μˆ˜λ™μœΌλ‘œ ν™•μΈν•˜μ„Έμš”." + ), + color=0xf39c12 + ) + elif days_left > ALERT_CERT_DAYS and alert_key in _alerted_certs: + _alerted_certs.discard(alert_key) + except Exception: + pass + except Exception as e: + print(f"[MONITOR] Certificate check error: {e}") + +# ── μŠ€μΌ€μ€„λŸ¬ μ‹œμž‘ ───────────────────────────────────────── +def start_scheduler(): + scheduler = AsyncIOScheduler() + scheduler.add_job(check_pods, "interval", minutes=1, id="pod_check") + scheduler.add_job(check_certificates, "interval", hours=24, id="cert_check") + scheduler.start() + print("[MONITOR] Scheduler started (Pod: 1min / Cert: 24hr)") + return scheduler diff --git a/backend/notifier.py b/backend/notifier.py new file mode 100755 index 0000000..7fd8985 --- /dev/null +++ b/backend/notifier.py @@ -0,0 +1,85 @@ +import os +import smtplib +import httpx +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from datetime import datetime + +# ── ν™˜κ²½λ³€μˆ˜ ────────────────────────────────────────────── +DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "") +GMAIL_USER = os.getenv("GMAIL_USER", "") +GMAIL_APP_PASSWORD = os.getenv("GMAIL_APP_PASSWORD", "") +ALERT_EMAIL_TO = os.getenv("ALERT_EMAIL_TO", "") + +# ── Discord ─────────────────────────────────────────────── +async def send_discord(title: str, message: str, color: int = 0xe74c3c): + if not DISCORD_WEBHOOK_URL: + print("[NOTIFIER] Discord webhook URL not set, skipping") + return + payload = { + "embeds": [{ + "title": title, + "description": message, + "color": color, + "footer": {"text": "Web Portal Monitor"}, + "timestamp": datetime.utcnow().isoformat() + }] + } + try: + async with httpx.AsyncClient() as client: + res = await client.post(DISCORD_WEBHOOK_URL, json=payload, timeout=10) + if res.status_code not in (200, 204): + print(f"[NOTIFIER] Discord error: {res.status_code} {res.text}") + else: + print(f"[NOTIFIER] Discord sent: {title}") + except Exception as e: + print(f"[NOTIFIER] Discord exception: {e}") + +# ── Gmail ───────────────────────────────────────────────── +def send_email(subject: str, body: str): + if not all([GMAIL_USER, GMAIL_APP_PASSWORD, ALERT_EMAIL_TO]): + print("[NOTIFIER] Gmail config not set, skipping") + return + try: + msg = MIMEMultipart("alternative") + msg["Subject"] = f"[Web Portal] {subject}" + msg["From"] = GMAIL_USER + msg["To"] = ALERT_EMAIL_TO + + html = f""" + +
+

⚠️ {subject}

+
+
+

{body}

+
+ Web Portal Monitor Β· {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +
+ + """ + msg.attach(MIMEText(html, "html")) + with smtplib.SMTP_SSL("smtp.gmail.com", 465) as smtp: + smtp.login(GMAIL_USER, GMAIL_APP_PASSWORD) + smtp.sendmail(GMAIL_USER, ALERT_EMAIL_TO, msg.as_string()) + print(f"[NOTIFIER] Email sent: {subject}") + except Exception as e: + print(f"[NOTIFIER] Email exception: {e}") + +# ── 채널별 μ•Œλ¦Ό ν•¨μˆ˜ ────────────────────────────────────── +import asyncio + +async def notify_both(title: str, message: str, color: int = 0xe74c3c): + """Discord + Gmail λ‘˜ λ‹€ 전솑 (Pod 이상/볡ꡬ)""" + await send_discord(title, message, color) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, send_email, title, message) + +async def notify_email_only(title: str, message: str, color: int = 0xf39c12): + """Gmail만 전솑 (μΈμ¦μ„œ 만료 μž„λ°•)""" + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, send_email, title, message) + +async def notify_discord_only(title: str, message: str, color: int = 0xe74c3c): + """Discord만 전솑 (계정 잠금, μž„μ‹œ λΉ„λ°€λ²ˆν˜Έ λ°œκΈ‰)""" + await send_discord(title, message, color) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9ac27e1..66a22c5 100755 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,6 @@ bcrypt==4.1.2 PyJWT==2.8.0 pydantic==2.6.4 python-multipart==0.0.9 +kubernetes==29.0.0 +httpx==0.27.0 +apscheduler==3.10.4 diff --git a/k8s/04-backend.yaml b/k8s/04-backend.yaml index 7f7c953..6c4670f 100755 --- a/k8s/04-backend.yaml +++ b/k8s/04-backend.yaml @@ -13,6 +13,7 @@ spec: labels: app: backend spec: + serviceAccountName: portal-backend-sa imagePullSecrets: - name: gitea-registry-secret containers: @@ -40,6 +41,30 @@ spec: secretKeyRef: name: portal-secrets key: jwt-secret + - name: DISCORD_WEBHOOK_URL + valueFrom: + secretKeyRef: + name: notify-secrets + key: discord-webhook-url + - name: GMAIL_USER + valueFrom: + secretKeyRef: + name: notify-secrets + key: gmail-user + - name: GMAIL_APP_PASSWORD + valueFrom: + secretKeyRef: + name: notify-secrets + key: gmail-app-password + - name: ALERT_EMAIL_TO + valueFrom: + secretKeyRef: + name: notify-secrets + key: alert-email-to + - name: NAMESPACE + value: web-portal + - name: ALERT_CERT_DAYS + value: "30" readinessProbe: httpGet: path: /health diff --git a/k8s/12-monitor-rbac.yaml b/k8s/12-monitor-rbac.yaml new file mode 100755 index 0000000..0613564 --- /dev/null +++ b/k8s/12-monitor-rbac.yaml @@ -0,0 +1,59 @@ +# λ°±μ—”λ“œ Podκ°€ K8s APIμ—μ„œ Pod/Certificate 정보λ₯Ό 읽을 수 μžˆλ„λ‘ κΆŒν•œ λΆ€μ—¬ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: portal-backend-sa + namespace: web-portal +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: portal-monitor-role + namespace: web-portal +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] +- apiGroups: ["cert-manager.io"] + resources: ["certificates"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: portal-monitor-rolebinding + namespace: web-portal +subjects: +- kind: ServiceAccount + name: portal-backend-sa + namespace: web-portal +roleRef: + kind: Role + name: portal-monitor-role + apiGroup: rbac.authorization.k8s.io +--- +# gitea, argocd λ„€μž„μŠ€νŽ˜μ΄μŠ€ μΈμ¦μ„œ 읽기 κΆŒν•œ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: portal-cert-reader +rules: +- apiGroups: ["cert-manager.io"] + resources: ["certificates"] + verbs: ["get", "list", "watch"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: portal-cert-reader-binding +subjects: +- kind: ServiceAccount + name: portal-backend-sa + namespace: web-portal +roleRef: + kind: ClusterRole + name: portal-cert-reader + apiGroup: rbac.authorization.k8s.io