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""" +
+{body}
+