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 fastapi import FastAPI, HTTPException, Depends, status
from contextlib import asynccontextmanager
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
@@ -12,7 +13,16 @@ import secrets
import string import string
from datetime import datetime, timedelta 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -170,6 +180,16 @@ def login(req: LoginRequest, conn=Depends(get_db)):
) )
conn.commit() conn.commit()
if locked: 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.") raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts. Please contact admin.")
remaining = MAX_LOGIN_ATTEMPTS - attempts remaining = MAX_LOGIN_ATTEMPTS - attempts
raise HTTPException(status_code=401, detail=f"Invalid credentials. {remaining} attempts remaining.") 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: 임시 비밀번호 발급 ─────────────────────────── # ─── Admin: 임시 비밀번호 발급 ───────────────────────────
@app.post("/api/admin/users/{user_id}/reset-password") @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() temp_pw = generate_temp_password()
hashed = bcrypt.hashpw(temp_pw.encode(), bcrypt.gensalt()).decode() 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( cur.execute(
"""UPDATE users SET password_hash=%s, must_change_password=TRUE, """UPDATE users SET password_hash=%s, must_change_password=TRUE,
login_attempts=0, is_locked=FALSE, password_change_requested=FALSE 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) (hashed, user_id)
) )
conn.commit() 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} return {"ok": True, "temp_password": temp_pw}
# ─── Admin: 계정 잠금 해제 ─────────────────────────────── # ─── Admin: 계정 잠금 해제 ───────────────────────────────
@@ -390,6 +422,37 @@ def update_user_pages(user_id: int, data: AccessUpdate, token=Depends(require_ad
conn.commit() conn.commit()
return {"ok": True} 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") @app.get("/health")
def health(): def health():
return {"status": "ok"} 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 PyJWT==2.8.0
pydantic==2.6.4 pydantic==2.6.4
python-multipart==0.0.9 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: labels:
app: backend app: backend
spec: spec:
serviceAccountName: portal-backend-sa
imagePullSecrets: imagePullSecrets:
- name: gitea-registry-secret - name: gitea-registry-secret
containers: containers:
@@ -40,6 +41,30 @@ spec:
secretKeyRef: secretKeyRef:
name: portal-secrets name: portal-secrets
key: jwt-secret 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: readinessProbe:
httpGet: httpGet:
path: /health 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