feat: Discord/Gmail 알림 기능 추가
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled

This commit is contained in:
qorgh529
2026-04-15 19:28:05 +09:00
parent dad98fedfa
commit 91b57b298e
7 changed files with 375 additions and 3 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
k8s/11-notify-secrets.yaml

View File

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

136
backend/monitor.py Executable file
View File

@@ -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

85
backend/notifier.py Executable file
View File

@@ -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"""
<html><body style="font-family:sans-serif;padding:20px">
<div style="background:#e74c3c;color:white;padding:12px 20px;border-radius:8px 8px 0 0">
<h2 style="margin:0">⚠️ {subject}</h2>
</div>
<div style="border:1px solid #ddd;border-top:none;padding:20px;border-radius:0 0 8px 8px">
<p style="white-space:pre-line">{body}</p>
<hr/>
<small style="color:#999">Web Portal Monitor · {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</small>
</div>
</body></html>
"""
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)

View File

@@ -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

View File

@@ -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

59
k8s/12-monitor-rbac.yaml Executable file
View File

@@ -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