From 8277653c43b2a78d7e40bacf5f53745a63d4e758 Mon Sep 17 00:00:00 2001 From: Khalid MOUTAOUAKIL Date: Sat, 4 Apr 2026 03:00:12 +0200 Subject: [PATCH] =?UTF-8?q?PatchCenter=20v2.0=20=E2=80=94=20Initial=20comm?= =?UTF-8?q?it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modules: Dashboard, Serveurs, Campagnes, Planning, Specifiques, Settings, Users Stack: FastAPI + Jinja2 + HTMX + Alpine.js + TailwindCSS + PostgreSQL Features: Qualys sync, prereqs auto, planning annuel, server specifics, role-based access Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 7 + app/__init__.py | 0 app/auth.py | 25 ++ app/config.py | 18 + app/database.py | 7 + app/dependencies.py | 20 + app/main.py | 28 ++ app/models/__init__.py | 0 app/routers/__init__.py | 0 app/routers/auth.py | 44 +++ app/routers/campaigns.py | 212 ++++++++++ app/routers/dashboard.py | 48 +++ app/routers/planning.py | 200 ++++++++++ app/routers/servers.py | 113 ++++++ app/routers/settings.py | 202 ++++++++++ app/routers/specifics.py | 159 ++++++++ app/routers/users.py | 142 +++++++ app/services/__init__.py | 0 app/services/campaign_service.py | 287 ++++++++++++++ app/services/prereq_service.py | 291 ++++++++++++++ app/services/qualys_service.py | 156 ++++++++ app/services/secrets_service.py | 64 +++ app/services/server_service.py | 227 +++++++++++ app/templates/base.html | 90 +++++ app/templates/campaign_detail.html | 250 ++++++++++++ app/templates/campaigns.html | 82 ++++ app/templates/dashboard.html | 79 ++++ app/templates/login.html | 27 ++ app/templates/partials/campaign_preview.html | 49 +++ app/templates/partials/server_detail.html | 120 ++++++ app/templates/partials/server_edit.html | 85 ++++ app/templates/partials/specific_edit.html | 182 +++++++++ app/templates/planning.html | 243 ++++++++++++ app/templates/servers.html | 109 ++++++ app/templates/settings.html | 389 +++++++++++++++++++ app/templates/specifics.html | 96 +++++ app/templates/users.html | 110 ++++++ run.sh | 4 + 38 files changed, 4165 insertions(+) create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/auth.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/dependencies.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/campaigns.py create mode 100644 app/routers/dashboard.py create mode 100644 app/routers/planning.py create mode 100644 app/routers/servers.py create mode 100644 app/routers/settings.py create mode 100644 app/routers/specifics.py create mode 100644 app/routers/users.py create mode 100644 app/services/__init__.py create mode 100644 app/services/campaign_service.py create mode 100644 app/services/prereq_service.py create mode 100644 app/services/qualys_service.py create mode 100644 app/services/secrets_service.py create mode 100644 app/services/server_service.py create mode 100644 app/templates/base.html create mode 100644 app/templates/campaign_detail.html create mode 100644 app/templates/campaigns.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/login.html create mode 100644 app/templates/partials/campaign_preview.html create mode 100644 app/templates/partials/server_detail.html create mode 100644 app/templates/partials/server_edit.html create mode 100644 app/templates/partials/specific_edit.html create mode 100644 app/templates/planning.html create mode 100644 app/templates/servers.html create mode 100644 app/templates/settings.html create mode 100644 app/templates/specifics.html create mode 100644 app/templates/users.html create mode 100755 run.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c16d6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +venv/ +.env +keys/ +*.log +start.sh diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..b9b9ed2 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,25 @@ +from datetime import datetime, timedelta +from jose import JWTError, jwt +from passlib.context import CryptContext +from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + +def create_access_token(data: dict): + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + +def decode_token(token: str): + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + return None diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..3c9aa2a --- /dev/null +++ b/app/config.py @@ -0,0 +1,18 @@ +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://patchcenter:PatchCenter2026!@localhost:5432/patchcenter_db") +SECRET_KEY = os.getenv("SECRET_KEY", "slpm-patchcenter-secret-key-2026-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 480 # 8 heures +APP_NAME = "PatchCenter" +APP_VERSION = "2.0" + +# Qualys API +QUALYS_URL = os.getenv("QUALYS_URL", "https://qualysapi.qualys.eu") +QUALYS_USER = os.getenv("QUALYS_USER", "sanef-ae") +QUALYS_PASS = os.getenv("QUALYS_PASS", 'DW:Q\\*"JEZr2tjZ=!Ox4') + +# iTop API (a configurer) +ITOP_URL = os.getenv("ITOP_URL", "") +ITOP_USER = os.getenv("ITOP_USER", "") +ITOP_PASS = os.getenv("ITOP_PASS", "") diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..d3b7c90 --- /dev/null +++ b/app/database.py @@ -0,0 +1,7 @@ +"""SQLAlchemy engine et session""" +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from .config import DATABASE_URL + +engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_size=10) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..ab23deb --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,20 @@ +"""Dependances communes pour les routers""" +from fastapi import Request +from .auth import decode_token +from .database import SessionLocal + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_current_user(request: Request): + """Extrait l'utilisateur du cookie JWT""" + token = request.cookies.get("access_token") + if not token: + return None + return decode_token(token) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..07420c9 --- /dev/null +++ b/app/main.py @@ -0,0 +1,28 @@ +"""PatchCenter v2 — Entry point FastAPI""" +from fastapi import FastAPI +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles +from .config import APP_NAME, APP_VERSION +from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics + +app = FastAPI(title=APP_NAME, version=APP_VERSION) +app.mount("/static", StaticFiles(directory="app/static"), name="static") + +app.include_router(auth.router) +app.include_router(dashboard.router) +app.include_router(servers.router) +app.include_router(settings.router) +app.include_router(users.router) +app.include_router(campaigns.router) +app.include_router(planning.router) +app.include_router(specifics.router) + + +@app.get("/") +async def root(): + return RedirectResponse(url="/login") + + +@app.get("/health") +async def health(): + return {"status": "ok", "app": APP_NAME, "version": APP_VERSION} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..82cc0b9 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import text +from ..dependencies import get_db, get_current_user +from ..auth import verify_password, create_access_token, hash_password +from ..config import APP_NAME, APP_VERSION + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + return templates.TemplateResponse("login.html", { + "request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": None + }) + +@router.post("/login") +async def login(request: Request, username: str = Form(...), password: str = Form(...), db=Depends(get_db)): + row = db.execute(text("SELECT id, username, password_hash, role FROM users WHERE LOWER(username) = LOWER(:u)"), + {"u": username}).fetchone() + if not row: + return templates.TemplateResponse("login.html", { + "request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Utilisateur inconnu" + }) + # Verifier mot de passe (bcrypt pour web, PBKDF2 legacy pour SLPM) + try: + ok = verify_password(password, row.password_hash) + except Exception: + ok = False + if not ok: + return templates.TemplateResponse("login.html", { + "request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Mot de passe incorrect" + }) + token = create_access_token({"sub": row.username, "role": row.role, "uid": row.id}) + response = RedirectResponse(url="/dashboard", status_code=303) + response.set_cookie(key="access_token", value=token, httponly=True, samesite="lax") + return response + +@router.get("/logout") +async def logout(): + response = RedirectResponse(url="/login", status_code=302) + response.delete_cookie("access_token") + return response diff --git a/app/routers/campaigns.py b/app/routers/campaigns.py new file mode 100644 index 0000000..f175108 --- /dev/null +++ b/app/routers/campaigns.py @@ -0,0 +1,212 @@ +"""Router campagnes — creation depuis planning + gestion exclusions""" +from datetime import datetime +from fastapi import APIRouter, Request, Depends, Query, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import text +from ..dependencies import get_db, get_current_user +from ..services.campaign_service import ( + list_campaigns, get_campaign, get_campaign_sessions, get_campaign_stats, + create_campaign_from_planning, get_servers_for_planning, + update_campaign_status, exclude_session, restore_session, + validate_prereq, get_prereq_stats, can_plan_campaign, + bulk_auto_exclude_failed_prereqs, +) +from ..services.prereq_service import check_prereqs_campaign, check_single_prereq +from ..config import APP_NAME + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + +EXCLUSION_REASONS = [ + ("eol", "Fin de vie (EOL)"), + ("creneau_inadequat", "Creneau non adequat"), + ("intervention_non_secops", "Intervention non-SecOps prevue"), + ("report_cycle", "Report au cycle suivant"), + ("non_patchable", "Serveur non patchable"), + ("autre", "Autre"), +] + + +@router.get("/campaigns", response_class=HTMLResponse) +async def campaigns_list(request: Request, db=Depends(get_db), + year: int = Query(None), status: str = Query(None)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + if not year: + year = datetime.now().year + campaigns = list_campaigns(db, year=year, status=status) + + # Semaines planifiees pour cette annee (pour le formulaire de creation) + now = datetime.now() + current_week = now.isocalendar()[1] + planned_weeks = db.execute(text(""" + SELECT DISTINCT pp.week_number, pp.week_code, pp.week_start, pp.week_end, + string_agg(DISTINCT d.name || ' (' || pp.env_scope || ')', ', ' ORDER BY d.name || ' (' || pp.env_scope || ')') as scope + FROM patch_planning pp + LEFT JOIN domains d ON pp.domain_code = d.code + WHERE pp.year = :y AND pp.status = 'open' AND pp.domain_code IS NOT NULL + AND pp.week_number >= :cw + GROUP BY pp.week_number, pp.week_code, pp.week_start, pp.week_end + ORDER BY pp.week_number + """), {"y": year, "cw": current_week}).fetchall() + + return templates.TemplateResponse("campaigns.html", { + "request": request, "user": user, "app_name": APP_NAME, + "campaigns": campaigns, "year": year, "status_filter": status, + "planned_weeks": planned_weeks, + }) + + +@router.get("/campaigns/preview", response_class=HTMLResponse) +async def campaign_preview(request: Request, db=Depends(get_db), + year: int = Query(...), week: int = Query(...)): + """HTMX: preview des serveurs pour une semaine du planning""" + user = get_current_user(request) + if not user: + return HTMLResponse("

Non autorise

") + servers, planning = get_servers_for_planning(db, year, week) + scope = ", ".join(set(f"{p.domain_name} ({p.env_scope})" for p in planning if p.domain_code)) + return templates.TemplateResponse("partials/campaign_preview.html", { + "request": request, "servers": servers, "scope": scope, + "week": week, "year": year, "count": len(servers), + }) + + +@router.post("/campaigns/create") +async def campaign_create(request: Request, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + + form = await request.form() + year = int(form.get("year", datetime.now().year)) + week = int(form.get("week_number", 0)) + label = form.get("label", f"Patch S{week:02d} {year}") + + # Serveurs exclus (checkboxes non cochees) + excluded = [] + for key in form.keys(): + if key.startswith("exclude_"): + sid = int(key.replace("exclude_", "")) + excluded.append(sid) + + cid = create_campaign_from_planning(db, year, week, label, user.get("uid"), excluded) + if not cid: + return RedirectResponse(url=f"/campaigns?year={year}&msg=no_servers", status_code=303) + + return RedirectResponse(url=f"/campaigns/{cid}", status_code=303) + + +@router.get("/campaigns/{campaign_id}", response_class=HTMLResponse) +async def campaign_detail(request: Request, campaign_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + + campaign = get_campaign(db, campaign_id) + if not campaign: + return RedirectResponse(url="/campaigns") + + sessions = get_campaign_sessions(db, campaign_id) + stats = get_campaign_stats(db, campaign_id) + prereq = get_prereq_stats(db, campaign_id) + can_plan = can_plan_campaign(db, campaign_id) + + return templates.TemplateResponse("campaign_detail.html", { + "request": request, "user": user, "app_name": APP_NAME, + "c": campaign, "sessions": sessions, "stats": stats, + "prereq": prereq, "can_plan": can_plan, + "exclusion_reasons": EXCLUSION_REASONS, + "msg": request.query_params.get("msg"), + }) + + +@router.post("/campaigns/{campaign_id}/status") +async def campaign_status_change(request: Request, campaign_id: int, + db=Depends(get_db), new_status: str = Form(...)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + # Bloquer planned si prereqs non valides + if new_status == "planned" and not can_plan_campaign(db, campaign_id): + return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=prereq_needed", status_code=303) + update_campaign_status(db, campaign_id, new_status) + return RedirectResponse(url=f"/campaigns/{campaign_id}", status_code=303) + + +@router.post("/campaigns/session/{session_id}/prereq") +async def session_prereq(request: Request, session_id: int, db=Depends(get_db), + prereq_ssh: str = Form(...), prereq_satellite: str = Form(...), + rollback_method: str = Form(""), rollback_justif: str = Form("")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + validate_prereq(db, session_id, prereq_ssh, prereq_satellite, + rollback_method or None, rollback_justif, user.get("sub")) + row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), + {"id": session_id}).fetchone() + cid = row.campaign_id if row else 0 + return RedirectResponse(url=f"/campaigns/{cid}?msg=prereq_saved#row-{session_id}", status_code=303) + + +@router.post("/campaigns/{campaign_id}/auto-exclude-failed") +async def auto_exclude_failed(request: Request, campaign_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + count = bulk_auto_exclude_failed_prereqs(db, campaign_id, user.get("sub")) + return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=auto_excluded_{count}", status_code=303) + + +@router.post("/campaigns/{campaign_id}/check-prereqs") +async def campaign_check_prereqs(request: Request, campaign_id: int, db=Depends(get_db)): + """Lance la verification automatique des prereqs pour toute la campagne""" + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + checked, auto_excluded = check_prereqs_campaign(db, campaign_id) + return RedirectResponse( + url=f"/campaigns/{campaign_id}?msg=checked_{checked}_{auto_excluded}", + status_code=303 + ) + + +@router.post("/campaigns/session/{session_id}/check-prereq") +async def session_check_prereq(request: Request, session_id: int, db=Depends(get_db)): + """Lance la verification prereq pour un seul serveur""" + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + check_single_prereq(db, session_id) + row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), + {"id": session_id}).fetchone() + cid = row.campaign_id if row else 0 + return RedirectResponse(url=f"/campaigns/{cid}?msg=prereq_checked#row-{session_id}", status_code=303) + + +@router.post("/campaigns/session/{session_id}/exclude") +async def session_exclude(request: Request, session_id: int, db=Depends(get_db), + reason: str = Form(...), detail: str = Form("")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + exclude_session(db, session_id, reason, detail, user.get("sub")) + # Retrouver campaign_id + row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), + {"id": session_id}).fetchone() + cid = row.campaign_id if row else 0 + return RedirectResponse(url=f"/campaigns/{cid}?msg=excluded#row-{session_id}", status_code=303) + + +@router.post("/campaigns/session/{session_id}/restore") +async def session_restore(request: Request, session_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + restore_session(db, session_id) + row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), + {"id": session_id}).fetchone() + cid = row.campaign_id if row else 0 + return RedirectResponse(url=f"/campaigns/{cid}?msg=restored#row-{session_id}", status_code=303) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py new file mode 100644 index 0000000..8ce0a2c --- /dev/null +++ b/app/routers/dashboard.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, Request, Depends +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import text +from ..dependencies import get_db, get_current_user +from ..config import APP_NAME + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/dashboard", response_class=HTMLResponse) +async def dashboard(request: Request, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + + # Stats + stats = {} + stats["total_servers"] = db.execute(text("SELECT COUNT(*) FROM servers")).scalar() + stats["patchable"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_os_owner='secops' AND etat='en_production'")).scalar() + stats["linux"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE os_family='linux'")).scalar() + stats["windows"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE os_family='windows'")).scalar() + stats["decom"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='decommissionne'")).scalar() + stats["eol"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE licence_support='eol'")).scalar() + stats["qualys_assets"] = db.execute(text("SELECT COUNT(*) FROM qualys_assets")).scalar() + stats["qualys_tags"] = db.execute(text("SELECT COUNT(*) FROM qualys_tags")).scalar() + + # Par domaine + domains = db.execute(text(""" + SELECT d.name, d.code, COUNT(s.id) as total, + COUNT(*) FILTER (WHERE s.etat='en_production') as actifs, + COUNT(*) FILTER (WHERE s.os_family='linux') as linux, + COUNT(*) FILTER (WHERE s.os_family='windows') as windows + FROM servers s + JOIN domain_environments de ON s.domain_env_id = de.id + JOIN domains d ON de.domain_id = d.id + GROUP BY d.name, d.code, d.display_order + ORDER BY d.display_order + """)).fetchall() + + # Par tier + tiers = db.execute(text("SELECT tier, COUNT(*) FROM servers GROUP BY tier ORDER BY tier")).fetchall() + + return templates.TemplateResponse("dashboard.html", { + "request": request, "user": user, "app_name": APP_NAME, + "stats": stats, "domains": domains, "tiers": tiers + }) diff --git a/app/routers/planning.py b/app/routers/planning.py new file mode 100644 index 0000000..31aacf0 --- /dev/null +++ b/app/routers/planning.py @@ -0,0 +1,200 @@ +"""Router planning — planning annuel de patching par domaine""" +from datetime import datetime, date, timedelta +from fastapi import APIRouter, Request, Depends, Query, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import text +from ..dependencies import get_db, get_current_user +from ..config import APP_NAME + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + +MONTHS = ["Jan", "Fev", "Mar", "Avr", "Mai", "Jun", "Jul", "Aou", "Sep", "Oct", "Nov", "Dec"] +DOMAIN_COLORS = { + "TRA": "#E67E22", "PEA": "#8E44AD", "FL": "#2ECC71", "BI": "#F1C40F", + "INF": "#3498DB", "GES": "#1ABC9C", "EMV": "#E74C3C", "DMZ": "#D4A0A0", +} +ENV_SCOPES = ["prod", "hprod", "all", "pilot", "prod_pilot"] +STATUSES = ["open", "freeze", "holiday", "empty"] + + +def _week_dates(year, week_num): + jan4 = date(year, 1, 4) + start_of_w1 = jan4 - timedelta(days=jan4.isoweekday() - 1) + monday = start_of_w1 + timedelta(weeks=week_num - 1) + sunday = monday + timedelta(days=6) + return monday, sunday + + +def _get_planning_data(db, year): + rows = db.execute(text(""" + SELECT pp.*, d.name as domain_name + FROM patch_planning pp + LEFT JOIN domains d ON pp.domain_code = d.code + WHERE pp.year = :y + ORDER BY pp.week_number, pp.domain_code + """), {"y": year}).fetchall() + + domains = db.execute(text(""" + SELECT d.code, d.name, COUNT(s.id) as srv_count + FROM domains d + LEFT JOIN domain_environments de ON de.domain_id = d.id + LEFT JOIN servers s ON s.domain_env_id = de.id AND s.etat = 'en_production' + GROUP BY d.code, d.name, d.display_order + ORDER BY d.display_order + """)).fetchall() + + freeze_weeks = set() + grid = {} + for r in rows: + if r.status == 'freeze': + freeze_weeks.add(r.week_number) + if r.domain_code: + if r.domain_code not in grid: + grid[r.domain_code] = {} + grid[r.domain_code][r.week_number] = { + "id": r.id, "cycle": r.cycle, "env_scope": r.env_scope, + "note": r.note, "status": r.status, + } + + all_domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall() + + # Annees disponibles + years_in_db = db.execute(text("SELECT DISTINCT year FROM patch_planning ORDER BY year")).fetchall() + available_years = [r.year for r in years_in_db] + + return rows, domains, grid, freeze_weeks, all_domains, available_years + + +@router.get("/planning", response_class=HTMLResponse) +async def planning_page(request: Request, db=Depends(get_db), + year: int = Query(None), msg: str = Query(None)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + if not year: + year = datetime.now().year + + rows, domains, grid, freeze_weeks, all_domains, available_years = _get_planning_data(db, year) + + now = datetime.now() + next_week = now.isocalendar()[1] + 1 + if next_week > 53: + next_week = 1 + + return templates.TemplateResponse("planning.html", { + "request": request, "user": user, "app_name": APP_NAME, + "year": year, "domains": domains, "grid": grid, + "freeze_weeks": freeze_weeks, "months": MONTHS, + "domain_colors": DOMAIN_COLORS, "weeks": range(1, 54), + "entries": rows, "all_domains": all_domains, + "env_scopes": ENV_SCOPES, "statuses": STATUSES, + "available_years": available_years, "msg": msg, + "default_week": next_week, + }) + + +@router.post("/planning/add") +async def planning_add(request: Request, db=Depends(get_db), + year: str = Form(...), week_number: str = Form(...), + domain_code: str = Form(""), env_scope: str = Form("all"), + cycle: str = Form(""), status: str = Form("open"), note: str = Form("")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + + y = int(year) + wn = int(week_number) if week_number else 0 + cyc = int(cycle) if cycle.strip() else None + + if not wn or wn < 1 or wn > 53: + return RedirectResponse(url=f"/planning?year={y}&msg=err_week", status_code=303) + if not domain_code and status == 'open': + return RedirectResponse(url=f"/planning?year={y}&msg=err_domain", status_code=303) + + # Pas dans le passe — semaine en cours acceptee lundi/mardi (MEP urgente) + now = datetime.now() + current_week = now.isocalendar()[1] + current_year = now.isocalendar()[0] + weekday = now.isoweekday() # 1=lundi, 7=dimanche + if y < current_year: + return RedirectResponse(url=f"/planning?year={y}&msg=err_past", status_code=303) + if y == current_year: + if wn < current_week: + return RedirectResponse(url=f"/planning?year={y}&msg=err_past", status_code=303) + if wn == current_week and weekday > 2: + return RedirectResponse(url=f"/planning?year={y}&msg=err_past_wed", status_code=303) + + ws, we = _week_dates(y, wn) + wc = f"S{wn:02d}" + db.execute(text(""" + INSERT INTO patch_planning (year, week_number, week_code, week_start, week_end, cycle, domain_code, env_scope, status, note) + VALUES (:y, :wn, :wc, :ws, :we, :cyc, :dc, :es, :st, :nt) + """), {"y": y, "wn": wn, "wc": wc, "ws": ws, "we": we, + "cyc": cyc, "dc": domain_code or None, "es": env_scope, "st": status, + "nt": note or None}) + db.commit() + return RedirectResponse(url=f"/planning?year={y}&msg=add", status_code=303) + + +@router.post("/planning/{entry_id}/edit") +async def planning_edit(request: Request, entry_id: int, db=Depends(get_db), + domain_code: str = Form(""), env_scope: str = Form("all"), + cycle: str = Form(""), status: str = Form("open"), note: str = Form("")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + row = db.execute(text("SELECT year FROM patch_planning WHERE id = :id"), {"id": entry_id}).fetchone() + cyc = int(cycle) if cycle.strip() else None + db.execute(text(""" + UPDATE patch_planning SET domain_code = :dc, env_scope = :es, cycle = :cyc, status = :st, note = :nt + WHERE id = :id + """), {"dc": domain_code or None, "es": env_scope, "cyc": cyc, + "st": status, "nt": note or None, "id": entry_id}) + db.commit() + y = row.year if row else datetime.now().year + return RedirectResponse(url=f"/planning?year={y}&msg=edit", status_code=303) + + +@router.post("/planning/{entry_id}/delete") +async def planning_delete(request: Request, entry_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + row = db.execute(text("SELECT year FROM patch_planning WHERE id = :id"), {"id": entry_id}).fetchone() + db.execute(text("DELETE FROM patch_planning WHERE id = :id"), {"id": entry_id}) + db.commit() + y = row.year if row else datetime.now().year + return RedirectResponse(url=f"/planning?year={y}&msg=delete", status_code=303) + + +@router.post("/planning/duplicate") +async def planning_duplicate(request: Request, db=Depends(get_db), + source_year: int = Form(...), target_year: int = Form(...)): + """Duplique le planning d'une annee vers une autre""" + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + + # Verifier que l'annee cible est vide + existing = db.execute(text("SELECT COUNT(*) FROM patch_planning WHERE year = :y"), + {"y": target_year}).scalar() + if existing > 0: + return RedirectResponse(url=f"/planning?year={target_year}&msg=exists", status_code=303) + + # Copier toutes les entrees en recalculant les dates + sources = db.execute(text("SELECT * FROM patch_planning WHERE year = :y ORDER BY week_number"), + {"y": source_year}).fetchall() + for s in sources: + ws, we = _week_dates(target_year, s.week_number) + wc = f"S{s.week_number:02d}" + db.execute(text(""" + INSERT INTO patch_planning (year, week_number, week_code, week_start, week_end, cycle, domain_code, env_scope, status, note) + VALUES (:y, :wn, :wc, :ws, :we, :cyc, :dc, :es, :st, :nt) + """), {"y": target_year, "wn": s.week_number, "wc": wc, "ws": ws, "we": we, + "cyc": s.cycle, "dc": s.domain_code, "es": s.env_scope, "st": s.status, + "nt": s.note}) + + db.commit() + return RedirectResponse(url=f"/planning?year={target_year}&msg=duplicate", status_code=303) diff --git a/app/routers/servers.py b/app/routers/servers.py new file mode 100644 index 0000000..05f10c4 --- /dev/null +++ b/app/routers/servers.py @@ -0,0 +1,113 @@ +"""Router serveurs — CRUD + detail + edit via HTMX""" +from fastapi import APIRouter, Request, Depends, Query, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from ..dependencies import get_db, get_current_user +from ..services.server_service import ( + get_server_full, get_server_tags, get_server_ips, + list_servers, update_server, get_reference_data +) +from ..services.qualys_service import sync_server_qualys +from ..config import APP_NAME + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/servers", response_class=HTMLResponse) +async def servers_list(request: Request, db=Depends(get_db), + domain: str = Query(None), env: str = Query(None), + tier: str = Query(None), etat: str = Query(None), + search: str = Query(None), page: int = Query(1), + sort: str = Query("hostname"), sort_dir: str = Query("asc")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + + filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "search": search} + servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir) + domains_list, envs_list = get_reference_data(db) + + return templates.TemplateResponse("servers.html", { + "request": request, "user": user, "app_name": APP_NAME, + "servers": servers, "total": total, "page": page, "per_page": 50, + "domains_list": domains_list, "envs_list": envs_list, "filters": filters, + "sort": sort, "sort_dir": sort_dir, + }) + + +@router.get("/servers/{server_id}/detail", response_class=HTMLResponse) +async def server_detail(request: Request, server_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return HTMLResponse("

Non autorise

") + s = get_server_full(db, server_id) + if not s: + return HTMLResponse("

Serveur non trouve

") + tags = get_server_tags(db, s.qid) + ips = get_server_ips(db, server_id) + return templates.TemplateResponse("partials/server_detail.html", { + "request": request, "s": s, "tags": tags, "ips": ips + }) + + +@router.get("/servers/{server_id}/edit", response_class=HTMLResponse) +async def server_edit(request: Request, server_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return HTMLResponse("

Non autorise

") + s = get_server_full(db, server_id) + if not s: + return HTMLResponse("

Serveur non trouve

") + domains, envs = get_reference_data(db) + ips = get_server_ips(db, server_id) + return templates.TemplateResponse("partials/server_edit.html", { + "request": request, "s": s, "domains": domains, "envs": envs, "ips": ips + }) + + +@router.put("/servers/{server_id}", response_class=HTMLResponse) +async def server_update(request: Request, server_id: int, db=Depends(get_db), + domain_code: str = Form(None), env_code: str = Form(None), + zone: str = Form(None), tier: str = Form(None), etat: str = Form(None), + patch_os_owner: str = Form(None), responsable_nom: str = Form(None), + referent_nom: str = Form(None), mode_operatoire: str = Form(None), + commentaire: str = Form(None), + ip_reelle: str = Form(None), ip_connexion: str = Form(None), + ssh_method: str = Form(None)): + + user = get_current_user(request) + if not user: + return HTMLResponse("

Non autorise

") + + data = { + "domain_code": domain_code, "env_code": env_code, "zone": zone, + "tier": tier, "etat": etat, "patch_os_owner": patch_os_owner, + "responsable_nom": responsable_nom, "referent_nom": referent_nom, + "mode_operatoire": mode_operatoire, "commentaire": commentaire, + "ip_reelle": ip_reelle, "ip_connexion": ip_connexion, + "ssh_method": ssh_method, + } + update_server(db, server_id, data, user.get("sub")) + + s = get_server_full(db, server_id) + tags = get_server_tags(db, s.qid) + ips = get_server_ips(db, server_id) + return templates.TemplateResponse("partials/server_detail.html", { + "request": request, "s": s, "tags": tags, "ips": ips + }) + + +@router.post("/servers/{server_id}/sync-qualys", response_class=HTMLResponse) +async def server_sync_qualys(request: Request, server_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return HTMLResponse("

Non autorise

") + result = sync_server_qualys(db, server_id) + s = get_server_full(db, server_id) + tags = get_server_tags(db, s.qid) if s else [] + ips = get_server_ips(db, server_id) + return templates.TemplateResponse("partials/server_detail.html", { + "request": request, "s": s, "tags": tags, "ips": ips, + "sync_msg": result.get("msg"), "sync_ok": result.get("ok"), + }) diff --git a/app/routers/settings.py b/app/routers/settings.py new file mode 100644 index 0000000..602b880 --- /dev/null +++ b/app/routers/settings.py @@ -0,0 +1,202 @@ +"""Router settings — configuration modules externes + connexions""" +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import text +from ..dependencies import get_db, get_current_user +from ..services.secrets_service import get_secret, set_secret, list_secrets, init_secrets_from_config +from ..config import APP_NAME + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + +SECTIONS = { + "qualys": [ + ("qualys_url", "URL API", False), + ("qualys_user", "Utilisateur", False), + ("qualys_pass", "Mot de passe", True), + ("qualys_proxy", "Proxy (ex: http://proxy:3128)", False), + ], + "itop": [ + ("itop_url", "URL API", False), + ("itop_user", "Utilisateur", False), + ("itop_pass", "Mot de passe", True), + ], + "ssh_key": [ + ("ssh_key_default_user", "User SSH par defaut", False), + ("ssh_key_default_port", "Port par defaut", False), + ("ssh_key_private_key", "Cle privee (PEM)", True), + ], + "ssh_pwd": [ + ("ssh_pwd_default_user", "User par defaut", False), + ("ssh_pwd_default_pass", "Password par defaut", True), + ], + "ssh_psmp": [ + ("psmp_host", "Adresse PSMP", False), + ("psmp_port", "Port PSMP", False), + ("psmp_user_format", "Format user", False), + ("psmp_cyberark_user", "Compte CyberArk", False), + ("psmp_target_user", "Utilisateur cible", False), + ("psmp_default_safe", "Safe par defaut", False), + ], + "rdp_psm": [ + ("rdp_psm_pvwa_url", "URL PVWA", False), + ("rdp_psm_pvwa_user", "User PVWA", False), + ("rdp_psm_pvwa_pass", "Password PVWA", True), + ("rdp_psm_component", "Connection Component", False), + ], + "rdp_pwd": [ + ("rdp_pwd_default_user", "User par defaut", False), + ("rdp_pwd_default_pass", "Password par defaut", True), + ("rdp_pwd_default_port", "Port RDP", False), + ], + "vsphere": [ + ("vsphere_user", "Utilisateur vCenter", False), + ("vsphere_pass", "Mot de passe vCenter", True), + ], + "splunk": [ + ("splunk_hec_url", "URL HEC", False), + ("splunk_hec_token", "Token HEC", True), + ("splunk_index", "Index", False), + ("splunk_sourcetype", "Sourcetype", False), + ("splunk_verify_ssl", "Verifier SSL (true/false)", False), + ], + "teams": [ + ("teams_webhook_url", "Webhook URL (canal)", False), + ("teams_sp_site_url", "SharePoint Site URL", False), + ("teams_sp_library", "SharePoint Library", False), + ("teams_sp_folder", "SharePoint Folder", False), + ("teams_sp_client_id", "App Client ID", False), + ("teams_sp_client_secret", "App Client Secret", True), + ("teams_sp_tenant_id", "Tenant ID", False), + ], +} + + +def _load_section_values(db): + vals = {} + for section, fields in SECTIONS.items(): + for key, label, is_secret in fields: + v = get_secret(db, key) + if is_secret and v: + vals[key] = "********" + else: + vals[key] = v or "" + return vals + + +# Regles d'acces par section: visible = qui peut voir, editable = qui peut modifier +SECTION_ACCESS = { + "qualys": {"visible": ["admin"], "editable": ["admin"]}, + "ssh_key": {"visible": ["admin"], "editable": ["admin"]}, + "ssh_pwd": {"visible": ["admin", "operator"], "editable": ["admin", "operator"]}, + "ssh_psmp": {"visible": ["admin", "operator"], "editable": ["admin", "operator"]}, + "rdp_psm": {"visible": ["admin"], "editable": ["admin"]}, + "rdp_pwd": {"visible": [], "editable": []}, + "vsphere": {"visible": ["admin", "operator", "coordinator"], "editable": ["admin", "operator"]}, + "splunk": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]}, + "teams": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]}, + "itop": {"visible": ["admin"], "editable": ["admin"]}, +} + + +def _build_context(db, user, saved=None): + init_secrets_from_config(db) + role = user.get("role", "viewer") + q_tags = db.execute(text("SELECT COUNT(*) FROM qualys_tags")).scalar() + q_assets = db.execute(text("SELECT COUNT(*) FROM qualys_assets")).scalar() + q_linked = db.execute(text("SELECT COUNT(*) FROM servers WHERE qualys_asset_id IS NOT NULL")).scalar() + vcenters = db.execute(text("SELECT * FROM vcenters ORDER BY name")).fetchall() + + # Filtrer les sections visibles selon le role + visible = {s: s in SECTION_ACCESS and role in SECTION_ACCESS[s]["visible"] for s in SECTIONS} + editable = {s: s in SECTION_ACCESS and role in SECTION_ACCESS[s]["editable"] for s in SECTIONS} + + return { + "user": user, "app_name": APP_NAME, "role": role, + "sections": SECTIONS, "vals": _load_section_values(db), + "q_tags": q_tags, "q_assets": q_assets, "q_linked": q_linked, + "vcenters": vcenters, "saved": saved, + "visible": visible, "editable": editable, + } + + +@router.get("/settings", response_class=HTMLResponse) +async def settings_page(request: Request, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + ctx = _build_context(db, user) + ctx["request"] = request + return templates.TemplateResponse("settings.html", ctx) + + +@router.post("/settings/{section}", response_class=HTMLResponse) +async def settings_save(request: Request, section: str, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + if section not in SECTIONS: + return HTMLResponse("

Section inconnue

", status_code=400) + + form = await request.form() + for key, label, is_secret in SECTIONS[section]: + val = form.get(key, "") + if is_secret and val == "********": + continue + if val: + set_secret(db, key, val, label) + + ctx = _build_context(db, user, saved=section) + ctx["request"] = request + return templates.TemplateResponse("settings.html", ctx) + + +# --- vCenter CRUD --- + +@router.post("/settings/vcenter/add", response_class=HTMLResponse) +async def vcenter_add(request: Request, db=Depends(get_db), + vc_name: str = Form(...), vc_endpoint: str = Form(...), + vc_datacenter: str = Form(""), vc_description: str = Form(""), + vc_responsable: str = Form("")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + db.execute(text( + "INSERT INTO vcenters (name, endpoint, datacenter, description, responsable) VALUES (:n, :e, :dc, :desc, :resp)" + ), {"n": vc_name, "e": vc_endpoint, "dc": vc_datacenter or None, "desc": vc_description or None, "resp": vc_responsable or None}) + db.commit() + ctx = _build_context(db, user, saved="vsphere") + ctx["request"] = request + return templates.TemplateResponse("settings.html", ctx) + + +@router.post("/settings/vcenter/{vc_id}/delete", response_class=HTMLResponse) +async def vcenter_delete(request: Request, vc_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + db.execute(text("UPDATE vcenters SET is_active = false WHERE id = :id"), {"id": vc_id}) + db.commit() + ctx = _build_context(db, user, saved="vsphere") + ctx["request"] = request + return templates.TemplateResponse("settings.html", ctx) + + +# --- Secret individuel --- + +@router.post("/settings/secret/update", response_class=HTMLResponse) +async def secret_update(request: Request, db=Depends(get_db), + secret_key: str = Form(...), secret_value: str = Form(...)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + if secret_value and secret_value != "********": + # Recuperer la description existante + existing = db.execute(text("SELECT description FROM app_secrets WHERE key = :k"), + {"k": secret_key}).fetchone() + desc = existing.description if existing else secret_key + set_secret(db, secret_key, secret_value, desc) + ctx = _build_context(db, user, saved="secret") + ctx["request"] = request + return templates.TemplateResponse("settings.html", ctx) diff --git a/app/routers/specifics.py b/app/routers/specifics.py new file mode 100644 index 0000000..38643b2 --- /dev/null +++ b/app/routers/specifics.py @@ -0,0 +1,159 @@ +"""Router serveurs specifiques — vue et edition des specificites patching""" +from fastapi import APIRouter, Request, Depends, Query, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import text +from ..dependencies import get_db, get_current_user +from ..config import APP_NAME + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + +APP_TYPES = [ + "SAP BOC", "Oracle ASM", "Oracle OEM", "Podman FL", "OCR Flux Libre", + "SI Patrimoine", "HAProxy FL", "Site institutionnel", "Centreon Poller", + "Sextan", "OCTAN", "DATI", "Covoiturage", "Scoop Docker", "Splunk Enterprise", + "SAS BI", "SMTP Relay", "PostgreSQL", "Masterparc", "Gaspar", + "Temps de parcours", "PAIPOR", "COMMVAULT", "Talend", "Autre", +] + + +def _list_specifics(db, app_type=None, search=None): + where = ["1=1"] + params = {} + if app_type: + where.append("ss.app_type = :at"); params["at"] = app_type + if search: + where.append("s.hostname ILIKE :q"); params["q"] = f"%{search}%" + wc = " AND ".join(where) + return db.execute(text(f""" + SELECT ss.*, s.hostname, s.fqdn, s.os_family, s.tier, + d.name as domaine, e.name as environnement, + dep.hostname as dep_hostname + FROM server_specifics ss + JOIN servers s ON ss.server_id = s.id + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN environments e ON de.environment_id = e.id + LEFT JOIN servers dep ON ss.dependency_server_id = dep.id + WHERE {wc} + ORDER BY ss.app_type, ss.patch_order_group, ss.reboot_order, s.hostname + """), params).fetchall() + + +@router.get("/specifics", response_class=HTMLResponse) +async def specifics_list(request: Request, db=Depends(get_db), + app_type: str = Query(None), search: str = Query(None)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + entries = _list_specifics(db, app_type, search) + # Types en base + types_in_db = db.execute(text( + "SELECT DISTINCT app_type FROM server_specifics WHERE app_type IS NOT NULL ORDER BY app_type" + )).fetchall() + return templates.TemplateResponse("specifics.html", { + "request": request, "user": user, "app_name": APP_NAME, + "entries": entries, "app_types": APP_TYPES, + "types_in_db": [r.app_type for r in types_in_db], + "filter_type": app_type, "filter_search": search, + }) + + +@router.get("/specifics/{spec_id}/edit", response_class=HTMLResponse) +async def specific_edit(request: Request, spec_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return HTMLResponse("

Non autorise

") + row = db.execute(text(""" + SELECT ss.*, s.hostname FROM server_specifics ss + JOIN servers s ON ss.server_id = s.id WHERE ss.id = :id + """), {"id": spec_id}).fetchone() + if not row: + return HTMLResponse("

Non trouve

") + return templates.TemplateResponse("partials/specific_edit.html", { + "request": request, "sp": row, "app_types": APP_TYPES, + }) + + +@router.post("/specifics/{spec_id}/save") +async def specific_save(request: Request, spec_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + form = await request.form() + + def val(k): v = form.get(k, ""); return v.strip() if v else None + def bval(k): return form.get(k) == "on" + def ival(k): v = form.get(k, ""); return int(v) if v.strip() else None + + db.execute(text(""" + UPDATE server_specifics SET + app_type = :app_type, reboot_order = :reboot_order, reboot_order_note = :reboot_order_note, + cmd_before_patch = :cmd_before_patch, cmd_after_patch = :cmd_after_patch, + cmd_before_reboot = :cmd_before_reboot, cmd_after_reboot = :cmd_after_reboot, + stop_command = :stop_command, start_command = :start_command, + stop_user = :stop_user, start_user = :start_user, + is_cluster = :is_cluster, cluster_role = :cluster_role, cluster_note = :cluster_note, + is_db = :is_db, db_type = :db_type, db_note = :db_note, + is_middleware = :is_middleware, mw_type = :mw_type, mw_note = :mw_note, + has_agent_special = :has_agent_special, agent_note = :agent_note, + has_service_critical = :has_service_critical, service_note = :service_note, + needs_manual_step = :needs_manual_step, manual_step_detail = :manual_step_detail, + kernel_update_blocked = :kernel_update_blocked, kernel_block_reason = :kernel_block_reason, + reboot_min_interval_minutes = :reboot_min_interval, reboot_delay_minutes = :reboot_delay, + sentinel_disable_required = :sentinel, ip_forwarding_required = :ip_fwd, + rolling_update = :rolling, rolling_update_note = :rolling_note, + auto_restart = :auto_restart, patch_order_group = :pog, + extra_excludes = :extra_excludes, patch_excludes = :patch_excludes, + no_reboot_reason = :no_reboot, note = :note + WHERE id = :id + """), { + "id": spec_id, "app_type": val("app_type"), + "reboot_order": ival("reboot_order"), "reboot_order_note": val("reboot_order_note"), + "cmd_before_patch": val("cmd_before_patch"), "cmd_after_patch": val("cmd_after_patch"), + "cmd_before_reboot": val("cmd_before_reboot"), "cmd_after_reboot": val("cmd_after_reboot"), + "stop_command": val("stop_command"), "start_command": val("start_command"), + "stop_user": val("stop_user"), "start_user": val("start_user"), + "is_cluster": bval("is_cluster"), "cluster_role": val("cluster_role"), "cluster_note": val("cluster_note"), + "is_db": bval("is_db"), "db_type": val("db_type"), "db_note": val("db_note"), + "is_middleware": bval("is_middleware"), "mw_type": val("mw_type"), "mw_note": val("mw_note"), + "has_agent_special": bval("has_agent_special"), "agent_note": val("agent_note"), + "has_service_critical": bval("has_service_critical"), "service_note": val("service_note"), + "needs_manual_step": bval("needs_manual_step"), "manual_step_detail": val("manual_step_detail"), + "kernel_update_blocked": bval("kernel_update_blocked"), "kernel_block_reason": val("kernel_block_reason"), + "reboot_min_interval": ival("reboot_min_interval"), "reboot_delay": ival("reboot_delay"), + "sentinel": bval("sentinel"), "ip_fwd": bval("ip_fwd"), + "rolling": bval("rolling"), "rolling_note": val("rolling_note"), + "auto_restart": bval("auto_restart"), "pog": val("patch_order_group"), + "extra_excludes": val("extra_excludes"), "patch_excludes": val("patch_excludes"), + "no_reboot": val("no_reboot"), "note": val("note"), + }) + db.commit() + # Recuperer le app_type pour rediriger vers le bon filtre + ancre + saved = db.execute(text("SELECT app_type FROM server_specifics WHERE id = :id"), + {"id": spec_id}).fetchone() + at = saved.app_type if saved and saved.app_type else "" + url = f"/specifics?msg=saved&app_type={at}#row-{spec_id}" if at else f"/specifics?msg=saved#row-{spec_id}" + return RedirectResponse(url=url, status_code=303) + + +@router.post("/specifics/add") +async def specific_add(request: Request, db=Depends(get_db), + hostname: str = Form(...), app_type: str = Form("")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + row = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname) = LOWER(:h)"), + {"h": hostname.strip()}).fetchone() + if not row: + return RedirectResponse(url="/specifics?msg=not_found", status_code=303) + existing = db.execute(text("SELECT id FROM server_specifics WHERE server_id = :sid"), + {"sid": row.id}).fetchone() + if existing: + return RedirectResponse(url="/specifics?msg=exists", status_code=303) + db.execute(text( + "INSERT INTO server_specifics (server_id, app_type) VALUES (:sid, :at)" + ), {"sid": row.id, "at": app_type or None}) + db.commit() + return RedirectResponse(url="/specifics?msg=added", status_code=303) diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..f38de91 --- /dev/null +++ b/app/routers/users.py @@ -0,0 +1,142 @@ +"""Router users — gestion utilisateurs + permissions par module""" +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import text +from ..dependencies import get_db, get_current_user +from ..auth import hash_password +from ..config import APP_NAME + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + +MODULES = ["servers", "campaigns", "qualys", "audit", "settings", "users"] +LEVELS = ["view", "edit", "admin"] + + +def _get_users_with_perms(db): + users = db.execute(text( + "SELECT id, username, display_name, email, role, auth_type, is_active, last_login FROM users ORDER BY username" + )).fetchall() + result = [] + for u in users: + perms = {} + rows = db.execute(text( + "SELECT module, level FROM user_permissions WHERE user_id = :uid" + ), {"uid": u.id}).fetchall() + for r in rows: + perms[r.module] = r.level + result.append({"user": u, "perms": perms}) + return result + + +@router.get("/users", response_class=HTMLResponse) +async def users_page(request: Request, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + users_data = _get_users_with_perms(db) + return templates.TemplateResponse("users.html", { + "request": request, "user": user, "app_name": APP_NAME, + "users_data": users_data, "modules": MODULES, "levels": LEVELS, + "saved": None, + }) + + +@router.post("/users/add", response_class=HTMLResponse) +async def user_add(request: Request, db=Depends(get_db), + new_username: str = Form(...), new_display_name: str = Form(...), + new_email: str = Form(""), new_password: str = Form(...), + new_role: str = Form("operator")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + + pw_hash = hash_password(new_password) + db.execute(text(""" + INSERT INTO users (username, display_name, email, password_hash, role) + VALUES (:u, :dn, :e, :ph, :r) + """), {"u": new_username, "dn": new_display_name, "e": new_email or None, + "ph": pw_hash, "r": new_role}) + + # Recuperer l'id du nouveau user + row = db.execute(text("SELECT id FROM users WHERE username = :u"), {"u": new_username}).fetchone() + if row: + # Permissions par defaut selon role + default_perms = { + "admin": {m: "admin" for m in MODULES}, + "coordinator": {"servers": "edit", "campaigns": "admin", "qualys": "edit", "audit": "view", "settings": "view", "users": "view"}, + "operator": {"servers": "edit", "campaigns": "edit", "qualys": "view", "audit": "view"}, + "viewer": {"servers": "view", "campaigns": "view", "qualys": "view", "audit": "view"}, + } + for mod, lvl in default_perms.get(new_role, {}).items(): + db.execute(text( + "INSERT INTO user_permissions (user_id, module, level) VALUES (:uid, :m, :l) ON CONFLICT DO NOTHING" + ), {"uid": row.id, "m": mod, "l": lvl}) + + db.commit() + users_data = _get_users_with_perms(db) + return templates.TemplateResponse("users.html", { + "request": request, "user": user, "app_name": APP_NAME, + "users_data": users_data, "modules": MODULES, "levels": LEVELS, + "saved": "add", + }) + + +@router.post("/users/{user_id}/permissions", response_class=HTMLResponse) +async def user_permissions_save(request: Request, user_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + + form = await request.form() + # Supprimer les anciennes permissions + db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id}) + # Inserer les nouvelles + for mod in MODULES: + lvl = form.get(f"perm_{mod}", "") + if lvl and lvl in LEVELS: + db.execute(text( + "INSERT INTO user_permissions (user_id, module, level) VALUES (:uid, :m, :l)" + ), {"uid": user_id, "m": mod, "l": lvl}) + db.commit() + + users_data = _get_users_with_perms(db) + return templates.TemplateResponse("users.html", { + "request": request, "user": user, "app_name": APP_NAME, + "users_data": users_data, "modules": MODULES, "levels": LEVELS, + "saved": f"perms_{user_id}", + }) + + +@router.post("/users/{user_id}/toggle", response_class=HTMLResponse) +async def user_toggle(request: Request, user_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + db.execute(text("UPDATE users SET is_active = NOT is_active WHERE id = :id"), {"id": user_id}) + db.commit() + users_data = _get_users_with_perms(db) + return templates.TemplateResponse("users.html", { + "request": request, "user": user, "app_name": APP_NAME, + "users_data": users_data, "modules": MODULES, "levels": LEVELS, + "saved": "toggle", + }) + + +@router.post("/users/{user_id}/password", response_class=HTMLResponse) +async def user_password(request: Request, user_id: int, db=Depends(get_db), + new_password: str = Form(...)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + pw_hash = hash_password(new_password) + db.execute(text("UPDATE users SET password_hash = :ph WHERE id = :id"), + {"ph": pw_hash, "id": user_id}) + db.commit() + users_data = _get_users_with_perms(db) + return templates.TemplateResponse("users.html", { + "request": request, "user": user, "app_name": APP_NAME, + "users_data": users_data, "modules": MODULES, "levels": LEVELS, + "saved": "password", + }) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py new file mode 100644 index 0000000..f9209a7 --- /dev/null +++ b/app/services/campaign_service.py @@ -0,0 +1,287 @@ +"""Service campagnes — logique metier patching""" +from datetime import datetime +from sqlalchemy import text + + +def list_campaigns(db, year=None, status=None): + where = ["1=1"] + params = {} + if year: + where.append("c.year = :year"); params["year"] = year + if status: + where.append("c.status = :status"); params["status"] = status + wc = " AND ".join(where) + return db.execute(text(f""" + SELECT c.*, u.display_name as created_by_name, + (SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id) as session_count, + (SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'patched') as patched_count, + (SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'failed') as failed_count, + (SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'pending') as pending_count, + (SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'excluded') as excluded_count + FROM campaigns c + LEFT JOIN users u ON c.created_by = u.id + WHERE {wc} ORDER BY c.year DESC, c.week_code DESC + """), params).fetchall() + + +def get_campaign(db, campaign_id): + return db.execute(text(""" + SELECT c.*, u.display_name as created_by_name + FROM campaigns c LEFT JOIN users u ON c.created_by = u.id + WHERE c.id = :id + """), {"id": campaign_id}).fetchone() + + +def get_campaign_sessions(db, campaign_id): + return db.execute(text(""" + SELECT ps.*, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier, + s.etat, s.ssh_method, s.licence_support, s.machine_type, + d.name as domaine, e.name as environnement, + u.display_name as intervenant_name + FROM patch_sessions ps + JOIN servers s ON ps.server_id = s.id + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN environments e ON de.environment_id = e.id + LEFT JOIN users u ON ps.intervenant_id = u.id + WHERE ps.campaign_id = :cid + ORDER BY CASE ps.status + WHEN 'in_progress' THEN 1 + WHEN 'pending' THEN 2 + WHEN 'prereq_ok' THEN 3 + WHEN 'patched' THEN 4 + WHEN 'failed' THEN 5 + WHEN 'reported' THEN 6 + WHEN 'excluded' THEN 7 + WHEN 'cancelled' THEN 8 + ELSE 9 END, s.hostname + """), {"cid": campaign_id}).fetchall() + + +def get_campaign_stats(db, campaign_id): + return db.execute(text(""" + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE status = 'patched') as patched, + COUNT(*) FILTER (WHERE status = 'failed') as failed, + COUNT(*) FILTER (WHERE status = 'pending') as pending, + COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress, + COUNT(*) FILTER (WHERE status = 'skipped') as skipped, + COUNT(*) FILTER (WHERE status = 'excluded') as excluded, + COUNT(*) FILTER (WHERE status = 'reported') as reported, + COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled + FROM patch_sessions WHERE campaign_id = :cid + """), {"cid": campaign_id}).fetchone() + + +def get_planning_for_week(db, year, week_number): + """Retourne les entrees planning pour une semaine donnee""" + return db.execute(text(""" + SELECT pp.*, d.name as domain_name + FROM patch_planning pp + LEFT JOIN domains d ON pp.domain_code = d.code + WHERE pp.year = :y AND pp.week_number = :wn AND pp.status = 'open' + ORDER BY pp.domain_code + """), {"y": year, "wn": week_number}).fetchall() + + +def get_servers_for_planning(db, year, week_number): + """Retourne les serveurs a proposer pour une semaine du planning. + Inclut les domaines planifies + DMZ (toujours inclus).""" + planning = get_planning_for_week(db, year, week_number) + if not planning: + return [], [] + + # Construire les filtres domaine/env depuis le planning + domain_envs = [] + for p in planning: + if p.domain_code == 'DMZ': + continue # DMZ traite separement + if p.env_scope == 'prod': + domain_envs.append(("d.code = :dc_{0} AND e.name = 'Production'".format(len(domain_envs)), p.domain_code)) + elif p.env_scope == 'hprod': + domain_envs.append(("d.code = :dc_{0} AND e.name != 'Production'".format(len(domain_envs)), p.domain_code)) + elif p.env_scope == 'prod_pilot': + domain_envs.append(("d.code = :dc_{0}".format(len(domain_envs)), p.domain_code)) + else: # all + domain_envs.append(("d.code = :dc_{0}".format(len(domain_envs)), p.domain_code)) + + if not domain_envs: + return [], planning + + # Construire la clause OR + or_clauses = [] + params = {} + for i, (clause, dc) in enumerate(domain_envs): + or_clauses.append(clause) + params[f"dc_{i}"] = dc + + # Toujours inclure DMZ + or_clauses.append("d.code = 'DMZ'") + + where = f""" + s.etat = 'en_production' + AND s.patch_os_owner = 'secops' + AND s.licence_support IN ('active', 'els') + AND ({' OR '.join(or_clauses)}) + """ + + servers = db.execute(text(f""" + SELECT s.id, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier, + s.licence_support, s.ssh_method, s.machine_type, + d.name as domaine, d.code as domain_code, e.name as environnement + FROM servers s + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN environments e ON de.environment_id = e.id + WHERE {where} + ORDER BY d.name, e.name, s.hostname + """), params).fetchall() + + return servers, planning + + +def create_campaign_from_planning(db, year, week_number, label, user_id, excluded_ids=None): + """Cree une campagne depuis le planning avec exclusions""" + servers, planning = get_servers_for_planning(db, year, week_number) + if not servers: + return None + + wc = f"S{week_number:02d}" + # Dates de la semaine + p = planning[0] if planning else None + ds = p.week_start if p else None + de = p.week_end if p else None + + row = db.execute(text(""" + INSERT INTO campaigns (week_code, year, label, status, date_start, date_end, created_by) + VALUES (:wc, :y, :label, 'draft', :ds, :de, :uid) + RETURNING id + """), {"wc": wc, "y": year, "label": label, "ds": ds, "de": de, "uid": user_id}).fetchone() + cid = row.id + + excluded = set(excluded_ids or []) + for s in servers: + status = 'excluded' if s.id in excluded else 'pending' + db.execute(text(""" + INSERT INTO patch_sessions (campaign_id, server_id, status) + VALUES (:cid, :sid, :st) + ON CONFLICT (campaign_id, server_id) DO NOTHING + """), {"cid": cid, "sid": s.id, "st": status}) + + # Update total + count = db.execute(text( + "SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status != 'excluded'" + ), {"cid": cid}).scalar() + db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"), + {"c": count, "cid": cid}) + + db.commit() + return cid + + +def exclude_session(db, session_id, reason, detail, username): + """Exclut un serveur d'une campagne avec motif""" + db.execute(text(""" + UPDATE patch_sessions SET + status = 'excluded', exclusion_reason = :reason, + exclusion_detail = :detail, excluded_by = :by, + excluded_at = now() + WHERE id = :id + """), {"id": session_id, "reason": reason, "detail": detail, "by": username}) + # Recalculer total + row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), + {"id": session_id}).fetchone() + if row: + count = db.execute(text( + "SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status NOT IN ('excluded','cancelled')" + ), {"cid": row.campaign_id}).scalar() + db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"), + {"c": count, "cid": row.campaign_id}) + db.commit() + + +def restore_session(db, session_id): + """Restaure un serveur exclu""" + db.execute(text(""" + UPDATE patch_sessions SET + status = 'pending', exclusion_reason = NULL, + exclusion_detail = NULL, excluded_by = NULL, excluded_at = NULL + WHERE id = :id + """), {"id": session_id}) + row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), + {"id": session_id}).fetchone() + if row: + count = db.execute(text( + "SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status NOT IN ('excluded','cancelled')" + ), {"cid": row.campaign_id}).scalar() + db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"), + {"c": count, "cid": row.campaign_id}) + db.commit() + + +def validate_prereq(db, session_id, ssh, satellite, rollback, rollback_justif, username): + """Valide les prereqs d'un serveur dans une campagne""" + db.execute(text(""" + UPDATE patch_sessions SET + prereq_ssh = :ssh, prereq_satellite = :sat, + rollback_method = :rb, rollback_justif = :rbj, + prereq_validated = CASE WHEN :ssh = 'ok' AND :sat = 'ok' AND :rb IS NOT NULL THEN true ELSE false END, + prereq_validated_by = :by, prereq_validated_at = now(), prereq_date = now() + WHERE id = :id + """), {"id": session_id, "ssh": ssh, "sat": satellite, + "rb": rollback or None, "rbj": rollback_justif or None, "by": username}) + db.commit() + + +def bulk_auto_exclude_failed_prereqs(db, campaign_id, username): + """Exclut automatiquement les serveurs qui n'ont pas passe les prereqs""" + failed = db.execute(text(""" + SELECT id FROM patch_sessions + WHERE campaign_id = :cid AND status = 'pending' + AND prereq_validated = false + AND prereq_date IS NOT NULL + AND (prereq_ssh = 'ko' OR prereq_satellite = 'ko' OR rollback_method IS NULL) + """), {"cid": campaign_id}).fetchall() + count = 0 + for r in failed: + exclude_session(db, r.id, "creneau_inadequat", "Prereqs non valides — report auto", username) + count += 1 + return count + + +def get_prereq_stats(db, campaign_id): + """Stats prereqs d'une campagne""" + return db.execute(text(""" + SELECT + COUNT(*) FILTER (WHERE status = 'pending') as total_pending, + COUNT(*) FILTER (WHERE status = 'pending' AND prereq_validated = true) as prereq_ok, + COUNT(*) FILTER (WHERE status = 'pending' AND prereq_validated = false AND prereq_date IS NOT NULL) as prereq_ko, + COUNT(*) FILTER (WHERE status = 'pending' AND prereq_date IS NULL) as prereq_todo, + COUNT(*) FILTER (WHERE status = 'pending' AND prereq_ssh = 'ok') as ssh_ok, + COUNT(*) FILTER (WHERE status = 'pending' AND prereq_satellite = 'ok') as sat_ok, + COUNT(*) FILTER (WHERE status = 'pending' AND rollback_method IS NOT NULL) as rollback_ok, + COUNT(*) FILTER (WHERE status = 'pending' AND prereq_disk_ok = true) as disk_ok + FROM patch_sessions WHERE campaign_id = :cid + """), {"cid": campaign_id}).fetchone() + + +def can_plan_campaign(db, campaign_id): + """Verifie si la campagne peut passer en 'planned' (tous les prereqs pending valides)""" + pending_not_validated = db.execute(text(""" + SELECT COUNT(*) FROM patch_sessions + WHERE campaign_id = :cid AND status = 'pending' AND prereq_validated = false + """), {"cid": campaign_id}).scalar() + return pending_not_validated == 0 + + +def update_campaign_status(db, campaign_id, new_status): + db.execute(text("UPDATE campaigns SET status = :s WHERE id = :id"), + {"s": new_status, "id": campaign_id}) + db.commit() + + +def get_reference_data(db): + domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall() + envs = db.execute(text("SELECT code, name FROM environments ORDER BY display_order")).fetchall() + return domains, envs diff --git a/app/services/prereq_service.py b/app/services/prereq_service.py new file mode 100644 index 0000000..2744b6a --- /dev/null +++ b/app/services/prereq_service.py @@ -0,0 +1,291 @@ +"""Service prerequis — verification automatique des serveurs d'une campagne +Check basique (TCP) + check approfondi (SSH) si accessible""" +import socket +import paramiko +import os +from sqlalchemy import text + +# Seuils espace disque (Mo) +DISK_ROOT_MIN_MB = 1200 # 1.2 Go minimum sur / +DISK_VAR_MIN_MB = 800 # 800 Mo minimum sur /var ou /var/log + +# SSH config (pour check approfondi) +SSH_KEY = "/opt/patchcenter/keys/id_rsa_cybglobal.pem" # Copier la cle ici +SSH_USER = "cybsecope" +SSH_TIMEOUT = 10 +DNS_SUFFIXES = ["", ".sanef.groupe", ".sanef-rec.fr", ".sanef.fr"] + + +def check_prereqs_campaign(db, campaign_id): + """Verifie les prereqs de tous les serveurs pending d'une campagne.""" + sessions = db.execute(text(""" + SELECT ps.id, s.hostname, s.os_family, s.etat, s.licence_support, + s.machine_type, s.satellite_host, s.ssh_method, + d.code as domain_code, z.name as zone + FROM patch_sessions ps + JOIN servers s ON ps.server_id = s.id + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN zones z ON s.zone_id = z.id + WHERE ps.campaign_id = :cid AND ps.status = 'pending' + """), {"cid": campaign_id}).fetchall() + + checked = 0 + for s in sessions: + result = _check_server(s) + _save_result(db, s.id, result) + checked += 1 + + auto_excluded = _auto_exclude(db, campaign_id) + db.commit() + return checked, auto_excluded + + +def _resolve_host(hostname): + """Trouve un FQDN resolvable et joignable sur port 22""" + for suffix in DNS_SUFFIXES: + target = hostname + suffix + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + r = sock.connect_ex((target, 22)) + sock.close() + if r == 0: + return target + except (socket.gaierror, socket.timeout, OSError): + continue + return None + + +def _ssh_connect(target): + """Tente une connexion SSH par cle. Retourne le client ou None.""" + if not os.path.exists(SSH_KEY): + return None + for loader in [paramiko.RSAKey.from_private_key_file, paramiko.Ed25519Key.from_private_key_file]: + try: + key = loader(SSH_KEY) + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(target, port=22, username=SSH_USER, pkey=key, + timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False) + return client + except Exception: + continue + return None + + +def _ssh_cmd(client, cmd, timeout=8): + """Execute une commande SSH et retourne stdout""" + try: + stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout) + return stdout.read().decode("utf-8", errors="replace").strip() + except Exception: + return "" + + +def _check_disk(client): + """Verifie l'espace disque via SSH. Retourne (root_mb, var_mb, ok)""" + output = _ssh_cmd(client, "df -BM --output=target,avail 2>/dev/null | grep -E '^/ |^/var' | head -5") + root_mb = None + var_mb = None + for line in output.split("\n"): + parts = line.split() + if len(parts) >= 2: + mount = parts[0] + try: + avail = int(parts[1].replace("M", "")) + except ValueError: + continue + if mount == "/": + root_mb = avail + elif mount in ("/var", "/var/log"): + var_mb = avail if var_mb is None or avail < (var_mb or 9999) else var_mb + + # Si /var pas monte separement, il est dans / + if var_mb is None and root_mb is not None: + var_mb = root_mb + + ok = True + if root_mb is not None and root_mb < DISK_ROOT_MIN_MB: + ok = False + if var_mb is not None and var_mb < DISK_VAR_MIN_MB: + ok = False + + return root_mb, var_mb, ok + + +def _check_satellite_ssh(client): + """Verifie la connectivite Satellite via SSH""" + output = _ssh_cmd(client, "subscription-manager identity 2>/dev/null | grep -i 'org' || echo 'not_registered'") + if "not_registered" in output or not output: + return "ko" + return "ok" + + +def _check_server(s): + """Verifie un serveur. Check basique + approfondi si SSH OK.""" + result = { + "ssh": "pending", "satellite": "pending", "rollback": None, + "disk_root_mb": None, "disk_var_mb": None, "disk_ok": None, + "eligible": True, "exclude_reason": None, "exclude_detail": None, + } + + # 1. Eligibilite de base + if s.licence_support == 'eol': + result["eligible"] = False + result["exclude_reason"] = "eol" + result["exclude_detail"] = "Licence EOL — serveur non supporte" + return result + + if s.etat != 'en_production': + result["eligible"] = False + result["exclude_reason"] = "non_patchable" + result["exclude_detail"] = f"Etat: {s.etat}" + return result + + # 2. Connectivite TCP port 22 + target = _resolve_host(s.hostname) + if not target: + result["ssh"] = "ko" + result["eligible"] = False + result["exclude_reason"] = "creneau_inadequat" + result["exclude_detail"] = f"Port 22 injoignable ({s.hostname})" + return result + + result["ssh"] = "ok" + + # 3. Rollback + if s.machine_type == 'vm': + result["rollback"] = "snapshot" + else: + result["rollback"] = "na" + + # 4. Check approfondi via SSH (si cle dispo) + client = _ssh_connect(target) + if client: + try: + # Espace disque + root_mb, var_mb, disk_ok = _check_disk(client) + result["disk_root_mb"] = root_mb + result["disk_var_mb"] = var_mb + result["disk_ok"] = disk_ok + + if not disk_ok: + detail_parts = [] + if root_mb is not None and root_mb < DISK_ROOT_MIN_MB: + detail_parts.append(f"/ = {root_mb}Mo (min {DISK_ROOT_MIN_MB}Mo)") + if var_mb is not None and var_mb < DISK_VAR_MIN_MB: + detail_parts.append(f"/var = {var_mb}Mo (min {DISK_VAR_MIN_MB}Mo)") + result["eligible"] = False + result["exclude_reason"] = "creneau_inadequat" + result["exclude_detail"] = "Espace disque insuffisant: " + ", ".join(detail_parts) + + # Satellite reel (Linux) + if s.os_family == 'linux': + result["satellite"] = _check_satellite_ssh(client) + else: + result["satellite"] = "na" + finally: + try: + client.close() + except Exception: + pass + else: + # Pas de connexion SSH approfondie — fallback sur les donnees en base + if s.os_family == 'linux': + result["satellite"] = "ok" if s.satellite_host else "ko" + else: + result["satellite"] = "na" + result["disk_ok"] = None # Non verifie + + return result + + +def _save_result(db, session_id, result): + """Sauvegarde les resultats prereqs""" + all_ok = (result["eligible"] + and result["ssh"] == "ok" + and result.get("disk_ok") is not False) + db.execute(text(""" + UPDATE patch_sessions SET + prereq_ssh = :ssh, prereq_satellite = :sat, + rollback_method = COALESCE(rollback_method, :rb), + prereq_disk_root = :dr, prereq_disk_log = :dv, + prereq_disk_root_mb = :drm, prereq_disk_var_mb = :dvm, + prereq_disk_ok = :dok, + prereq_validated = :valid, prereq_date = now() + WHERE id = :id + """), { + "id": session_id, "ssh": result["ssh"], "sat": result["satellite"], + "rb": result["rollback"], + "dr": result["disk_root_mb"], "dv": result["disk_var_mb"], + "drm": result["disk_root_mb"], "dvm": result["disk_var_mb"], + "dok": result["disk_ok"], + "valid": all_ok, + }) + + +def _auto_exclude(db, campaign_id): + """Exclut les serveurs non eligibles apres verification""" + non_eligible = db.execute(text(""" + SELECT ps.id, s.hostname, s.licence_support, s.etat, + ps.prereq_ssh, ps.prereq_disk_ok + FROM patch_sessions ps + JOIN servers s ON ps.server_id = s.id + WHERE ps.campaign_id = :cid AND ps.status = 'pending' + AND (s.licence_support = 'eol' + OR s.etat != 'en_production' + OR ps.prereq_ssh = 'ko' + OR ps.prereq_disk_ok = false) + """), {"cid": campaign_id}).fetchall() + + count = 0 + for s in non_eligible: + if s.licence_support == 'eol': + reason, detail = "eol", "Licence EOL — auto-exclu" + elif s.etat != 'en_production': + reason, detail = "non_patchable", f"Etat {s.etat} — auto-exclu" + elif s.prereq_disk_ok is False: + reason, detail = "creneau_inadequat", "Espace disque insuffisant — auto-exclu" + else: + reason, detail = "creneau_inadequat", "SSH injoignable — auto-exclu" + + db.execute(text(""" + UPDATE patch_sessions SET + status = 'excluded', exclusion_reason = :r, + exclusion_detail = :d, excluded_by = 'system', excluded_at = now() + WHERE id = :id + """), {"id": s.id, "r": reason, "d": detail}) + count += 1 + + if count > 0: + total = db.execute(text( + "SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status NOT IN ('excluded','cancelled')" + ), {"cid": campaign_id}).scalar() + db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"), + {"c": total, "cid": campaign_id}) + + return count + + +def check_single_prereq(db, session_id): + """Verifie les prereqs d'un seul serveur""" + s = db.execute(text(""" + SELECT ps.id, s.hostname, s.os_family, s.etat, s.licence_support, + s.machine_type, s.satellite_host, s.ssh_method, + d.code as domain_code, z.name as zone + FROM patch_sessions ps + JOIN servers s ON ps.server_id = s.id + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN zones z ON s.zone_id = z.id + WHERE ps.id = :sid + """), {"sid": session_id}).fetchone() + + if not s: + return None + + result = _check_server(s) + _save_result(db, session_id, result) + db.commit() + return result diff --git a/app/services/qualys_service.py b/app/services/qualys_service.py new file mode 100644 index 0000000..d58fdcf --- /dev/null +++ b/app/services/qualys_service.py @@ -0,0 +1,156 @@ +"""Service Qualys — sync tags pour un serveur via API""" +import re +import requests +import urllib3 +from sqlalchemy import text +from .secrets_service import get_secret + +urllib3.disable_warnings() + + +def _get_qualys_creds(db): + """Recupere les credentials Qualys depuis les secrets chiffres""" + url = get_secret(db, "qualys_url") or "https://qualysapi.qualys.eu" + user = get_secret(db, "qualys_user") or "" + pwd = get_secret(db, "qualys_pass") or "" + proxy = get_secret(db, "qualys_proxy") or "" + return url, user, pwd, proxy + + +def parse_xml(txt, tag): + return re.findall(f"<{tag}>([^<]*)", txt) + + +def sync_server_qualys(db, server_id): + """Sync les tags Qualys pour un serveur donne. Retourne un dict resultat.""" + row = db.execute(text( + "SELECT hostname, qualys_asset_id FROM servers WHERE id = :id" + ), {"id": server_id}).fetchone() + if not row: + return {"ok": False, "msg": "Serveur introuvable"} + + hostname = row.hostname + qid = row.qualys_asset_id + + qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db) + if not qualys_user: + return {"ok": False, "msg": "Credentials Qualys non configures (Settings)"} + proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None + + # Chercher l'asset par hostname si pas de qualys_asset_id + if not qid: + qid = _find_asset_by_hostname(qualys_url, qualys_user, qualys_pass, hostname, proxies) + if not qid: + return {"ok": False, "msg": f"Asset '{hostname}' non trouve dans Qualys"} + db.execute(text("UPDATE servers SET qualys_asset_id = :qid WHERE id = :id"), + {"qid": qid, "id": server_id}) + + # Recuperer l'asset complet avec tags + try: + r = requests.post( + f"{qualys_url}/qps/rest/2.0/search/am/hostasset", + json={"ServiceRequest": { + "filters": {"Criteria": [ + {"field": "id", "operator": "EQUALS", "value": str(qid)} + ]} + }}, + auth=(qualys_user, qualys_pass), + verify=False, timeout=60, proxies=proxies, + headers={"Content-Type": "application/json"} + ) + except Exception as e: + return {"ok": False, "msg": f"Erreur API: {e}"} + + if r.status_code != 200 or "SUCCESS" not in r.text: + return {"ok": False, "msg": f"API HTTP {r.status_code}"} + + # Parser asset + blocks = r.text.split("") + if len(blocks) < 2: + return {"ok": False, "msg": "Asset non trouve dans la reponse"} + + block = blocks[1].split("")[0] + fqdn = (parse_xml(block, "fqdn") or [""])[0] + address = (parse_xml(block, "address") or [""])[0] + os_val = (parse_xml(block, "os") or [""])[0] + agent_status = (parse_xml(block, "status") or [""])[0] if "" in block else "" + agent_version = (parse_xml(block, "agentVersion") or [""])[0] + last_checkin = (parse_xml(block, "lastCheckedIn") or [""])[0] or None + + os_family = None + os_low = os_val.lower() + if any(k in os_low for k in ("linux", "red hat", "centos", "debian")): + os_family = "linux" + elif "windows" in os_low: + os_family = "windows" + + # Update qualys_assets + db.execute(text(""" + INSERT INTO qualys_assets (qualys_asset_id, name, hostname, fqdn, ip_address, os, os_family, + agent_status, agent_version, last_checkin, server_id) + VALUES (:qid, :name, :hn, :fqdn, :ip, :os, :osf, :ast, :av, :lc, :sid) + ON CONFLICT (qualys_asset_id) DO UPDATE SET + fqdn=EXCLUDED.fqdn, ip_address=EXCLUDED.ip_address, os=EXCLUDED.os, + os_family=EXCLUDED.os_family, agent_status=EXCLUDED.agent_status, + agent_version=EXCLUDED.agent_version, last_checkin=EXCLUDED.last_checkin, updated_at=now() + """), {"qid": qid, "name": hostname, "hn": hostname.split(".")[0].lower(), + "fqdn": fqdn or None, "ip": address or None, "os": os_val, "osf": os_family, + "ast": agent_status, "av": agent_version, "lc": last_checkin, "sid": server_id}) + + # Enrichir servers + db.execute(text(""" + UPDATE servers SET + fqdn = COALESCE(NULLIF(:fqdn, ''), fqdn), + os_version = COALESCE(NULLIF(:os, ''), os_version) + WHERE id = :id + """), {"fqdn": fqdn, "os": os_val, "id": server_id}) + + # Tags + tag_count = 0 + if "" in block: + tag_block = block.split("")[1].split("")[0] + tag_ids = parse_xml(tag_block, "id") + tag_names = parse_xml(tag_block, "name") + + # Supprimer anciens liens + db.execute(text("DELETE FROM qualys_asset_tags WHERE qualys_asset_id = :qid"), {"qid": qid}) + + for tid, tname in zip(tag_ids, tag_names): + # Upsert tag + db.execute(text(""" + INSERT INTO qualys_tags (qualys_tag_id, name) VALUES (:tid, :tn) + ON CONFLICT (qualys_tag_id) DO UPDATE SET name=EXCLUDED.name, updated_at=now() + """), {"tid": int(tid), "tn": tname}) + # Lien asset-tag + db.execute(text(""" + INSERT INTO qualys_asset_tags (qualys_asset_id, qualys_tag_id) + VALUES (:qid, :tid) ON CONFLICT DO NOTHING + """), {"qid": qid, "tid": int(tid)}) + tag_count += 1 + + db.commit() + return {"ok": True, "msg": f"Synchro OK — {tag_count} tags", "tags": tag_count} + + +def _find_asset_by_hostname(qualys_url, qualys_user, qualys_pass, hostname, proxies=None): + """Cherche un asset Qualys par hostname""" + try: + r = requests.post( + f"{qualys_url}/qps/rest/2.0/search/am/hostasset", + json={"ServiceRequest": { + "preferences": {"limitResults": 5}, + "filters": {"Criteria": [ + {"field": "name", "operator": "CONTAINS", "value": hostname} + ]} + }}, + auth=(qualys_user, qualys_pass), + verify=False, timeout=60, proxies=proxies, + headers={"Content-Type": "application/json"} + ) + if r.status_code == 200 and "SUCCESS" in r.text: + ids = parse_xml(r.text, "id") + if ids: + return int(ids[0]) + except Exception: + pass + return None diff --git a/app/services/secrets_service.py b/app/services/secrets_service.py new file mode 100644 index 0000000..e21f224 --- /dev/null +++ b/app/services/secrets_service.py @@ -0,0 +1,64 @@ +"""Service secrets — chiffrement Fernet pour credentials en base""" +import os +import base64 +from cryptography.fernet import Fernet +from sqlalchemy import text +from ..config import SECRET_KEY + +# Derive une cle Fernet 32 bytes depuis SECRET_KEY +_raw = SECRET_KEY.encode()[:32].ljust(32, b'\0') +_fernet_key = base64.urlsafe_b64encode(_raw) +_fernet = Fernet(_fernet_key) + + +def encrypt(value: str) -> str: + return _fernet.encrypt(value.encode()).decode() + + +def decrypt(value: str) -> str: + return _fernet.decrypt(value.encode()).decode() + + +def get_secret(db, key: str) -> str | None: + """Recupere et dechiffre un secret depuis app_secrets""" + row = db.execute(text("SELECT value FROM app_secrets WHERE key = :k"), {"k": key}).fetchone() + if not row: + return None + try: + return decrypt(row.value) + except Exception: + return None + + +def set_secret(db, key: str, value: str, description: str = ""): + """Chiffre et stocke un secret dans app_secrets""" + enc = encrypt(value) + db.execute(text(""" + INSERT INTO app_secrets (key, value, description, updated_at) + VALUES (:k, :v, :d, now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, + description = EXCLUDED.description, updated_at = now() + """), {"k": key, "v": enc, "d": description}) + db.commit() + + +def list_secrets(db): + """Liste les cles (sans valeurs) des secrets""" + rows = db.execute(text( + "SELECT key, description, updated_at FROM app_secrets ORDER BY key" + )).fetchall() + return rows + + +def init_secrets_from_config(db): + """Initialise les secrets depuis config si pas encore en base""" + from ..config import QUALYS_URL, QUALYS_USER, QUALYS_PASS + defaults = { + "qualys_url": (QUALYS_URL, "URL API Qualys"), + "qualys_user": (QUALYS_USER, "Utilisateur Qualys"), + "qualys_pass": (QUALYS_PASS, "Mot de passe Qualys"), + "qualys_proxy": ("http://proxy.sanef.fr:8080", "Proxy Qualys"), + } + for key, (val, desc) in defaults.items(): + if val and not get_secret(db, key): + set_secret(db, key, val, desc) diff --git a/app/services/server_service.py b/app/services/server_service.py new file mode 100644 index 0000000..32ef702 --- /dev/null +++ b/app/services/server_service.py @@ -0,0 +1,227 @@ +"""Logique metier serveurs — requetes SQL separees du router""" +from sqlalchemy import text + + +def get_server_full(db, server_id): + """Retourne un serveur avec tous ses JOINs""" + return db.execute(text(""" + SELECT s.*, d.name as domaine, d.code as domaine_code, + e.name as environnement, e.code as env_code, + z.name as zone, s.qualys_asset_id as qid + FROM servers s + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN environments e ON de.environment_id = e.id + LEFT JOIN zones z ON s.zone_id = z.id + WHERE s.id = :id + """), {"id": server_id}).fetchone() + + +def get_server_tags(db, qualys_asset_id): + """Retourne les tags Qualys d'un asset""" + if not qualys_asset_id: + return [] + rows = db.execute(text(""" + SELECT qt.name FROM qualys_asset_tags qat + JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id + WHERE qat.qualys_asset_id = :aid ORDER BY qt.name + """), {"aid": qualys_asset_id}).fetchall() + return [r.name for r in rows] + + +def get_server_ips(db, server_id): + """Retourne les IPs segmentees: reelle, connexion, autres""" + rows = db.execute(text(""" + SELECT ip_address, ip_type, is_ssh, interface, description + FROM server_ips WHERE server_id = :id ORDER BY ip_type, ip_address + """), {"id": server_id}).fetchall() + + ip_reelle = None + ip_connexion = None + autres_ips = [] + + for r in rows: + ip = str(r.ip_address) + is_primary = (r.ip_type == 'primary') + is_ssh = r.is_ssh + + if is_primary and not ip_reelle: + ip_reelle = ip + if is_ssh and not ip_connexion: + ip_connexion = ip + if not is_primary and not is_ssh: + autres_ips.append({ + "ip": ip, "type": r.ip_type, + "interface": r.interface or "", "description": r.description or "" + }) + + return {"ip_reelle": ip_reelle, "ip_connexion": ip_connexion, "autres_ips": autres_ips} + + +def update_server_ips(db, server_id, ip_reelle, ip_connexion): + """Met a jour IP reelle et IP de connexion""" + ip_reelle = (ip_reelle or "").strip() + ip_connexion = (ip_connexion or "").strip() + + # Supprimer les anciennes entrees primary / ssh + db.execute(text( + "DELETE FROM server_ips WHERE server_id = :sid AND (ip_type = 'primary' OR is_ssh = true)" + ), {"sid": server_id}) + + if not ip_reelle and not ip_connexion: + return + + if ip_reelle == ip_connexion: + # Meme IP : une seule row primary + ssh + db.execute(text(""" + INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh) + VALUES (:sid, :ip, 'primary', true) + """), {"sid": server_id, "ip": ip_reelle}) + else: + if ip_reelle: + db.execute(text(""" + INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh) + VALUES (:sid, :ip, 'primary', false) + """), {"sid": server_id, "ip": ip_reelle}) + if ip_connexion: + db.execute(text(""" + INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh) + VALUES (:sid, :ip, 'secondary', true) + """), {"sid": server_id, "ip": ip_connexion}) + + +SORT_COLS = { + "hostname": "s.hostname", + "env": "e.name", + "domaine": "d.name", + "tier": "s.tier", + "etat": "s.etat", + "os": "s.os_family", + "owner": "s.patch_os_owner", + "zone": "z.name", +} + + +def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="asc"): + """Liste paginee, filtree et triee des serveurs""" + offset = (page - 1) * per_page + where = ["1=1"] + params = {"limit": per_page, "offset": offset} + + if filters.get("domain"): + where.append("d.code = :domain"); params["domain"] = filters["domain"] + if filters.get("env"): + where.append("e.code = :env"); params["env"] = filters["env"] + if filters.get("tier"): + where.append("s.tier = :tier"); params["tier"] = filters["tier"] + if filters.get("etat"): + where.append("s.etat = :etat"); params["etat"] = filters["etat"] + if filters.get("search"): + where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%" + + wc = " AND ".join(where) + order_col = SORT_COLS.get(sort, "s.hostname") + order_dir = "DESC" if sort_dir == "desc" else "ASC" + order_clause = f"{order_col} {order_dir}, s.hostname ASC" + + servers = db.execute(text(f""" + SELECT s.id, s.hostname, s.fqdn, d.name as domaine, e.name as environnement, + z.name as zone, s.os_family, s.os_version, s.tier, s.etat, + s.licence_support, s.patch_os_owner, s.responsable_nom, s.machine_type, + CASE + WHEN s.os_version ILIKE '%Red Hat%' THEN + 'Red Hat ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '') + WHEN s.os_version ILIKE '%Oracle%Linux%' THEN + 'Oracle ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '') + WHEN s.os_version ILIKE '%CentOS%' THEN + 'CentOS ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d[\d.]*)'))[1], '') + WHEN s.os_version ILIKE '%Ubuntu%' THEN 'Ubuntu' + WHEN s.os_version ILIKE '%Windows Server 2022 Standard%' THEN '2022 Standard' + WHEN s.os_version ILIKE '%Windows Server 2022 Datacenter%' THEN '2022 Datacenter' + WHEN s.os_version ILIKE '%Windows Server 2019 Standard%' THEN '2019 Standard' + WHEN s.os_version ILIKE '%Windows Server 2019 Datacenter%' THEN '2019 Datacenter' + WHEN s.os_version ILIKE '%Windows Server 2016 Standard%' THEN '2016 Standard' + WHEN s.os_version ILIKE '%Windows Server 2016%' THEN '2016 Standard' + WHEN s.os_version ILIKE '%Windows Server 2012%' THEN '2012 R2' + WHEN s.os_version ILIKE '%Windows Server 2008%' THEN '2008 R2' + WHEN s.os_version ILIKE '%Windows 10 Enterprise%' THEN 'Windows 10 Ent' + WHEN s.os_version ILIKE '%Windows%2022%' THEN '2022 Standard' + WHEN s.os_version ILIKE '%Windows%2019%' THEN '2019 Standard' + WHEN s.os_version ILIKE '%Windows%2016%' THEN '2016 Standard' + WHEN s.os_version ILIKE '%Windows%2019%' THEN '2019 Standard' + WHEN s.os_version ILIKE '%Windows%2022%' THEN '2022 Standard' + ELSE LEFT(s.os_version, 25) + END as os_short + FROM servers s + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN environments e ON de.environment_id = e.id + LEFT JOIN zones z ON s.zone_id = z.id + WHERE {wc} ORDER BY {order_clause} LIMIT :limit OFFSET :offset + """), params).fetchall() + + total = db.execute(text(f""" + SELECT COUNT(*) FROM servers s + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN environments e ON de.environment_id = e.id + WHERE {wc} + """), params).scalar() + + return servers, total + + +def update_server(db, server_id, data, username): + """Met a jour un serveur et log l'action""" + # Domain + Env -> domain_env_id + if data.get("domain_code") and data.get("env_code"): + row = db.execute(text(""" + SELECT de.id FROM domain_environments de + JOIN domains d ON de.domain_id = d.id + JOIN environments e ON de.environment_id = e.id + WHERE d.code = :dc AND e.code = :ec + """), {"dc": data["domain_code"], "ec": data["env_code"]}).fetchone() + if row: + db.execute(text("UPDATE servers SET domain_env_id = :deid WHERE id = :id"), + {"deid": row.id, "id": server_id}) + + # Zone + if data.get("zone"): + zrow = db.execute(text("SELECT id FROM zones WHERE name = :z"), {"z": data["zone"]}).fetchone() + if zrow: + db.execute(text("UPDATE servers SET zone_id = :zid WHERE id = :id"), + {"zid": zrow.id, "id": server_id}) + + # IPs (reelle + connexion) + update_server_ips(db, server_id, data.get("ip_reelle"), data.get("ip_connexion")) + + # Champs directs + updates = [] + params = {"id": server_id} + direct_fields = ["tier", "etat", "patch_os_owner", "responsable_nom", + "referent_nom", "mode_operatoire", "commentaire", "ssh_method"] + changed = [] + for field in direct_fields: + if data.get(field) is not None: + updates.append(f"{field} = :{field}") + params[field] = data[field] + changed.append(field) + + if updates: + updates.append("updated_at = now()") + db.execute(text(f"UPDATE servers SET {', '.join(updates)} WHERE id = :id"), params) + + # Audit + db.execute(text( + "INSERT INTO audit_log (username, action, entity_type, entity_id) VALUES (:un, 'EDIT_SERVER', 'server', :sid)" + ), {"un": username, "sid": server_id}) + + db.commit() + return changed + + +def get_reference_data(db): + """Retourne les listes de reference pour les filtres/formulaires""" + domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall() + envs = db.execute(text("SELECT code, name FROM environments ORDER BY display_order")).fetchall() + return domains, envs diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..6113144 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,90 @@ + + + + + + {{ app_name }} - {% block title %}{% endblock %} + + + + + + + + {% if user %} +
+ +
+ +
+
+ {{ user.sub }} + {{ user.role }} + Deconnexion +
+
+
+
+ {% block content %}{% endblock %} +
+
+
+
+
+
+ {% else %} + {% block fullpage %}{% endblock %} + {% endif %} +
+ + diff --git a/app/templates/campaign_detail.html b/app/templates/campaign_detail.html new file mode 100644 index 0000000..909417e --- /dev/null +++ b/app/templates/campaign_detail.html @@ -0,0 +1,250 @@ +{% extends 'base.html' %} +{% block title %}{{ c.label or c.week_code }}{% endblock %} +{% block content %} + + +
+
+ ← Campagnes +

{{ c.label or c.week_code }}

+
+ {{ c.status }} + {{ c.week_code }} {{ c.year }} + {% if c.date_start %}{{ c.date_start.strftime('%d/%m/%Y') }}{% if c.date_end %} → {{ c.date_end.strftime('%d/%m/%Y') }}{% endif %}{% endif %} + par {{ c.created_by_name or '-' }} +
+
+
+ {% if c.status == 'draft' %} + {% if can_plan %} +
+
+ {% else %} + + {% endif %} + {% elif c.status == 'planned' %} +
+
+ {% elif c.status == 'in_progress' %} +
+
+ {% endif %} + {% if c.status in ('draft', 'planned') %} +
+
+ {% endif %} +
+
+ +{% if msg %} +
+ {% if msg == 'excluded' %}Serveur exclu.{% elif msg == 'restored' %}Serveur restaure.{% elif msg == 'prereq_saved' %}Prereqs sauvegardes.{% elif msg == 'prereq_checked' %}Prereq re-verifie.{% elif msg == 'prereq_needed' %}Impossible de planifier : tous les serveurs pending doivent avoir leurs prereqs valides.{% elif msg.startswith('checked_') %}Verification terminee : {{ msg.split('_')[1] }} serveur(s) verifies, {{ msg.split('_')[2] }} auto-exclus.{% elif msg.startswith('auto_excluded_') %}{{ msg.split('_')[-1] }} serveur(s) exclus (prereqs KO).{% endif %} +
+{% endif %} + + +
+
+
{{ stats.total }}
+
Total
+
+
+
{{ stats.patched }}
+
Patches
+
+
+
{{ stats.failed }}
+
Echoues
+
+
+
{{ stats.pending }}
+
En attente
+
+
+
{{ stats.excluded }}
+
Exclus
+
+
+
{{ stats.reported }}
+
Reportes
+
+
+ {% set patchable = stats.total - stats.excluded - stats.cancelled %} + {% if patchable > 0 %} +
{{ (stats.patched / patchable * 100)|int }}%
+ {% else %}
-
{% endif %} +
Progression
+
+
+ + +{% if c.status == 'draft' and prereq %} +
+
+

Prerequis ({{ prereq.prereq_ok }}/{{ prereq.total_pending }} valides)

+
+
+ +
+ {% if prereq.prereq_ko > 0 %} +
+ +
+ {% endif %} +
+
+
+
A verifier{{ prereq.prereq_todo }}
+
SSH OK{{ prereq.ssh_ok }}
+
Satellite OK{{ prereq.sat_ok }}
+
Rollback OK{{ prereq.rollback_ok }}
+
Disque OK{{ prereq.disk_ok }}
+
+ {% if prereq.total_pending > 0 %} +
+
+
+ {% endif %} +
+{% endif %} + + +
+ + + + + + + + {% if c.status == 'draft' %} + + + + + + {% endif %} + + + + + {% for s in sessions %} + + + + + + + {% if c.status == 'draft' %} + + + + + + {% endif %} + + + + + {% if s.status == 'pending' and c.status == 'draft' %} + + + + + + + + {% endif %} + {% endfor %} + +
HostnameDomaineEnvOSLicenceSSHSatelliteRollbackDisquePrereqStatutActions
{{ s.hostname }}{{ s.domaine or '-' }}{{ (s.environnement or '-')[:6] }}{{ s.os_family or '-' }}{{ s.licence_support }} + {% if s.prereq_ssh == 'ok' %}OK + {% elif s.prereq_ssh == 'ko' %}KO + {% else %}-{% endif %} + + {% if s.prereq_satellite == 'ok' %}OK + {% elif s.prereq_satellite == 'ko' %}KO + {% elif s.prereq_satellite == 'na' %}N/A + {% else %}-{% endif %} + + {% if s.rollback_method %}{{ s.rollback_method }} + {% else %}-{% endif %} + + {% if s.prereq_disk_ok is true %}OK + {% elif s.prereq_disk_ok is false %}KO + {% else %}-{% endif %} + + {% if s.prereq_validated %}OK + {% elif s.prereq_date %}KO + {% else %}-{% endif %} + + {{ s.status }} + {% if s.exclusion_reason %} +
+ {% if s.exclusion_reason == 'eol' %}EOL + {% elif s.exclusion_reason == 'creneau_inadequat' %}Creneau/Prereq + {% elif s.exclusion_reason == 'intervention_non_secops' %}Non-SecOps + {% elif s.exclusion_reason == 'report_cycle' %}Reporte + {% elif s.exclusion_reason == 'non_patchable' %}Non patchable + {% else %}{{ s.exclusion_reason }}{% endif %} + {% if s.excluded_by %}({{ s.excluded_by }}){% endif %} +
+ {% if s.exclusion_detail %}
{{ s.exclusion_detail[:60] }}
{% endif %} + {% endif %} +
+ {% if s.status == 'excluded' %} +
+ +
+ {% elif s.status == 'pending' and c.status == 'draft' %} +
+
+ +
+ + +
+ {% endif %} +
+
+ SSH: + + Satellite: + + Rollback: + + + + +
+
+
+ Motif: + + + + +
+
+
+{% endblock %} diff --git a/app/templates/campaigns.html b/app/templates/campaigns.html new file mode 100644 index 0000000..97e1b16 --- /dev/null +++ b/app/templates/campaigns.html @@ -0,0 +1,82 @@ +{% extends 'base.html' %} +{% block title %}Campagnes{% endblock %} +{% block content %} +
+

Campagnes {{ year }}

+ +
+ + +
+ Toutes + {% for st in ['draft','planned','in_progress','completed','cancelled'] %} + {{ st }} + {% endfor %} +
+ + + + + +
+ + +
+

Creer depuis le planning

+ + {% if planned_weeks %} +
+ +
+ + +
+
+
+ {% else %} +

Aucune semaine planifiee a venir pour {{ year }}. Verifiez le planning.

+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..beb1a40 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,79 @@ +{% extends 'base.html' %} +{% block title %}Dashboard{% endblock %} +{% block content %} +

Dashboard

+ + +
+
+
{{ stats.total_servers }}
+
Serveurs
+
+
+
{{ stats.patchable }}
+
Patchables SecOps
+
+
+
{{ stats.linux }} / {{ stats.windows }}
+
Linux / Windows
+
+
+
{{ stats.qualys_tags }}
+
Tags Qualys
+
+
+ + +
+

Par domaine

+ + + + + + + + + + {% for d in domains %} + + + + + + + + {% endfor %} + +
DomaineTotalActifsLinuxWindows
{{ d.name }}{{ d.total }}{{ d.actifs }}{{ d.linux }}{{ d.windows }}
+
+ + +
+

Par tier

+
+ {% for t in tiers %} +
+
{{ t[1] }}
+
{{ t[0] }}
+
+ {% endfor %} +
+
+ + +
+
+ Decomissionnes + {{ stats.decom }} +
+
+ EOL + {{ stats.eol }} +
+
+ Assets Qualys + {{ stats.qualys_assets }} +
+
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..8c46031 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} +{% block title %}Connexion{% endblock %} +{% block fullpage %} +
+
+
+

PatchCenter

+

Authentification requise

+
+ {% if error %} +
{{ error }}
+ {% endif %} +
+
+ + +
+
+ + +
+ +
+

v{{ version }}

+
+
+{% endblock %} diff --git a/app/templates/partials/campaign_preview.html b/app/templates/partials/campaign_preview.html new file mode 100644 index 0000000..bebddca --- /dev/null +++ b/app/templates/partials/campaign_preview.html @@ -0,0 +1,49 @@ +
+
+
+ {{ count }} serveurs proposes + Scope: {{ scope }} +
+
+ +
+ + +
+ + +
+ +
+ + + + + + + + + + + + + {% for s in servers %} + + + + + + + + + + + {% endfor %} + +
HostnameDomaineEnvOSTierConnexionLicence
{{ s.hostname }}{{ s.domaine or '-' }}{{ (s.environnement or '-')[:6] }}{{ s.os_family or '-' }}{{ s.tier }}{{ s.ssh_method or '-' }}{{ s.licence_support }}
+
+ +

Decochez les serveurs a exclure. Vous pourrez aussi exclure/reporter individuellement apres creation.

+ +
+
diff --git a/app/templates/partials/server_detail.html b/app/templates/partials/server_detail.html new file mode 100644 index 0000000..aef5cdb --- /dev/null +++ b/app/templates/partials/server_detail.html @@ -0,0 +1,120 @@ +
+
+

{{ s.hostname }}

+ +
+ + +
+

Identification

+
+
FQDN{{ s.fqdn or '-' }}
+
Domain.ltd{{ s.domain_ltd or '-' }}
+
Machine{{ s.machine_type }}
+
+
+ + +
+

Reseau

+
+
+ IP reelle + {{ ips.ip_reelle or '-' }} +
+
+ IP de connexion + {{ ips.ip_connexion or '-' }} +
+ {% if ips.autres_ips %} +
+ Autres IPs +
+ {% for a in ips.autres_ips %} +
+ {{ a.ip }} + {{ a.type }}{% if a.interface %} ({{ a.interface }}){% endif %}{% if a.description %} - {{ a.description }}{% endif %} +
+ {% endfor %} +
+
+ {% endif %} +
Mode connexion{{ s.ssh_method }}
+
+
+ + +
+

Classification

+
+
Domaine{{ s.domaine }}
+
Environnement{{ s.environnement }}
+
Zone{{ s.zone or 'LAN' }}
+
Tier{{ s.tier }}
+
Etat{{ s.etat }}
+
+
+ + +
+

Technique

+
+
OS{{ s.os_family or '-' }}
+
{{ s.os_version or '' }}
+
Licence{{ s.licence_support }}
+
+
+ + +
+

Patching

+
+
Owner OS{{ s.patch_os_owner }}
+
Frequence{{ s.patch_frequency }}
+
Podman{{ 'Oui' if s.is_podman else 'Non' }}
+
Prevenance{{ 'Oui' if s.need_pct else 'Non' }}
+
Satellite{% if s.satellite_host %}{% if 'sat1' in s.satellite_host %}SAT1 (DMZ){% elif 'sat2' in s.satellite_host %}SAT2 (LAN){% else %}{{ s.satellite_host }}{% endif %}{% else %}N/A{% endif %}
+
+
+ + +
+

Responsables

+
+
Responsable: {{ s.responsable_nom or '-' }}
+
Referent: {{ s.referent_nom or '-' }}
+
+
+ + {% if s.mode_operatoire %} +
+

Mode operatoire

+

{{ s.mode_operatoire }}

+
+ {% endif %} + + {% if tags %} +
+

Tags Qualys

+
+ {% for tag in tags %} + {{ tag }} + {% endfor %} +
+
+ {% endif %} + + {% if sync_msg is defined and sync_msg %} +
+ {{ sync_msg }} +
+ {% endif %} + + +
+ + + +
+ Synchro en cours... +
diff --git a/app/templates/partials/server_edit.html b/app/templates/partials/server_edit.html new file mode 100644 index 0000000..1b95436 --- /dev/null +++ b/app/templates/partials/server_edit.html @@ -0,0 +1,85 @@ +
+
+

Editer {{ s.hostname }}

+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
diff --git a/app/templates/partials/specific_edit.html b/app/templates/partials/specific_edit.html new file mode 100644 index 0000000..82b2d4c --- /dev/null +++ b/app/templates/partials/specific_edit.html @@ -0,0 +1,182 @@ +
+

{{ sp.hostname }}

+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + +

Commandes

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +

Flags

+
+ + + + + + + + + + + +
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+ + +
+
diff --git a/app/templates/planning.html b/app/templates/planning.html new file mode 100644 index 0000000..9a67aba --- /dev/null +++ b/app/templates/planning.html @@ -0,0 +1,243 @@ +{% extends 'base.html' %} +{% block title %}Planning Patching {{ year }}{% endblock %} +{% block content %} +
+

Planning Patching {{ year }}

+
+ {{ year - 1 }} + {{ year + 1 }} + + {% if entries %} +
+ + + +
+ {% endif %} +
+
+ +{% if msg %} +
+ {% if msg == 'add' %}Entree ajoutee.{% elif msg == 'edit' %}Entree modifiee.{% elif msg == 'delete' %}Entree supprimee.{% elif msg == 'duplicate' %}Planning duplique avec succes.{% elif msg == 'exists' %}L'annee cible contient deja des entrees. Supprimez-les d'abord.{% elif msg == 'err_week' %}Numero de semaine invalide (1-53).{% elif msg == 'err_domain' %}Domaine requis pour une entree ouverte.{% elif msg == 'err_past' %}Impossible d'ajouter dans le passe (semaine deja ecoulee).{% elif msg == 'err_past_wed' %}Semaine en cours : ajout possible uniquement lundi et mardi (MEP urgente).{% endif %} +
+{% endif %} + + +
+
Cycle 1
+
Cycle 2
+
Cycle 3
+
Gel
+
DMZ (continu)
+ HPROD = hors-prod | PROD = production | pilot = prod pilote +
+ + +
+ + + + + + {% for m in months %} + + {% endfor %} + + + + + {% for w in weeks %} + + {% endfor %} + + + + {% for dom in domains %} + + + {% for w in weeks %} + {% set entry = grid.get(dom.code, {}).get(w) %} + + {% endfor %} + + {% endfor %} + +
Domaine{{ m }}
+ {{ w }} +
+
+ +
+ {{ dom.name }} + ({{ dom.srv_count }}) +
+
+
+ {% if w in freeze_weeks %} +
+ {% elif entry %} + {% set bg = '#1e3a8a' %} + {% if entry.cycle == 2 %}{% set bg = '#7c3aed' %}{% endif %} + {% if entry.cycle == 3 %}{% set bg = '#166534' %}{% endif %} + {% if dom.code == 'DMZ' %}{% set bg = '#5f3737' %}{% endif %} +
+ + {% if entry.env_scope == 'prod' %}P{% elif entry.env_scope == 'hprod' %}H{% elif entry.env_scope == 'prod_pilot' %}PP{% elif entry.env_scope == 'all' %}A{% endif %} + +
+ {% else %} +
+ {% endif %} +
+
+ + +
+{% for cycle_num in [1, 2, 3] %} +
+

Cycle {{ cycle_num }}

+
+ {% for w in weeks %} + {% for dom in domains %} + {% set entry = grid.get(dom.code, {}).get(w) %} + {% if entry and entry.cycle == cycle_num and dom.code != 'DMZ' %} +
+
+ S{{ '%02d' % w }} + + {{ dom.name }} + {{ entry.env_scope }} +
+ {% if entry.note %}{{ entry.note[:20] }}{% endif %} +
+ {% endif %} + {% endfor %} + {% endfor %} +
+
+{% endfor %} +
+ + +
+
+

Donnees planning {{ year }} ({{ entries|length }} entrees)

+
+ + + + + + + + + + + + + + + {% for e in entries %} + + + + + + + + + + + + + + + {% endfor %} + +
Sem.DatesDomaineEnvCycleStatutNoteActions
+
+ + +
+

Ajouter une entree

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+{% endblock %} diff --git a/app/templates/servers.html b/app/templates/servers.html new file mode 100644 index 0000000..f50056a --- /dev/null +++ b/app/templates/servers.html @@ -0,0 +1,109 @@ +{% extends 'base.html' %} +{% block title %}Serveurs{% endblock %} + +{% macro sort_url(col) -%} +?sort={{ col }}&sort_dir={% if sort == col and sort_dir == 'asc' %}desc{% else %}asc{% endif %}&search={{ filters.search or '' }}&domain={{ filters.domain or '' }}&env={{ filters.env or '' }}&tier={{ filters.tier or '' }}&etat={{ filters.etat or '' }}&page=1 +{%- endmacro %} + +{% macro sort_icon(col) -%} +{% if sort == col %}{% if sort_dir == 'asc' %}▲{% else %}▼{% endif %}{% endif %} +{%- endmacro %} + +{% macro qs(p) -%} +?page={{ p }}&search={{ filters.search or '' }}&domain={{ filters.domain or '' }}&env={{ filters.env or '' }}&tier={{ filters.tier or '' }}&etat={{ filters.etat or '' }}&sort={{ sort }}&sort_dir={{ sort_dir }} +{%- endmacro %} + +{% block content %} +
+

Serveurs ({{ total }})

+
+ +
+
+ + +
+ + + + + + + + + Reset +
+ + +
+ + + + + + + + + + + + + + + + + {% for s in servers %} + + + + + + + + + + + + + + + {% endfor %} + +
Hostname {{ sort_icon('hostname') }}Domaine {{ sort_icon('domaine') }}Env {{ sort_icon('env') }}Zone {{ sort_icon('zone') }}OS {{ sort_icon('os') }}VersionLicenceTier {{ sort_icon('tier') }}Etat {{ sort_icon('etat') }}Owner {{ sort_icon('owner') }}Actions
{{ s.hostname }}{{ s.domaine or '-' }}{{ (s.environnement or '-')[:6] }}{{ s.zone or 'LAN' }}{{ s.os_family or '-' }}{{ s.os_short or '-' }}{{ s.licence_support }}{{ s.tier }}{{ (s.etat or '')[:8] }}{{ s.patch_os_owner or '-' }} + +
+
+ + +
+ Page {{ page }} / {{ ((total - 1) // per_page) + 1 }} — {{ total }} serveurs +
+ {% if page > 1 %}Precedent{% endif %} + {% if page * per_page < total %}Suivant{% endif %} +
+
+ + +{% endblock %} diff --git a/app/templates/settings.html b/app/templates/settings.html new file mode 100644 index 0000000..4ad16ba --- /dev/null +++ b/app/templates/settings.html @@ -0,0 +1,389 @@ +{% extends 'base.html' %} +{% block title %}Settings{% endblock %} +{% block content %} +

Settings

+ +{% if saved %} +
+ Section "{{ saved }}" sauvegardee. +
+{% endif %} + +{% macro section_header(key, title, badge_text, badge_class, extra="") %} + +{% endmacro %} + +
+ + + {% if visible.qualys %} +
+ {{ section_header("qualys", "Qualys API", "Connecte", "badge-green", q_tags|string + " tags / " + q_assets|string + " assets / " + q_linked|string + " lies") }} +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ {% if editable.qualys %}{% endif %} +
+
+
+ {% endif %} + + + {% if visible.ssh_key %} +
+ {{ section_header("ssh_key", "SSH Cle privee", "ssh_key", "badge-green") }} +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+

Surchargeable par serveur (ssh_user, ssh_port dans la fiche serveur).

+ {% if editable.ssh_key %}{% endif %} +
+
+
+ {% endif %} + + + {% if visible.ssh_pwd %} +
+ {{ section_header("ssh_pwd", "SSH Password", "ssh_pwd", "badge-yellow") }} +
+
+
+ + +
+
+ + +
+

Pour les environnements recette sans cle SSH. Chaque operateur peut configurer son propre compte.

+ {% if editable.ssh_pwd %}{% endif %} +
+
+
+ {% endif %} + + + {% if visible.ssh_psmp %} +
+ {{ section_header("ssh_psmp", "SSH PSMP — CyberArk", "ssh_psmp", "badge-yellow") }} +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+

Auth keyboard-interactive. Chaque operateur configure son propre compte CyberArk. MDP saisi en session.

+ {% if editable.ssh_psmp %}{% endif %} +
+
+
+ {% endif %} + + + {% if visible.rdp_psm %} +
+ {{ section_header("rdp_psm", "RDP PSM — CyberArk", "rdp_psm", "badge-blue") }} +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+

Connexion RDP via token PVWA API. Production Windows uniquement.

+ {% if editable.rdp_psm %}{% endif %} +
+
+
+ {% endif %} + + + + + {% if visible.vsphere %} +
+ +
+ {% if editable.vsphere %} +
+

Credentials vSphere (communs)

+
+
+ + +
+
+ + +
+
+ +
+ {% endif %} + +
+

vCenters enregistres

+ + + + + + + + + {% if editable.vsphere %}{% endif %} + + + {% for vc in vcenters %} + + + + + + + + {% if editable.vsphere %} + + {% endif %} + + {% endfor %} + +
NomEndpointDatacenterDescriptionResponsableActifAction
{{ vc.name }}{{ vc.endpoint }}{{ vc.datacenter or '-' }}{{ vc.description or '-' }}{{ vc.responsable or '-' }}{{ 'Oui' if vc.is_active else 'Non' }} + {% if vc.is_active %} +
+ +
+ {% endif %} +
+
+ + {% if editable.vsphere %} +
+

Ajouter un vCenter

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ {% endif %} +
+
+ {% endif %} + + + {% if visible.splunk %} +
+ {{ section_header("splunk", "Splunk — Remote Log", "HEC", "badge-yellow") }} +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+

Envoie les evenements de patching vers Splunk via HEC.

+ {% if editable.splunk %}{% endif %} +
+
+
+ {% endif %} + + + {% if visible.teams %} +
+ {{ section_header("teams", "Teams — Notifications", "Webhook + SharePoint", "badge-blue") }} +
+
+

Canal Teams (Webhook direct)

+
+ + +
+

Conversation groupe (SharePoint + Power Automate)

+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+

Power Automate : depose JSON sur SharePoint → lit + poste dans la conversation → supprime.

+ {% if editable.teams %}{% endif %} +
+
+
+ {% endif %} + + + {% if visible.itop %} +
+ +
+
+ {% for key, label, is_secret in sections.itop %} +
+ + +
+ {% endfor %} +
+
- Import serveurs + metadata
+
- Sync responsables / referents
+
- Lien applications / clusters
+
- Enrichissement domaine / environnement
+
+ {% if editable.itop %}{% endif %} +
+
+
+ {% endif %} + +
+{% endblock %} diff --git a/app/templates/specifics.html b/app/templates/specifics.html new file mode 100644 index 0000000..fd74cf5 --- /dev/null +++ b/app/templates/specifics.html @@ -0,0 +1,96 @@ +{% extends 'base.html' %} +{% block title %}Serveurs specifiques{% endblock %} +{% block content %} +
+

Serveurs specifiques ({{ entries|length }})

+
+ +{% set msg = request.query_params.get('msg') %} +{% if msg %} +
+ {% if msg == 'saved' %}Specificites sauvegardees.{% elif msg == 'added' %}Serveur ajoute.{% elif msg == 'not_found' %}Hostname non trouve en base.{% elif msg == 'exists' %}Ce serveur a deja des specificites.{% endif %} +
+{% endif %} + + +
+ Tous ({{ entries|length }}) + {% for t in types_in_db %} + {{ t }} + {% endfor %} +
+ + +
+
+ + +
+ + + + + + + + + + + + + + {% for e in entries %} + + + + + + + + + + + + {% endfor %} + +
HostnameTypeDomaineEnvFlagsOrdreAuto-restartNoteActions
{{ e.hostname }}{{ e.app_type or '-' }}{{ e.domaine or '-' }}{{ (e.environnement or '-')[:6] }} +
+ {% if e.kernel_update_blocked %}K{% endif %} + {% if e.is_cluster %}C{% endif %} + {% if e.is_db %}DB{% endif %} + {% if e.is_middleware %}MW{% endif %} + {% if e.sentinel_disable_required %}S1{% endif %} + {% if e.ip_forwarding_required %}IP{% endif %} + {% if e.rolling_update %}RU{% endif %} + {% if e.needs_manual_step %}M{% endif %} + {% if e.extra_excludes %}EX{% endif %} +
+
{% if e.reboot_order %}#{{ e.reboot_order }}{% if e.patch_order_group %} ({{ e.patch_order_group }}){% endif %}{% else %}-{% endif %}{{ 'Oui' if e.auto_restart else 'Non' }}{{ (e.note or '')[:80] }}{% if e.note and e.note|length > 80 %}...{% endif %} + +
+
+ + + + + +
+

Ajouter un serveur specifique

+
+
+ + +
+
+ + +
+ +
+
+{% endblock %} diff --git a/app/templates/users.html b/app/templates/users.html new file mode 100644 index 0000000..8974421 --- /dev/null +++ b/app/templates/users.html @@ -0,0 +1,110 @@ +{% extends 'base.html' %} +{% block title %}Utilisateurs{% endblock %} +{% block content %} +

Utilisateurs & Permissions

+ +{% if saved %} +
+ {% if saved == 'add' %}Utilisateur cree.{% elif saved == 'password' %}Mot de passe modifie.{% elif saved == 'toggle' %}Statut modifie.{% else %}Permissions sauvegardees.{% endif %} +
+{% endif %} + + +
+{% for ud in users_data %} +
+
+
+ {{ ud.user.username }} + {{ ud.user.display_name }} + {{ ud.user.role }} + {{ 'Actif' if ud.user.is_active else 'Inactif' }} + {% if ud.user.email %}{{ ud.user.email }}{% endif %} +
+
+ + {% for m in modules %} + {% if ud.perms.get(m) %} + {{ m[:3] }} + {% endif %} + {% endfor %} + +
+
+ +
+ +
+

Permissions par module

+
+ {% for m in modules %} +
+ + +
+ {% endfor %} +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+
+
+
+{% endfor %} +
+ + +
+

Ajouter un utilisateur

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

Les permissions par module seront pre-remplies selon le role choisi. Modifiables ensuite.

+ +
+
+{% endblock %} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..e81ad23 --- /dev/null +++ b/run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd /opt/patchcenter +source venv/bin/activate +uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload