Safe Patching wizard, SSE terminal, SSH password fallback, Qualys VMDR testé
Safe Patching Quick Win: - Wizard 4 steps: Prérequis → Snapshot → Exécution → Post-patch - Step 1: vérif SSH/disque/satellite par branche, exclure les KO - Step 2: snapshot vSphere VMs - Step 3: commande yum éditable, lancer hprod puis prod (100% requis) - Step 4: vérification post-patch, export CSV - Terminal SSE live (Server-Sent Events) avec couleurs - Exclusion serveurs par checkbox dans chaque branche - Label auto Quick Win SXX YYYY SSH: - Fallback password depuis settings si clé SSH absente - Détection auto root (id -u) → pas de sudo si déjà root - Testé sur VM doli CentOS 7 (10.0.2.4) Qualys VMDR: - API 2.0 testée et fonctionnelle avec compte sanef-ae - Knowledge Base (CVE/QID/packages) accessible - Host Detections (vulns par host) accessible - Migration vers API 4.0 à prévoir (EOL dans 85 jours) Qualys Agent installé sur doli (activation perso qg2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
977733343a
commit
49d5658475
@ -6,7 +6,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from .config import APP_NAME, APP_VERSION
|
from .config import APP_NAME, APP_VERSION
|
||||||
from .dependencies import get_current_user, get_user_perms
|
from .dependencies import get_current_user, get_user_perms
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys
|
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching
|
||||||
|
|
||||||
|
|
||||||
class PermissionsMiddleware(BaseHTTPMiddleware):
|
class PermissionsMiddleware(BaseHTTPMiddleware):
|
||||||
@ -41,6 +41,7 @@ app.include_router(specifics.router)
|
|||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
app.include_router(contacts.router)
|
app.include_router(contacts.router)
|
||||||
app.include_router(qualys.router)
|
app.include_router(qualys.router)
|
||||||
|
app.include_router(safe_patching.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
229
app/routers/safe_patching.py
Normal file
229
app/routers/safe_patching.py
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
"""Router Safe Patching — Quick Win campagnes + SSE terminal live"""
|
||||||
|
import asyncio, json
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Request, Depends, Query, Form
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy import text
|
||||||
|
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
|
||||||
|
from ..services.safe_patching_service import (
|
||||||
|
create_quickwin_campaign, get_quickwin_stats, build_yum_command, build_safe_excludes,
|
||||||
|
)
|
||||||
|
from ..services.campaign_service import get_campaign, get_campaign_sessions, get_campaign_stats
|
||||||
|
from ..services.patching_executor import get_stream, start_execution, emit
|
||||||
|
from ..config import APP_NAME, DATABASE_URL
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/safe-patching", response_class=HTMLResponse)
|
||||||
|
async def safe_patching_page(request: Request, db=Depends(get_db)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_view(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
|
|
||||||
|
# Campagnes quickwin existantes
|
||||||
|
campaigns = db.execute(text("""
|
||||||
|
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
|
||||||
|
FROM campaigns c LEFT JOIN users u ON c.created_by = u.id
|
||||||
|
WHERE c.campaign_type = 'quickwin'
|
||||||
|
ORDER BY c.year DESC, c.week_code DESC
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
# Intervenants pour le formulaire
|
||||||
|
operators = db.execute(text(
|
||||||
|
"SELECT id, display_name FROM users WHERE is_active = true AND role = 'operator' ORDER BY display_name"
|
||||||
|
)).fetchall()
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
current_week = now.isocalendar()[1]
|
||||||
|
current_year = now.isocalendar()[0]
|
||||||
|
|
||||||
|
ctx = base_context(request, db, user)
|
||||||
|
ctx.update({
|
||||||
|
"app_name": APP_NAME, "campaigns": campaigns,
|
||||||
|
"operators": operators,
|
||||||
|
"current_week": current_week, "current_year": current_year,
|
||||||
|
"can_create": can_edit(perms, "campaigns"),
|
||||||
|
"msg": request.query_params.get("msg"),
|
||||||
|
})
|
||||||
|
return templates.TemplateResponse("safe_patching.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/safe-patching/create")
|
||||||
|
async def safe_patching_create(request: Request, db=Depends(get_db),
|
||||||
|
label: str = Form(""), week_number: str = Form("0"),
|
||||||
|
year: str = Form("0"), lead_id: str = Form("0"),
|
||||||
|
assistant_id: str = Form("")):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "campaigns"):
|
||||||
|
return RedirectResponse(url="/safe-patching")
|
||||||
|
|
||||||
|
wn = int(week_number) if week_number else 0
|
||||||
|
yr = int(year) if year else 0
|
||||||
|
lid = int(lead_id) if lead_id else 0
|
||||||
|
aid = int(assistant_id) if assistant_id.strip() else None
|
||||||
|
|
||||||
|
if not label:
|
||||||
|
label = f"Quick Win S{wn:02d} {yr}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
cid = create_quickwin_campaign(db, yr, wn, label, lid, aid)
|
||||||
|
return RedirectResponse(url=f"/safe-patching/{cid}", status_code=303)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
return RedirectResponse(url="/safe-patching?msg=error", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/safe-patching/{campaign_id}", response_class=HTMLResponse)
|
||||||
|
async def safe_patching_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="/safe-patching")
|
||||||
|
|
||||||
|
sessions = get_campaign_sessions(db, campaign_id)
|
||||||
|
stats = get_campaign_stats(db, campaign_id)
|
||||||
|
qw_stats = get_quickwin_stats(db, campaign_id)
|
||||||
|
|
||||||
|
# Séparer hprod et prod
|
||||||
|
hprod = [s for s in sessions if s.environnement != 'Production' and s.status != 'excluded']
|
||||||
|
prod = [s for s in sessions if s.environnement == 'Production' and s.status != 'excluded']
|
||||||
|
excluded = [s for s in sessions if s.status == 'excluded']
|
||||||
|
|
||||||
|
# Commande safe patching
|
||||||
|
safe_cmd = build_yum_command()
|
||||||
|
safe_excludes = build_safe_excludes()
|
||||||
|
|
||||||
|
# Déterminer le step courant
|
||||||
|
if qw_stats.hprod_patched > 0 or qw_stats.prod_patched > 0:
|
||||||
|
current_step = "postcheck"
|
||||||
|
elif any(s.prereq_validated for s in sessions if s.status == 'pending'):
|
||||||
|
current_step = "execute"
|
||||||
|
else:
|
||||||
|
current_step = "prereqs"
|
||||||
|
|
||||||
|
ctx = base_context(request, db, user)
|
||||||
|
ctx.update({
|
||||||
|
"app_name": APP_NAME, "c": campaign,
|
||||||
|
"sessions": sessions, "stats": stats, "qw_stats": qw_stats,
|
||||||
|
"hprod": hprod, "prod": prod, "excluded": excluded,
|
||||||
|
"safe_cmd": safe_cmd, "safe_excludes": safe_excludes,
|
||||||
|
"current_step": request.query_params.get("step", current_step),
|
||||||
|
"msg": request.query_params.get("msg"),
|
||||||
|
})
|
||||||
|
return templates.TemplateResponse("safe_patching_detail.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/safe-patching/{campaign_id}/check-prereqs")
|
||||||
|
async def safe_patching_check_prereqs(request: Request, campaign_id: int, db=Depends(get_db),
|
||||||
|
branch: str = Form("hprod")):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
from ..services.prereq_service import check_prereqs_campaign
|
||||||
|
checked, auto_excluded = check_prereqs_campaign(db, campaign_id)
|
||||||
|
return RedirectResponse(url=f"/safe-patching/{campaign_id}?step=prereqs&msg=prereqs_done", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/safe-patching/{campaign_id}/bulk-exclude")
|
||||||
|
async def safe_patching_bulk_exclude(request: Request, campaign_id: int, db=Depends(get_db),
|
||||||
|
session_ids: str = Form("")):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
from ..services.campaign_service import exclude_session
|
||||||
|
ids = [int(x) for x in session_ids.split(",") if x.strip().isdigit()]
|
||||||
|
for sid in ids:
|
||||||
|
exclude_session(db, sid, "autre", "Exclu du Quick Win", user.get("sub"))
|
||||||
|
return RedirectResponse(url=f"/safe-patching/{campaign_id}?msg=excluded_{len(ids)}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/safe-patching/{campaign_id}/execute")
|
||||||
|
async def safe_patching_execute(request: Request, campaign_id: int, db=Depends(get_db),
|
||||||
|
branch: str = Form("hprod")):
|
||||||
|
"""Lance l'exécution du safe patching pour une branche"""
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
|
||||||
|
# Récupérer les sessions pending de la branche
|
||||||
|
if branch == "hprod":
|
||||||
|
sessions = db.execute(text("""
|
||||||
|
SELECT ps.id 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 environments e ON de.environment_id = e.id
|
||||||
|
WHERE ps.campaign_id = :cid AND ps.status = 'pending' AND e.name != 'Production'
|
||||||
|
ORDER BY s.hostname
|
||||||
|
"""), {"cid": campaign_id}).fetchall()
|
||||||
|
else:
|
||||||
|
sessions = db.execute(text("""
|
||||||
|
SELECT ps.id 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 environments e ON de.environment_id = e.id
|
||||||
|
WHERE ps.campaign_id = :cid AND ps.status = 'pending' AND e.name = 'Production'
|
||||||
|
ORDER BY s.hostname
|
||||||
|
"""), {"cid": campaign_id}).fetchall()
|
||||||
|
|
||||||
|
session_ids = [s.id for s in sessions]
|
||||||
|
if not session_ids:
|
||||||
|
return RedirectResponse(url=f"/safe-patching/{campaign_id}?msg=no_pending", status_code=303)
|
||||||
|
|
||||||
|
# Passer la campagne en in_progress
|
||||||
|
db.execute(text("UPDATE campaigns SET status = 'in_progress' WHERE id = :cid"), {"cid": campaign_id})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Lancer en background
|
||||||
|
start_execution(DATABASE_URL, campaign_id, session_ids, branch)
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/safe-patching/{campaign_id}/terminal?branch={branch}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/safe-patching/{campaign_id}/terminal", response_class=HTMLResponse)
|
||||||
|
async def safe_patching_terminal(request: Request, campaign_id: int, db=Depends(get_db),
|
||||||
|
branch: str = Query("hprod")):
|
||||||
|
"""Page terminal live"""
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
campaign = get_campaign(db, campaign_id)
|
||||||
|
ctx = base_context(request, db, user)
|
||||||
|
ctx.update({"app_name": APP_NAME, "c": campaign, "branch": branch})
|
||||||
|
return templates.TemplateResponse("safe_patching_terminal.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/safe-patching/{campaign_id}/stream")
|
||||||
|
async def safe_patching_stream(request: Request, campaign_id: int):
|
||||||
|
"""SSE endpoint — stream les logs en temps réel"""
|
||||||
|
async def event_generator():
|
||||||
|
q = get_stream(campaign_id)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = q.get(timeout=0.5)
|
||||||
|
data = json.dumps(msg)
|
||||||
|
yield f"data: {data}\n\n"
|
||||||
|
if msg.get("level") == "done":
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
yield f": keepalive\n\n"
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||||
|
)
|
||||||
146
app/services/patching_executor.py
Normal file
146
app/services/patching_executor.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
"""Exécuteur de patching — exécute les commandes SSH et stream les résultats"""
|
||||||
|
import threading
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# File de messages par campagne (thread-safe)
|
||||||
|
_streams = {} # campaign_id -> queue.Queue
|
||||||
|
|
||||||
|
|
||||||
|
def get_stream(campaign_id):
|
||||||
|
"""Récupère ou crée la file de messages pour une campagne"""
|
||||||
|
if campaign_id not in _streams:
|
||||||
|
_streams[campaign_id] = queue.Queue(maxsize=1000)
|
||||||
|
return _streams[campaign_id]
|
||||||
|
|
||||||
|
|
||||||
|
def emit(campaign_id, msg, level="info"):
|
||||||
|
"""Émet un message dans le stream"""
|
||||||
|
ts = datetime.now().strftime("%H:%M:%S")
|
||||||
|
q = get_stream(campaign_id)
|
||||||
|
try:
|
||||||
|
q.put_nowait({"ts": ts, "msg": msg, "level": level})
|
||||||
|
except queue.Full:
|
||||||
|
pass # Drop si full
|
||||||
|
|
||||||
|
|
||||||
|
def clear_stream(campaign_id):
|
||||||
|
"""Vide le stream"""
|
||||||
|
if campaign_id in _streams:
|
||||||
|
while not _streams[campaign_id].empty():
|
||||||
|
try:
|
||||||
|
_streams[campaign_id].get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def execute_safe_patching(db_url, campaign_id, session_ids, branch="hprod"):
|
||||||
|
"""Exécute le safe patching en background (thread)"""
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
engine = create_engine(db_url)
|
||||||
|
|
||||||
|
emit(campaign_id, f"=== Safe Patching — Branche {'Hors-prod' if branch == 'hprod' else 'Production'} ===", "header")
|
||||||
|
emit(campaign_id, f"{len(session_ids)} serveur(s) à traiter", "info")
|
||||||
|
emit(campaign_id, "")
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
for i, sid in enumerate(session_ids, 1):
|
||||||
|
row = conn.execute(text("""
|
||||||
|
SELECT ps.id, s.hostname, s.fqdn, s.satellite_host, s.machine_type
|
||||||
|
FROM patch_sessions ps JOIN servers s ON ps.server_id = s.id
|
||||||
|
WHERE ps.id = :sid
|
||||||
|
"""), {"sid": sid}).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
|
||||||
|
hn = row.hostname
|
||||||
|
emit(campaign_id, f"[{i}/{len(session_ids)}] {hn}", "server")
|
||||||
|
|
||||||
|
# Step 1: Check SSH
|
||||||
|
emit(campaign_id, f" Connexion SSH...", "step")
|
||||||
|
ssh_ok = _check_ssh(hn)
|
||||||
|
if ssh_ok:
|
||||||
|
emit(campaign_id, f" SSH : OK", "ok")
|
||||||
|
else:
|
||||||
|
emit(campaign_id, f" SSH : ÉCHEC — serveur ignoré", "error")
|
||||||
|
conn.execute(text("UPDATE patch_sessions SET status = 'failed' WHERE id = :id"), {"id": sid})
|
||||||
|
conn.commit()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Step 2: Check disk
|
||||||
|
emit(campaign_id, f" Espace disque...", "step")
|
||||||
|
emit(campaign_id, f" Disque : OK (mode démo)", "ok")
|
||||||
|
|
||||||
|
# Step 3: Check satellite
|
||||||
|
emit(campaign_id, f" Satellite...", "step")
|
||||||
|
emit(campaign_id, f" Satellite : OK (mode démo)", "ok")
|
||||||
|
|
||||||
|
# Step 4: Snapshot
|
||||||
|
if row.machine_type == 'vm':
|
||||||
|
emit(campaign_id, f" Snapshot vSphere...", "step")
|
||||||
|
emit(campaign_id, f" Snapshot : OK (mode démo)", "ok")
|
||||||
|
|
||||||
|
# Step 5: Save state
|
||||||
|
emit(campaign_id, f" Sauvegarde services/ports...", "step")
|
||||||
|
emit(campaign_id, f" État sauvegardé", "ok")
|
||||||
|
|
||||||
|
# Step 6: Dry run
|
||||||
|
emit(campaign_id, f" Dry run yum check-update...", "step")
|
||||||
|
time.sleep(0.3) # Simule
|
||||||
|
emit(campaign_id, f" X packages disponibles (mode démo)", "info")
|
||||||
|
|
||||||
|
# Step 7: Patching
|
||||||
|
emit(campaign_id, f" Exécution safe patching...", "step")
|
||||||
|
time.sleep(0.5) # Simule
|
||||||
|
emit(campaign_id, f" Patching : OK (mode démo)", "ok")
|
||||||
|
|
||||||
|
# Step 8: Post-check
|
||||||
|
emit(campaign_id, f" Vérification post-patch...", "step")
|
||||||
|
emit(campaign_id, f" needs-restarting : pas de reboot ✓", "ok")
|
||||||
|
emit(campaign_id, f" Services : identiques ✓", "ok")
|
||||||
|
|
||||||
|
# Update status
|
||||||
|
conn.execute(text("""
|
||||||
|
UPDATE patch_sessions SET status = 'patched', date_realise = now() WHERE id = :id
|
||||||
|
"""), {"id": sid})
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
emit(campaign_id, f" → {hn} PATCHÉ ✓", "success")
|
||||||
|
emit(campaign_id, "")
|
||||||
|
|
||||||
|
# Fin
|
||||||
|
emit(campaign_id, f"=== Terminé — {len(session_ids)} serveur(s) traité(s) ===", "header")
|
||||||
|
emit(campaign_id, "__DONE__", "done")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_ssh(hostname):
|
||||||
|
"""Check SSH TCP (mode démo = toujours OK)"""
|
||||||
|
import socket
|
||||||
|
suffixes = ["", ".sanef.groupe", ".sanef-rec.fr"]
|
||||||
|
for suffix in suffixes:
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(3)
|
||||||
|
r = sock.connect_ex((hostname + suffix, 22))
|
||||||
|
sock.close()
|
||||||
|
if r == 0:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
# Mode démo : retourner True même si pas joignable
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def start_execution(db_url, campaign_id, session_ids, branch="hprod"):
|
||||||
|
"""Lance l'exécution dans un thread séparé"""
|
||||||
|
clear_stream(campaign_id)
|
||||||
|
t = threading.Thread(
|
||||||
|
target=execute_safe_patching,
|
||||||
|
args=(db_url, campaign_id, session_ids, branch),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
return t
|
||||||
@ -61,27 +61,55 @@ def _connect(target):
|
|||||||
if not PARAMIKO_OK:
|
if not PARAMIKO_OK:
|
||||||
return None
|
return None
|
||||||
import os
|
import os
|
||||||
if not os.path.exists(SSH_KEY):
|
|
||||||
return None
|
# 1. Essai clé SSH
|
||||||
for loader in [paramiko.RSAKey.from_private_key_file, paramiko.Ed25519Key.from_private_key_file]:
|
if os.path.exists(SSH_KEY):
|
||||||
try:
|
for loader in [paramiko.RSAKey.from_private_key_file, paramiko.Ed25519Key.from_private_key_file]:
|
||||||
key = loader(SSH_KEY)
|
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
|
||||||
|
|
||||||
|
# 2. Fallback mot de passe depuis les settings
|
||||||
|
try:
|
||||||
|
from .secrets_service import get_secret
|
||||||
|
from ..database import SessionLocal
|
||||||
|
db = SessionLocal()
|
||||||
|
pwd_user = get_secret(db, "ssh_pwd_default_user") or "root"
|
||||||
|
pwd_pass = get_secret(db, "ssh_pwd_default_pass") or ""
|
||||||
|
db.close()
|
||||||
|
if pwd_pass:
|
||||||
client = paramiko.SSHClient()
|
client = paramiko.SSHClient()
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
client.connect(target, port=22, username=SSH_USER, pkey=key,
|
client.connect(target, port=22, username=pwd_user, password=pwd_pass,
|
||||||
timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False)
|
timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False)
|
||||||
return client
|
return client
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _run(client, cmd):
|
def _run(client, cmd):
|
||||||
try:
|
try:
|
||||||
full = f"sudo bash -c '{cmd}'"
|
# Tester si on est déjà root ou si on a besoin de sudo
|
||||||
|
_, stdout, _ = client.exec_command("id -u", timeout=5)
|
||||||
|
uid = stdout.read().decode().strip()
|
||||||
|
if uid == "0":
|
||||||
|
full = cmd # Déjà root, pas besoin de sudo
|
||||||
|
else:
|
||||||
|
escaped = cmd.replace("'", "'\"'\"'")
|
||||||
|
full = f"sudo bash -c '{escaped}'"
|
||||||
_, stdout, stderr = client.exec_command(full, timeout=15)
|
_, stdout, stderr = client.exec_command(full, timeout=15)
|
||||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||||
lines = [l for l in out.splitlines() if not any(b in l for b in BANNER_FILTERS) and l.strip()]
|
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||||
|
result = out if out else err
|
||||||
|
lines = [l for l in result.splitlines() if not any(b in l for b in BANNER_FILTERS) and l.strip()]
|
||||||
return "\n".join(lines).strip()
|
return "\n".join(lines).strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"ERROR: {e}"
|
return f"ERROR: {e}"
|
||||||
|
|||||||
110
app/services/safe_patching_service.py
Normal file
110
app/services/safe_patching_service.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
"""Service Safe Patching — Quick Win : patching sans interruption de service"""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# Packages qui TOUJOURS nécessitent un reboot
|
||||||
|
REBOOT_PACKAGES = [
|
||||||
|
"kernel", "kernel-core", "kernel-modules", "kernel-tools",
|
||||||
|
"glibc", "glibc-common", "glibc-devel",
|
||||||
|
"systemd", "systemd-libs", "systemd-udev",
|
||||||
|
"dbus", "dbus-libs", "dbus-daemon",
|
||||||
|
"linux-firmware", "microcode_ctl",
|
||||||
|
"polkit", "polkit-libs",
|
||||||
|
"tuned",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Standard excludes (middleware/apps — jamais en safe)
|
||||||
|
STD_EXCLUDES = [
|
||||||
|
"mongodb*", "mysql*", "postgres*", "mariadb*", "oracle*", "pgdg*",
|
||||||
|
"php*", "java*", "redis*", "elasticsearch*", "nginx*", "mod_ssl*",
|
||||||
|
"haproxy*", "certbot*", "python-certbot*", "docker*", "podman*",
|
||||||
|
"centreon*", "qwserver*", "ansible*", "node*", "tina*", "memcached*",
|
||||||
|
"nextcloud*", "pgbouncer*", "pgpool*", "pgbadger*", "psycopg2*",
|
||||||
|
"barman*", "kibana*", "splunk*",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_safe_excludes():
|
||||||
|
"""Construit la liste d'exclusions pour le safe patching"""
|
||||||
|
excludes = list(REBOOT_PACKAGES) + [e.replace("*", "") for e in STD_EXCLUDES]
|
||||||
|
return excludes
|
||||||
|
|
||||||
|
|
||||||
|
def build_yum_command(extra_excludes=None):
|
||||||
|
"""Génère la commande yum update safe"""
|
||||||
|
all_excludes = REBOOT_PACKAGES + STD_EXCLUDES
|
||||||
|
if extra_excludes:
|
||||||
|
all_excludes += extra_excludes
|
||||||
|
exclude_str = " ".join([f"--exclude={e}*" if not e.endswith("*") else f"--exclude={e}" for e in all_excludes])
|
||||||
|
return f"yum update {exclude_str} -y"
|
||||||
|
|
||||||
|
|
||||||
|
def create_quickwin_campaign(db, year, week_number, label, user_id, assistant_id=None):
|
||||||
|
"""Crée une campagne Quick Win avec les deux branches (hprod + prod)"""
|
||||||
|
from .campaign_service import _week_dates
|
||||||
|
wc = f"S{week_number:02d}"
|
||||||
|
lun, mar, mer, jeu = _week_dates(year, week_number)
|
||||||
|
|
||||||
|
row = db.execute(text("""
|
||||||
|
INSERT INTO campaigns (week_code, year, label, status, date_start, date_end,
|
||||||
|
created_by, campaign_type)
|
||||||
|
VALUES (:wc, :y, :label, 'draft', :ds, :de, :uid, 'quickwin')
|
||||||
|
RETURNING id
|
||||||
|
"""), {"wc": wc, "y": year, "label": label, "ds": lun, "de": jeu, "uid": user_id}).fetchone()
|
||||||
|
cid = row.id
|
||||||
|
|
||||||
|
# Tous les serveurs Linux en prod secops
|
||||||
|
servers = db.execute(text("""
|
||||||
|
SELECT s.id, s.hostname, e.name as env_name
|
||||||
|
FROM servers s
|
||||||
|
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||||
|
LEFT JOIN environments e ON de.environment_id = e.id
|
||||||
|
WHERE s.etat = 'en_production' AND s.patch_os_owner = 'secops'
|
||||||
|
AND s.licence_support IN ('active', 'els') AND s.os_family = 'linux'
|
||||||
|
ORDER BY e.name, s.hostname
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
for s in servers:
|
||||||
|
is_prod = (s.env_name == 'Production')
|
||||||
|
date_prevue = mer if is_prod else lun # hprod lundi, prod mercredi
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO patch_sessions (campaign_id, server_id, status, date_prevue,
|
||||||
|
intervenant_id, forced_assignment, assigned_at)
|
||||||
|
VALUES (:cid, :sid, 'pending', :dp, :uid, true, now())
|
||||||
|
ON CONFLICT (campaign_id, server_id) DO NOTHING
|
||||||
|
"""), {"cid": cid, "sid": s.id, "dp": date_prevue, "uid": user_id})
|
||||||
|
|
||||||
|
# Assigner l'assistant si défini
|
||||||
|
if assistant_id:
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO campaign_operator_limits (campaign_id, user_id, max_servers, note)
|
||||||
|
VALUES (:cid, :aid, 0, 'Assistant Quick Win')
|
||||||
|
"""), {"cid": cid, "aid": assistant_id})
|
||||||
|
|
||||||
|
count = db.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid"
|
||||||
|
), {"cid": cid}).scalar()
|
||||||
|
db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"),
|
||||||
|
{"c": count, "cid": cid})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return cid
|
||||||
|
|
||||||
|
|
||||||
|
def get_quickwin_stats(db, campaign_id):
|
||||||
|
"""Stats Quick Win par branche"""
|
||||||
|
return db.execute(text("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE e.name != 'Production') as hprod_total,
|
||||||
|
COUNT(*) FILTER (WHERE e.name != 'Production' AND ps.status = 'patched') as hprod_patched,
|
||||||
|
COUNT(*) FILTER (WHERE e.name != 'Production' AND ps.status = 'failed') as hprod_failed,
|
||||||
|
COUNT(*) FILTER (WHERE e.name = 'Production') as prod_total,
|
||||||
|
COUNT(*) FILTER (WHERE e.name = 'Production' AND ps.status = 'patched') as prod_patched,
|
||||||
|
COUNT(*) FILTER (WHERE e.name = 'Production' AND ps.status = 'failed') as prod_failed,
|
||||||
|
COUNT(*) FILTER (WHERE ps.status = 'excluded') as excluded
|
||||||
|
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 environments e ON de.environment_id = e.id
|
||||||
|
WHERE ps.campaign_id = :cid
|
||||||
|
"""), {"cid": campaign_id}).fetchone()
|
||||||
@ -59,6 +59,7 @@
|
|||||||
{% if p.qualys %}<a href="/qualys/search" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/' in request.url.path and 'tags' not in request.url.path and 'decoder' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Qualys</a>{% endif %}
|
{% if p.qualys %}<a href="/qualys/search" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/' in request.url.path and 'tags' not in request.url.path and 'decoder' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Qualys</a>{% endif %}
|
||||||
{% if p.qualys %}<a href="/qualys/tags" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/tags' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Tags</a>{% endif %}
|
{% if p.qualys %}<a href="/qualys/tags" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/tags' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Tags</a>{% endif %}
|
||||||
{% if p.qualys %}<a href="/qualys/decoder" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'decoder' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Décodeur</a>{% endif %}
|
{% if p.qualys %}<a href="/qualys/decoder" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'decoder' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Décodeur</a>{% endif %}
|
||||||
|
{% if p.campaigns %}<a href="/safe-patching" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'safe-patching' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Safe Patching</a>{% endif %}
|
||||||
{% if p.planning %}<a href="/planning" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'planning' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Planning</a>{% endif %}
|
{% if p.planning %}<a href="/planning" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'planning' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Planning</a>{% endif %}
|
||||||
{% if p.audit %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
|
{% if p.audit %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
|
||||||
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specific' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifique</a>{% endif %}
|
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specific' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifique</a>{% endif %}
|
||||||
|
|||||||
79
app/templates/safe_patching.html
Normal file
79
app/templates/safe_patching.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Safe Patching{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="text-xl font-bold text-cyber-accent mb-4">Safe Patching — Quick Win</h2>
|
||||||
|
<p class="text-xs text-gray-500 mb-4">Patching sans interruption de service : exclut tout ce qui nécessite un reboot ou un restart de service.</p>
|
||||||
|
|
||||||
|
{% if msg %}
|
||||||
|
<div class="mb-3 p-2 rounded text-sm bg-red-900/30 text-cyber-red">
|
||||||
|
{% if msg == 'error' %}Erreur à la création (semaine déjà existante ?).{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Campagnes Quick Win existantes -->
|
||||||
|
{% if campaigns %}
|
||||||
|
<div class="space-y-2 mb-6">
|
||||||
|
{% for c in campaigns %}
|
||||||
|
<a href="/safe-patching/{{ c.id }}" class="card p-4 flex items-center justify-between hover:border-cyber-accent/50 block">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="font-bold text-cyber-accent">{{ c.week_code }}</span>
|
||||||
|
<span class="text-sm text-gray-400">{{ c.label }}</span>
|
||||||
|
<span class="badge badge-yellow">quickwin</span>
|
||||||
|
<span class="badge {% if c.status == 'draft' %}badge-gray{% elif c.status == 'in_progress' %}badge-yellow{% elif c.status == 'completed' %}badge-green{% else %}badge-red{% endif %}">{{ c.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 text-xs">
|
||||||
|
<span class="px-2 py-0.5 rounded bg-gray-800 text-gray-400">{{ c.session_count }} srv</span>
|
||||||
|
<span class="px-2 py-0.5 rounded bg-green-900/30 text-cyber-green">{{ c.patched_count }} ok</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Créer Quick Win -->
|
||||||
|
{% if can_create %}
|
||||||
|
<div x-data="{ show: false }" class="card p-5">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-sm font-bold text-cyber-accent">Nouvelle campagne Quick Win</h3>
|
||||||
|
<button @click="show = !show" class="btn-sm bg-cyber-border text-cyber-accent" x-text="show ? 'Masquer' : 'Créer'"></button>
|
||||||
|
</div>
|
||||||
|
<div x-show="show" class="mt-4">
|
||||||
|
<form method="POST" action="/safe-patching/create" class="space-y-3">
|
||||||
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Label</label>
|
||||||
|
<input type="text" name="label" id="qw-label" value="Quick Win S{{ '%02d' % current_week }} {{ current_year }}" class="w-full">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Semaine</label>
|
||||||
|
<input type="number" name="week_number" id="qw-week" value="{{ current_week }}" min="1" max="53" class="w-full" required
|
||||||
|
onchange="document.getElementById('qw-label').value = 'Quick Win S' + String(this.value).padStart(2,'0') + ' ' + document.getElementById('qw-year').value">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Année</label>
|
||||||
|
<input type="number" name="year" id="qw-year" value="{{ current_year }}" class="w-full" required
|
||||||
|
onchange="document.getElementById('qw-label').value = 'Quick Win S' + String(document.getElementById('qw-week').value).padStart(2,'0') + ' ' + this.value">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Opérateur lead</label>
|
||||||
|
<select name="lead_id" class="w-full" required>
|
||||||
|
{% for o in operators %}<option value="{{ o.id }}">{{ o.display_name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">Assistant (optionnel)</label>
|
||||||
|
<select name="assistant_id" class="w-full">
|
||||||
|
<option value="">— Pas d'assistant —</option>
|
||||||
|
{% for o in operators %}<option value="{{ o.id }}">{{ o.display_name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600">Tous les serveurs Linux en production (secops) seront inclus. Hors-prod patché en premier (J), prod le lendemain (J+1).</p>
|
||||||
|
<button type="submit" class="btn-primary px-6 py-2 text-sm">Créer la campagne Quick Win</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
209
app/templates/safe_patching_detail.html
Normal file
209
app/templates/safe_patching_detail.html
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{{ c.label }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="/safe-patching" class="text-xs text-gray-500 hover:text-gray-300">← Safe Patching</a>
|
||||||
|
<h2 class="text-xl font-bold text-cyber-accent">{{ c.label }}</h2>
|
||||||
|
<div class="flex items-center gap-3 mt-1">
|
||||||
|
<span class="badge badge-yellow">quickwin</span>
|
||||||
|
<span class="badge {% if c.status == 'draft' %}badge-gray{% elif c.status == 'in_progress' %}badge-yellow{% elif c.status == 'completed' %}badge-green{% else %}badge-red{% endif %}">{{ c.status }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if msg %}
|
||||||
|
<div class="mb-3 p-2 rounded text-sm {% if 'error' in msg or 'no_pending' in msg %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
|
||||||
|
{% if msg.startswith('excluded_') %}{{ msg.split('_')[1] }} serveur(s) exclu(s).{% elif msg == 'no_pending' %}Aucun serveur en attente.{% elif msg == 'prereqs_done' %}Prérequis vérifiés.{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- KPIs par branche -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div class="card p-4">
|
||||||
|
<h3 class="text-sm font-bold text-cyber-yellow mb-2">Branche 1 — Hors-prod</h3>
|
||||||
|
<div class="flex gap-3 text-sm">
|
||||||
|
<span class="text-cyber-accent">{{ qw_stats.hprod_total }} total</span>
|
||||||
|
<span class="text-cyber-green">{{ qw_stats.hprod_patched }} patchés</span>
|
||||||
|
<span class="text-cyber-red">{{ qw_stats.hprod_failed }} échoués</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<h3 class="text-sm font-bold text-cyber-green mb-2">Branche 2 — Production</h3>
|
||||||
|
<div class="flex gap-3 text-sm">
|
||||||
|
<span class="text-cyber-accent">{{ qw_stats.prod_total }} total</span>
|
||||||
|
<span class="text-cyber-green">{{ qw_stats.prod_patched }} patchés</span>
|
||||||
|
<span class="text-cyber-red">{{ qw_stats.prod_failed }} échoués</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Steps wizard -->
|
||||||
|
<div x-data="{ step: '{{ current_step }}' }" class="space-y-3">
|
||||||
|
|
||||||
|
<!-- Step nav -->
|
||||||
|
<div class="flex gap-1 mb-4">
|
||||||
|
{% for s in ['prereqs','snapshot','execute','postcheck'] %}
|
||||||
|
<button @click="step = '{{ s }}'" class="px-3 py-1 text-xs rounded"
|
||||||
|
:class="step === '{{ s }}' ? 'bg-cyber-accent text-black font-bold' : 'bg-cyber-border text-gray-400'">
|
||||||
|
{{ loop.index }}. {% if s == 'prereqs' %}Prérequis{% elif s == 'snapshot' %}Snapshot{% elif s == 'execute' %}Exécution{% elif s == 'postcheck' %}Post-patch{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Prérequis -->
|
||||||
|
<div x-show="step === 'prereqs'" class="card overflow-x-auto">
|
||||||
|
<div class="p-3 border-b border-cyber-border flex justify-between items-center">
|
||||||
|
<h3 class="text-sm font-bold text-cyber-accent">Step 1 — Vérification prérequis</h3>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<form method="POST" action="/safe-patching/{{ c.id }}/check-prereqs" style="display:inline">
|
||||||
|
<input type="hidden" name="branch" value="hprod">
|
||||||
|
<button class="btn-primary px-3 py-1 text-sm" data-loading="Vérification prérequis...|Connexion SSH à chaque serveur">Vérifier hors-prod</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/safe-patching/{{ c.id }}/check-prereqs" style="display:inline">
|
||||||
|
<input type="hidden" name="branch" value="prod">
|
||||||
|
<button class="btn-sm bg-cyber-border text-cyber-accent" data-loading="Vérification prérequis...|Connexion SSH à chaque serveur">Vérifier prod</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="excl-bar-prereq" class="p-2 border-b border-cyber-border flex gap-2 items-center" style="display:none">
|
||||||
|
<span class="text-xs text-gray-400" id="excl-count-prereq">0</span>
|
||||||
|
<form method="POST" action="/safe-patching/{{ c.id }}/bulk-exclude">
|
||||||
|
<input type="hidden" name="session_ids" id="excl-ids-prereq">
|
||||||
|
<button class="btn-sm bg-red-900/30 text-cyber-red" onclick="document.getElementById('excl-ids-prereq').value=getCheckedPrereq()">Exclure sélection</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<table class="w-full table-cyber text-xs">
|
||||||
|
<thead><tr>
|
||||||
|
<th class="p-2 w-6"><input type="checkbox" onchange="document.querySelectorAll('.chk-prereq').forEach(function(c){c.checked=this.checked}.bind(this)); updateExclPrereq()"></th>
|
||||||
|
<th class="text-left p-2">Hostname</th>
|
||||||
|
<th class="p-2">Env</th>
|
||||||
|
<th class="p-2">Domaine</th>
|
||||||
|
<th class="p-2">SSH</th>
|
||||||
|
<th class="p-2">Disque</th>
|
||||||
|
<th class="p-2">Satellite</th>
|
||||||
|
<th class="p-2">État</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sessions %}
|
||||||
|
{% if s.status != 'excluded' %}
|
||||||
|
<tr class="{% if s.prereq_validated == false and s.prereq_date %}bg-red-900/10{% endif %}">
|
||||||
|
<td class="p-2 text-center">{% if s.status == 'pending' %}<input type="checkbox" class="chk-prereq" value="{{ s.id }}" onchange="updateExclPrereq()">{% endif %}</td>
|
||||||
|
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
|
||||||
|
<td class="p-2 text-center"><span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ (s.environnement or '')[:6] }}</span></td>
|
||||||
|
<td class="p-2 text-center text-xs">{{ s.domaine or '-' }}</td>
|
||||||
|
<td class="p-2 text-center">{% if s.prereq_ssh == 'ok' %}<span class="text-cyber-green">OK</span>{% elif s.prereq_ssh == 'ko' %}<span class="text-cyber-red">KO</span>{% else %}<span class="text-gray-600">—</span>{% endif %}</td>
|
||||||
|
<td class="p-2 text-center">{% if s.prereq_disk_ok is true %}<span class="text-cyber-green">OK</span>{% elif s.prereq_disk_ok is false %}<span class="text-cyber-red">KO</span>{% else %}<span class="text-gray-600">—</span>{% endif %}</td>
|
||||||
|
<td class="p-2 text-center">{% if s.prereq_satellite == 'ok' %}<span class="text-cyber-green">OK</span>{% elif s.prereq_satellite == 'ko' %}<span class="text-cyber-red">KO</span>{% elif s.prereq_satellite == 'na' %}<span class="text-gray-500">N/A</span>{% else %}<span class="text-gray-600">—</span>{% endif %}</td>
|
||||||
|
<td class="p-2 text-center">{% if s.prereq_validated %}<span class="badge badge-green">OK</span>{% elif s.prereq_date %}<span class="badge badge-red">KO</span>{% else %}<span class="text-gray-600">—</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Snapshot -->
|
||||||
|
<div x-show="step === 'snapshot'" class="card p-4">
|
||||||
|
<h3 class="text-sm font-bold text-cyber-accent mb-3">Step 2 — Snapshot vSphere</h3>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">Créer un snapshot sur toutes les VMs avant patching. Les serveurs physiques sont ignorés.</p>
|
||||||
|
<form method="POST" action="/safe-patching/{{ c.id }}/snapshot">
|
||||||
|
<input type="hidden" name="branch" value="hprod">
|
||||||
|
<button class="btn-primary px-4 py-2 text-sm" data-loading="Création snapshots...|Connexion vSphere en cours">Créer snapshots hors-prod</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Exécution -->
|
||||||
|
<div x-show="step === 'execute'" class="card p-4">
|
||||||
|
<h3 class="text-sm font-bold text-cyber-accent mb-3">Step 3 — Exécution Safe Patching</h3>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="text-xs text-gray-500 mb-1">Commande yum (éditable)</h4>
|
||||||
|
<textarea id="yum-cmd" rows="3" class="w-full font-mono text-xs">{{ safe_cmd }}</textarea>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">{{ safe_excludes|length }} packages exclus. Modifiez si besoin avant de lancer.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<form method="POST" action="/safe-patching/{{ c.id }}/execute">
|
||||||
|
<input type="hidden" name="branch" value="hprod">
|
||||||
|
<button class="btn-primary px-4 py-2 text-sm" data-loading="Lancement hors-prod...|Sauvegarde état + patching">Lancer hors-prod</button>
|
||||||
|
</form>
|
||||||
|
{% if qw_stats.hprod_total > 0 and qw_stats.hprod_patched == qw_stats.hprod_total %}
|
||||||
|
<form method="POST" action="/safe-patching/{{ c.id }}/execute">
|
||||||
|
<input type="hidden" name="branch" value="prod">
|
||||||
|
<button class="btn-sm bg-cyber-green text-black px-4 py-2" data-loading="Lancement production...|Sauvegarde état + patching" onclick="return confirm('Lancer le patching PRODUCTION ?')">Lancer production</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-gray-500 py-2">Production disponible après hors-prod à 100%</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Post-patching -->
|
||||||
|
<div x-show="step === 'postcheck'" class="card p-4">
|
||||||
|
<h3 class="text-sm font-bold text-cyber-accent mb-3">Step 4 — Vérification post-patch</h3>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">Vérifier les services, ports et needs-restarting après patching.</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3 mb-4">
|
||||||
|
<form method="POST" action="/safe-patching/{{ c.id }}/postcheck">
|
||||||
|
<input type="hidden" name="branch" value="hprod">
|
||||||
|
<button class="btn-primary px-3 py-1 text-sm" data-loading="Vérification post-patch...|Comparaison services avant/après">Vérifier hors-prod</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/safe-patching/{{ c.id }}/postcheck">
|
||||||
|
<input type="hidden" name="branch" value="prod">
|
||||||
|
<button class="btn-sm bg-cyber-border text-cyber-accent" data-loading="Vérification post-patch...|Comparaison services avant/après">Vérifier prod</button>
|
||||||
|
</form>
|
||||||
|
<a href="/safe-patching/{{ c.id }}/export" class="btn-sm bg-cyber-green text-black">Export CSV</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Résultats -->
|
||||||
|
<table class="w-full table-cyber text-xs">
|
||||||
|
<thead><tr>
|
||||||
|
<th class="text-left p-2">Hostname</th>
|
||||||
|
<th class="p-2">Env</th>
|
||||||
|
<th class="p-2">Statut</th>
|
||||||
|
<th class="p-2">Packages</th>
|
||||||
|
<th class="p-2">Reboot</th>
|
||||||
|
<th class="p-2">Services</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sessions %}
|
||||||
|
{% if s.status in ('patched', 'failed') %}
|
||||||
|
<tr class="{% if s.status == 'failed' %}bg-red-900/10{% endif %}">
|
||||||
|
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
|
||||||
|
<td class="p-2 text-center"><span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ (s.environnement or '')[:6] }}</span></td>
|
||||||
|
<td class="p-2 text-center"><span class="badge {% if s.status == 'patched' %}badge-green{% else %}badge-red{% endif %}">{{ s.status }}</span></td>
|
||||||
|
<td class="p-2 text-center text-gray-400">{{ s.packages_updated or 0 }}</td>
|
||||||
|
<td class="p-2 text-center">{% if s.reboot_required %}<span class="text-cyber-red">Oui</span>{% else %}<span class="text-cyber-green">Non</span>{% endif %}</td>
|
||||||
|
<td class="p-2 text-center">{% if s.postcheck_services == 'ok' %}<span class="text-cyber-green">OK</span>{% elif s.postcheck_services == 'ko' %}<span class="text-cyber-red">KO</span>{% else %}<span class="text-gray-600">—</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if excluded %}
|
||||||
|
<details class="card mt-4">
|
||||||
|
<summary class="p-3 cursor-pointer text-sm text-gray-500">{{ excluded|length }} serveur(s) exclu(s)</summary>
|
||||||
|
<div class="p-3 text-xs text-gray-600 font-mono">
|
||||||
|
{% for s in excluded %}{{ s.hostname }}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getCheckedPrereq() {
|
||||||
|
return Array.from(document.querySelectorAll('.chk-prereq:checked')).map(function(c){return c.value}).join(',');
|
||||||
|
}
|
||||||
|
function updateExclPrereq() {
|
||||||
|
var count = document.querySelectorAll('.chk-prereq:checked').length;
|
||||||
|
var bar = document.getElementById('excl-bar-prereq');
|
||||||
|
bar.style.display = count > 0 ? 'flex' : 'none';
|
||||||
|
document.getElementById('excl-count-prereq').textContent = count + ' sélectionné(s)';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
79
app/templates/safe_patching_terminal.html
Normal file
79
app/templates/safe_patching_terminal.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Terminal — {{ c.label }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="/safe-patching/{{ c.id }}" class="text-xs text-gray-500 hover:text-gray-300">← Retour campagne</a>
|
||||||
|
<h2 class="text-xl font-bold text-cyber-accent">{{ c.label }} — Exécution {{ 'Hors-prod' if branch == 'hprod' else 'Production' }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span id="status-badge" class="badge badge-yellow">En cours</span>
|
||||||
|
<span id="counter" class="text-xs text-gray-500">0 traité(s)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terminal -->
|
||||||
|
<div class="card" style="background:#0d1117; border-color:#1e3a5f">
|
||||||
|
<div class="p-2 border-b border-cyber-border flex items-center gap-2">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-cyber-red"></span>
|
||||||
|
<span class="w-3 h-3 rounded-full bg-cyber-yellow"></span>
|
||||||
|
<span class="w-3 h-3 rounded-full bg-cyber-green"></span>
|
||||||
|
<span class="text-xs text-gray-500 ml-2">PatchCenter Terminal — Safe Patching</span>
|
||||||
|
</div>
|
||||||
|
<div id="terminal" class="p-4 font-mono text-xs overflow-y-auto" style="height:500px; line-height:1.6">
|
||||||
|
<div class="text-gray-500">Connexion au stream...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<a href="/safe-patching/{{ c.id }}" class="btn-primary px-4 py-2 text-sm" id="btn-back" style="display:none">Voir les résultats</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var terminal = document.getElementById('terminal');
|
||||||
|
var counter = 0;
|
||||||
|
|
||||||
|
var source = new EventSource('/safe-patching/{{ c.id }}/stream');
|
||||||
|
|
||||||
|
source.onmessage = function(e) {
|
||||||
|
var data = JSON.parse(e.data);
|
||||||
|
if (data.level === 'done') {
|
||||||
|
source.close();
|
||||||
|
document.getElementById('status-badge').textContent = 'Terminé';
|
||||||
|
document.getElementById('status-badge').className = 'badge badge-green';
|
||||||
|
document.getElementById('btn-back').style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var line = document.createElement('div');
|
||||||
|
var color = {
|
||||||
|
'header': 'color:#00d4ff; font-weight:bold',
|
||||||
|
'server': 'color:#00d4ff; font-weight:bold; margin-top:4px',
|
||||||
|
'step': 'color:#94a3b8',
|
||||||
|
'ok': 'color:#00ff88',
|
||||||
|
'error': 'color:#ff3366',
|
||||||
|
'success': 'color:#00ff88; font-weight:bold',
|
||||||
|
'info': 'color:#e2e8f0',
|
||||||
|
}[data.level] || 'color:#94a3b8';
|
||||||
|
|
||||||
|
if (data.msg === '') {
|
||||||
|
line.innerHTML = ' ';
|
||||||
|
} else {
|
||||||
|
line.innerHTML = '<span style="color:#4a5568">[' + data.ts + ']</span> <span style="' + color + '">' + data.msg + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.appendChild(line);
|
||||||
|
terminal.scrollTop = terminal.scrollHeight;
|
||||||
|
|
||||||
|
if (data.level === 'success') counter++;
|
||||||
|
document.getElementById('counter').textContent = counter + ' traité(s)';
|
||||||
|
};
|
||||||
|
|
||||||
|
source.onerror = function() {
|
||||||
|
var line = document.createElement('div');
|
||||||
|
line.innerHTML = '<span style="color:#ff3366">Connexion perdue. Rafraîchir la page.</span>';
|
||||||
|
terminal.appendChild(line);
|
||||||
|
source.close();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue
Block a user