feat: Discord/Gmail 알림 기능 추가
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
k8s/11-notify-secrets.yaml
|
||||
@@ -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
136
backend/monitor.py
Executable 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
85
backend/notifier.py
Executable 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
59
k8s/12-monitor-rbac.yaml
Executable 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
|
||||
Reference in New Issue
Block a user