diff --git a/app/routers/audit_full.py b/app/routers/audit_full.py
deleted file mode 100644
index 795821f..0000000
--- a/app/routers/audit_full.py
+++ /dev/null
@@ -1,703 +0,0 @@
-"""Router Audit Complet — import JSON, liste, detail, carte flux, carte applicative"""
-import json
-from fastapi import APIRouter, Request, Depends, UploadFile, File
-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.server_audit_full_service import (
- import_json_report, get_latest_audits, get_audit_detail,
- get_flow_map, get_flow_map_for_server, get_app_map,
-)
-from ..config import APP_NAME
-
-router = APIRouter()
-templates = Jinja2Templates(directory="app/templates")
-
-
-@router.get("/audit-full", response_class=HTMLResponse)
-async def audit_full_list(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, "audit"):
- return RedirectResponse(url="/dashboard")
-
- filtre = request.query_params.get("filter", "")
- search = request.query_params.get("q", "").strip()
- domain = request.query_params.get("domain", "")
- page = int(request.query_params.get("page", "1"))
- per_page = 20
-
- # KPIs (toujours sur tout le jeu)
- kpis = db.execute(text("""
- SELECT
- COUNT(*) as total,
- COUNT(*) FILTER (WHERE reboot_required = true) as needs_reboot,
- COUNT(*) FILTER (WHERE EXISTS (
- SELECT 1 FROM jsonb_array_elements(disk_usage) d
- WHERE (d->>'pct')::int >= 90
- )) as disk_critical,
- COUNT(*) FILTER (WHERE EXISTS (
- SELECT 1 FROM jsonb_array_elements(disk_usage) d
- WHERE (d->>'pct')::int >= 80 AND (d->>'pct')::int < 90
- )) as disk_warning,
- COUNT(*) FILTER (WHERE
- uptime LIKE '%month%' OR uptime LIKE '%year%'
- OR (uptime LIKE '%week%' AND (
- CASE WHEN uptime ~ '(\d+) week' THEN (substring(uptime from '(\d+) week'))::int ELSE 0 END >= 17
- ))
- ) as uptime_long,
- COUNT(*) FILTER (WHERE services::text ~* 'postgres') as app_postgres,
- COUNT(*) FILTER (WHERE services::text ~* 'mariadb|mysqld') as app_mariadb,
- COUNT(*) FILTER (WHERE services::text ~* 'hdb|sapstart|HANA') as app_hana,
- COUNT(*) FILTER (WHERE services::text ~* 'oracle|ora_pmon' OR processes::text ~* 'ora_pmon|oracle') as app_oracle,
- COUNT(*) FILTER (WHERE services::text ~* '"httpd"' OR listen_ports::text ~* '"httpd"') as app_httpd,
- COUNT(*) FILTER (WHERE services::text ~* '"nginx"' OR listen_ports::text ~* '"nginx"') as app_nginx,
- COUNT(*) FILTER (WHERE services::text ~* 'haproxy') as app_haproxy,
- COUNT(*) FILTER (WHERE services::text ~* 'tomcat' OR processes::text ~* 'tomcat|catalina') as app_tomcat,
- COUNT(*) FILTER (WHERE listen_ports::text ~* '"node"' OR processes::text ~* '/applis.*node') as app_nodejs,
- COUNT(*) FILTER (WHERE services::text ~* 'redis') as app_redis,
- COUNT(*) FILTER (WHERE services::text ~* 'mongod') as app_mongodb,
- COUNT(*) FILTER (WHERE services::text ~* 'elasticsearch|opensearch') as app_elastic,
- COUNT(*) FILTER (WHERE services::text ~* 'docker|podman' OR processes::text ~* 'dockerd|podman') as app_container,
- COUNT(*) FILTER (WHERE listen_ports::text ~* '"java"' OR processes::text ~* '\.jar') as app_java
- FROM server_audit_full
- WHERE status IN ('ok','partial')
- AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
- """)).fetchone()
-
- # Domaines + zones pour le filtre
- all_domains = db.execute(text(
- "SELECT code, name, 'domain' as type FROM domains ORDER BY name"
- )).fetchall()
- all_zones = db.execute(text(
- "SELECT name as code, name, 'zone' as type FROM zones ORDER BY name"
- )).fetchall()
-
- # Requete avec filtres
- audits = get_latest_audits(db, limit=9999)
-
- # Filtre KPI
- if filtre == "reboot":
- audits = [a for a in audits if a.reboot_required]
- elif filtre == "disk_critical":
- ids = {r.id for r in db.execute(text("""
- SELECT saf.id FROM server_audit_full saf
- WHERE saf.status IN ('ok','partial') AND EXISTS (
- SELECT 1 FROM jsonb_array_elements(saf.disk_usage) d WHERE (d->>'pct')::int >= 90
- ) AND saf.id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
- """)).fetchall()}
- audits = [a for a in audits if a.id in ids]
- elif filtre == "disk_warning":
- ids = {r.id for r in db.execute(text("""
- SELECT saf.id FROM server_audit_full saf
- WHERE saf.status IN ('ok','partial') AND EXISTS (
- SELECT 1 FROM jsonb_array_elements(saf.disk_usage) d WHERE (d->>'pct')::int >= 80
- ) AND saf.id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
- """)).fetchall()}
- audits = [a for a in audits if a.id in ids]
- elif filtre == "uptime":
- audits = [a for a in audits if a.uptime and ("month" in a.uptime or "year" in a.uptime)]
- elif filtre and filtre.startswith("app_"):
- # Filtre applicatif generique
- app_patterns = {
- "app_postgres": "postgres",
- "app_mariadb": "mariadb|mysqld",
- "app_hana": "hdb|sapstart|HANA",
- "app_oracle": "ora_pmon|oracle",
- "app_httpd": "httpd",
- "app_nginx": "nginx",
- "app_haproxy": "haproxy",
- "app_tomcat": "tomcat|catalina",
- "app_nodejs": "node",
- "app_redis": "redis",
- "app_mongodb": "mongod",
- "app_elastic": "elasticsearch|opensearch",
- "app_container": "docker|podman",
- "app_java": "java|\\.jar",
- }
- pattern = app_patterns.get(filtre, "")
- if pattern:
- ids = {r.id for r in db.execute(text("""
- SELECT id FROM server_audit_full
- WHERE status IN ('ok','partial')
- AND (services::text ~* :pat OR listen_ports::text ~* :pat OR processes::text ~* :pat)
- AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
- """), {"pat": pattern}).fetchall()}
- audits = [a for a in audits if a.id in ids]
-
- # Filtre domaine ou zone
- if domain:
- # D'abord chercher comme zone
- zone_servers = {r.hostname for r in db.execute(text("""
- SELECT s.hostname FROM servers s
- JOIN zones z ON s.zone_id = z.id
- WHERE z.name = :name
- """), {"name": domain}).fetchall()}
- if zone_servers:
- audits = [a for a in audits if a.hostname in zone_servers]
- else:
- # Sinon chercher comme domaine
- domain_servers = {r.hostname for r in db.execute(text("""
- SELECT s.hostname FROM servers s
- JOIN domain_environments de ON s.domain_env_id = de.id
- JOIN domains d ON de.domain_id = d.id
- WHERE d.code = :dc
- """), {"dc": domain}).fetchall()}
- audits = [a for a in audits if a.hostname in domain_servers]
-
- # Recherche hostname
- if search:
- q = search.lower()
- audits = [a for a in audits if q in a.hostname.lower()]
-
- # Tri
- sort = request.query_params.get("sort", "hostname")
- sort_dir = request.query_params.get("dir", "asc")
- if sort == "hostname":
- audits.sort(key=lambda a: a.hostname.lower(), reverse=(sort_dir == "desc"))
- elif sort == "uptime":
- def uptime_days(a):
- u = a.uptime or ""
- d = 0
- import re as _re
- m = _re.search(r"(\d+) year", u)
- if m: d += int(m.group(1)) * 365
- m = _re.search(r"(\d+) month", u)
- if m: d += int(m.group(1)) * 30
- m = _re.search(r"(\d+) week", u)
- if m: d += int(m.group(1)) * 7
- m = _re.search(r"(\d+) day", u)
- if m: d += int(m.group(1))
- return d
- audits.sort(key=uptime_days, reverse=(sort_dir == "desc"))
- elif sort == "reboot":
- audits.sort(key=lambda a: (1 if a.reboot_required else 0), reverse=(sort_dir == "desc"))
- elif sort == "patch":
- def patch_sort_key(a):
- if a.last_patch_date:
- return a.last_patch_date
- elif a.last_patch_year and a.last_patch_week:
- return f"{a.last_patch_year}-{a.last_patch_week}"
- return ""
- audits.sort(key=patch_sort_key, reverse=(sort_dir == "desc"))
-
- # Pagination
- total_filtered = len(audits)
- total_pages = max(1, (total_filtered + per_page - 1) // per_page)
- page = max(1, min(page, total_pages))
- audits_page = audits[(page - 1) * per_page : page * per_page]
-
- ctx = base_context(request, db, user)
- ctx.update({
- "app_name": APP_NAME, "audits": audits_page, "kpis": kpis,
- "filter": filtre, "search": search, "domain": domain,
- "all_domains": all_domains, "all_zones": all_zones,
- "sort": sort, "sort_dir": sort_dir,
- "page": page, "total_pages": total_pages, "total_filtered": total_filtered,
- "msg": request.query_params.get("msg"),
- })
- return templates.TemplateResponse("audit_full_list.html", ctx)
-
-
-@router.post("/audit-full/import")
-async def audit_full_import(request: Request, db=Depends(get_db),
- file: UploadFile = File(...)):
- user = get_current_user(request)
- if not user:
- return RedirectResponse(url="/login")
- perms = get_user_perms(db, user)
- if not can_edit(perms, "audit"):
- return RedirectResponse(url="/audit-full")
-
- try:
- content = await file.read()
- json_data = json.loads(content.decode("utf-8-sig"))
- imported, errors = import_json_report(db, json_data)
- return RedirectResponse(
- url=f"/audit-full?msg=imported_{imported}_{errors}",
- status_code=303,
- )
- except Exception as e:
- return RedirectResponse(
- url=f"/audit-full?msg=error_{str(e)[:50]}",
- status_code=303,
- )
-
-
-@router.get("/audit-full/patching", response_class=HTMLResponse)
-async def audit_full_patching(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, "audit"):
- return RedirectResponse(url="/dashboard")
-
- year = int(request.query_params.get("year", "2026"))
- search = request.query_params.get("q", "").strip()
- domain = request.query_params.get("domain", "")
- scope = request.query_params.get("scope", "") # secops, other, ou vide=tout
- page = int(request.query_params.get("page", "1"))
- sort = request.query_params.get("sort", "hostname")
- sort_dir = request.query_params.get("dir", "asc")
- per_page = 30
-
- yr_count = "patch_count_2026" if year == 2026 else "patch_count_2025"
- yr_weeks = "patch_weeks_2026" if year == 2026 else "patch_weeks_2025"
-
- # KPIs globaux + secops/autre
- _latest = "id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)"
- kpis = db.execute(text(
- f"SELECT COUNT(*) as total,"
- f" COUNT(*) FILTER (WHERE {yr_count} >= 1) as patched,"
- f" COUNT(*) FILTER (WHERE {yr_count} = 1) as once,"
- f" COUNT(*) FILTER (WHERE {yr_count} >= 2) as twice,"
- f" COUNT(*) FILTER (WHERE {yr_count} >= 3) as thrice,"
- f" COUNT(*) FILTER (WHERE {yr_count} = 0 OR {yr_count} IS NULL) as never"
- f" FROM server_audit_full WHERE status IN ('ok','partial') AND {_latest}"
- )).fetchone()
-
- kpis_secops = db.execute(text(
- f"SELECT COUNT(*) as total,"
- f" COUNT(*) FILTER (WHERE saf.{yr_count} >= 1) as patched,"
- f" COUNT(*) FILTER (WHERE saf.{yr_count} = 0 OR saf.{yr_count} IS NULL) as never"
- f" FROM server_audit_full saf JOIN servers s ON saf.server_id = s.id"
- f" WHERE saf.status IN ('ok','partial') AND s.patch_os_owner = 'secops'"
- f" AND saf.{_latest}"
- )).fetchone()
-
- kpis_other = db.execute(text(
- f"SELECT COUNT(*) as total,"
- f" COUNT(*) FILTER (WHERE saf.{yr_count} >= 1) as patched,"
- f" COUNT(*) FILTER (WHERE saf.{yr_count} = 0 OR saf.{yr_count} IS NULL) as never"
- f" FROM server_audit_full saf JOIN servers s ON saf.server_id = s.id"
- f" WHERE saf.status IN ('ok','partial') AND (s.patch_os_owner != 'secops' OR s.patch_os_owner IS NULL)"
- f" AND saf.{_latest}"
- )).fetchone()
-
- # Comparaison Y-1 a meme semaine
- compare = None
- from datetime import datetime as _dt
- current_week = _dt.now().isocalendar()[1]
- if year == 2026:
- # Cumulatif 2025 a la meme semaine (pre-calcule)
- import json as _json, os as _os
- cumul_2025_path = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "..", "data", "cumul_2025_by_week.json")
- prev_at_same_week = 0
- prev_total = 1045
- prev_data_ok = False
- try:
- with open(cumul_2025_path) as f:
- cumul_2025 = _json.load(f)
- prev_at_same_week = cumul_2025.get(str(current_week - 1), cumul_2025.get(str(current_week), 0))
- prev_data_ok = True
- except Exception:
- pass
-
- compare = db.execute(text(
- f"SELECT"
- f" COUNT(*) FILTER (WHERE patch_count_2026 >= 1) as current_patched,"
- f" COUNT(*) FILTER (WHERE patch_count_2025 >= 1) as prev_year_total,"
- f" COUNT(*) as total"
- f" FROM server_audit_full WHERE status IN ('ok','partial') AND {_latest}"
- )).fetchone()
- compare = {
- "current_patched": compare.current_patched,
- "current_total": compare.total,
- "prev_year_total": compare.prev_year_total,
- "prev_at_same_week": prev_at_same_week,
- "prev_total": prev_total,
- "prev_data_ok": prev_data_ok,
- "compare_week": current_week - 1,
- }
-
- patch_by_domain = db.execute(text(
- f"SELECT d.name as domain, d.code,"
- f" COUNT(DISTINCT saf.hostname) as total,"
- f" COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.{yr_count} >= 1) as patched,"
- f" COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.{yr_count} >= 2) as twice,"
- f" COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.{yr_count} = 0 OR saf.{yr_count} IS NULL) as never"
- f" FROM server_audit_full saf JOIN servers s ON saf.server_id = s.id"
- f" JOIN domain_environments de ON s.domain_env_id = de.id JOIN domains d ON de.domain_id = d.id"
- f" WHERE saf.status IN ('ok','partial')"
- f" AND saf.id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)"
- f" GROUP BY d.name, d.code, d.display_order ORDER BY d.display_order"
- )).fetchall()
-
- patch_weekly = []
- if year == 2026:
- patch_weekly = db.execute(text("""
- SELECT week, SUM(patched)::int as patched, SUM(cancelled)::int as cancelled FROM (
- SELECT unnest(string_to_array(patch_weeks_2026, ',')) as week, 1 as patched, 0 as cancelled
- FROM server_audit_full
- WHERE status IN ('ok','partial') AND patch_weeks_2026 IS NOT NULL AND patch_weeks_2026 != ''
- AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
- UNION ALL
- SELECT unnest(string_to_array(cancelled_weeks_2026, ',')) as week, 0 as patched, 1 as cancelled
- FROM server_audit_full
- WHERE status IN ('ok','partial') AND cancelled_weeks_2026 IS NOT NULL AND cancelled_weeks_2026 != ''
- AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
- ) combined WHERE week != '' GROUP BY week ORDER BY week
- """)).fetchall()
-
- all_domains = db.execute(text("SELECT code, name, 'domain' as type FROM domains ORDER BY name")).fetchall()
- all_zones = db.execute(text("SELECT name as code, name, 'zone' as type FROM zones ORDER BY name")).fetchall()
-
- servers = db.execute(text(
- f"SELECT DISTINCT ON (saf.hostname) saf.id, saf.hostname, saf.os_release,"
- f" saf.last_patch_date, saf.last_patch_week, saf.last_patch_year,"
- f" saf.{yr_count} as patch_count, saf.{yr_weeks} as patch_weeks,"
- f" d.name as domain, e.name as env, z.name as zone"
- f" FROM server_audit_full saf"
- f" LEFT JOIN servers s ON saf.server_id = s.id"
- f" LEFT JOIN domain_environments de ON s.domain_env_id = de.id"
- f" LEFT JOIN domains d ON de.domain_id = d.id"
- f" LEFT JOIN environments e ON de.environment_id = e.id"
- f" LEFT JOIN zones z ON s.zone_id = z.id"
- f" WHERE saf.status IN ('ok','partial')"
- f" ORDER BY saf.hostname, saf.audit_date DESC"
- )).fetchall()
-
- if domain:
- zone_hosts = {r.hostname for r in db.execute(text(
- "SELECT s.hostname FROM servers s JOIN zones z ON s.zone_id = z.id WHERE z.name = :n"
- ), {"n": domain}).fetchall()}
- if zone_hosts:
- servers = [s for s in servers if s.hostname in zone_hosts]
- else:
- dom_hosts = {r.hostname for r in db.execute(text(
- "SELECT s.hostname FROM servers s JOIN domain_environments de ON s.domain_env_id = de.id"
- " JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc"
- ), {"dc": domain}).fetchall()}
- servers = [s for s in servers if s.hostname in dom_hosts]
- if search:
- servers = [s for s in servers if search.lower() in s.hostname.lower()]
-
- # Filtre scope secops / autre
- if scope == "secops":
- secops_hosts = {r.hostname for r in db.execute(text(
- "SELECT hostname FROM servers WHERE patch_os_owner = 'secops'"
- )).fetchall()}
- servers = [s for s in servers if s.hostname in secops_hosts]
- elif scope == "other":
- secops_hosts = {r.hostname for r in db.execute(text(
- "SELECT hostname FROM servers WHERE patch_os_owner = 'secops'"
- )).fetchall()}
- servers = [s for s in servers if s.hostname not in secops_hosts]
-
- if sort == "hostname":
- servers.sort(key=lambda s: s.hostname.lower(), reverse=(sort_dir == "desc"))
- elif sort == "count":
- servers.sort(key=lambda s: s.patch_count or 0, reverse=(sort_dir == "desc"))
- elif sort == "last":
- servers.sort(key=lambda s: s.last_patch_week or "", reverse=(sort_dir == "desc"))
-
- total_filtered = len(servers)
- total_pages = max(1, (total_filtered + per_page - 1) // per_page)
- page = max(1, min(page, total_pages))
- servers_page = servers[(page - 1) * per_page : page * per_page]
-
- ctx = base_context(request, db, user)
- ctx.update({
- "app_name": APP_NAME, "year": year, "kpis": kpis,
- "kpis_secops": kpis_secops, "kpis_other": kpis_other,
- "compare": compare,
- "patch_by_domain": patch_by_domain, "patch_weekly": patch_weekly,
- "servers": servers_page, "all_domains": all_domains, "all_zones": all_zones,
- "search": search, "domain": domain, "scope": scope,
- "sort": sort, "sort_dir": sort_dir,
- "page": page, "total_pages": total_pages, "total_filtered": total_filtered,
- })
- return templates.TemplateResponse("audit_full_patching.html", ctx)
-
-
-@router.get("/audit-full/patching/export-csv")
-async def patching_export_csv(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, "audit"):
- return RedirectResponse(url="/audit-full")
-
- import io, csv
- year = int(request.query_params.get("year", "2026"))
- search = request.query_params.get("q", "").strip()
- domain = request.query_params.get("domain", "")
- scope = request.query_params.get("scope", "")
-
- yr_count = "patch_count_2026" if year == 2026 else "patch_count_2025"
- yr_weeks = "patch_weeks_2026" if year == 2026 else "patch_weeks_2025"
-
- servers = db.execute(text(
- f"SELECT DISTINCT ON (saf.hostname) saf.hostname,"
- f" saf.{yr_count} as patch_count, saf.{yr_weeks} as patch_weeks,"
- f" saf.last_patch_date, saf.last_patch_week, saf.last_patch_year,"
- f" saf.patch_status_2026,"
- f" d.name as domain, e.name as env, z.name as zone, s.os_family, s.etat"
- f" FROM server_audit_full saf"
- f" LEFT JOIN servers s ON saf.server_id = s.id"
- f" LEFT JOIN domain_environments de ON s.domain_env_id = de.id"
- f" LEFT JOIN domains d ON de.domain_id = d.id"
- f" LEFT JOIN environments e ON de.environment_id = e.id"
- f" LEFT JOIN zones z ON s.zone_id = z.id"
- f" WHERE saf.status IN ('ok','partial')"
- f" ORDER BY saf.hostname, saf.audit_date DESC"
- )).fetchall()
-
- if scope == "secops":
- secops = {r.hostname for r in db.execute(text("SELECT hostname FROM servers WHERE patch_os_owner = 'secops'")).fetchall()}
- servers = [s for s in servers if s.hostname in secops]
- elif scope == "other":
- secops = {r.hostname for r in db.execute(text("SELECT hostname FROM servers WHERE patch_os_owner = 'secops'")).fetchall()}
- servers = [s for s in servers if s.hostname not in secops]
- if domain:
- zone_hosts = {r.hostname for r in db.execute(text(
- "SELECT s.hostname FROM servers s JOIN zones z ON s.zone_id = z.id WHERE z.name = :n"
- ), {"n": domain}).fetchall()}
- if zone_hosts:
- servers = [s for s in servers if s.hostname in zone_hosts]
- else:
- dom_hosts = {r.hostname for r in db.execute(text(
- "SELECT s.hostname FROM servers s JOIN domain_environments de ON s.domain_env_id = de.id"
- " JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc"
- ), {"dc": domain}).fetchall()}
- servers = [s for s in servers if s.hostname in dom_hosts]
- if search:
- servers = [s for s in servers if search.lower() in s.hostname.lower()]
-
- output = io.StringIO()
- w = csv.writer(output, delimiter=";")
- w.writerow(["Hostname", "OS", "Domaine", "Environnement", "Zone", "Etat",
- "Nb patches", "Semaines", "Dernier patch", "Statut"])
- for s in servers:
- w.writerow([
- s.hostname, s.os_family or "", s.domain or "", s.env or "",
- s.zone or "", s.etat or "", s.patch_count or 0,
- s.patch_weeks or "", s.last_patch_date or s.last_patch_week or "",
- s.patch_status_2026 or "",
- ])
-
- output.seek(0)
- return StreamingResponse(
- iter(["\ufeff" + output.getvalue()]),
- media_type="text/csv",
- headers={"Content-Disposition": f"attachment; filename=patching_{year}.csv"})
-
-
-@router.get("/audit-full/export-csv")
-async def audit_full_export_csv(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, "audit"):
- return RedirectResponse(url="/audit-full")
-
- import io, csv
- filtre = request.query_params.get("filter", "")
- search = request.query_params.get("q", "").strip()
- domain = request.query_params.get("domain", "")
-
- audits = get_latest_audits(db, limit=9999)
-
- # Memes filtres que la page liste
- if filtre == "reboot":
- audits = [a for a in audits if a.reboot_required]
- elif filtre == "uptime":
- audits = [a for a in audits if a.uptime and ("month" in a.uptime or "year" in a.uptime)]
- elif filtre and filtre.startswith("app_"):
- app_patterns = {
- "app_postgres": "postgres", "app_mariadb": "mariadb|mysqld",
- "app_hana": "hdb|sapstart|HANA", "app_oracle": "ora_pmon|oracle",
- "app_httpd": "httpd", "app_nginx": "nginx", "app_haproxy": "haproxy",
- "app_tomcat": "tomcat|catalina", "app_nodejs": "node",
- "app_redis": "redis", "app_mongodb": "mongod",
- "app_elastic": "elasticsearch|opensearch", "app_container": "docker|podman",
- "app_java": "java|\\.jar",
- }
- pattern = app_patterns.get(filtre, "")
- if pattern:
- ids = {r.id for r in db.execute(text("""
- SELECT id FROM server_audit_full
- WHERE status IN ('ok','partial')
- AND (services::text ~* :pat OR listen_ports::text ~* :pat OR processes::text ~* :pat)
- AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
- """), {"pat": pattern}).fetchall()}
- audits = [a for a in audits if a.id in ids]
- if domain:
- zone_servers = {r.hostname for r in db.execute(text(
- "SELECT s.hostname FROM servers s JOIN zones z ON s.zone_id = z.id WHERE z.name = :name"
- ), {"name": domain}).fetchall()}
- if zone_servers:
- audits = [a for a in audits if a.hostname in zone_servers]
- else:
- domain_servers = {r.hostname for r in db.execute(text("""
- SELECT s.hostname FROM servers s JOIN domain_environments de ON s.domain_env_id = de.id
- JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc
- """), {"dc": domain}).fetchall()}
- audits = [a for a in audits if a.hostname in domain_servers]
- if search:
- q = search.lower()
- audits = [a for a in audits if q in a.hostname.lower()]
-
- # Generer CSV
- output = io.StringIO()
- writer = csv.writer(output, delimiter=";")
- writer.writerow(["Hostname", "OS", "Kernel", "Uptime", "Services", "Processus",
- "Ports", "Connexions", "Reboot requis", "Date audit"])
- for a in audits:
- writer.writerow([
- a.hostname, a.os_release or "", a.kernel or "", a.uptime or "",
- a.svc_count, a.proc_count, a.port_count, a.conn_count,
- "Oui" if a.reboot_required else "Non",
- a.audit_date.strftime("%Y-%m-%d %H:%M") if a.audit_date else "",
- ])
-
- output.seek(0)
- return StreamingResponse(
- iter(["\ufeff" + output.getvalue()]),
- media_type="text/csv",
- headers={"Content-Disposition": "attachment; filename=audit_serveurs.csv"},
- )
-
-
-@router.get("/audit-full/flow-map", response_class=HTMLResponse)
-async def audit_full_flow_map(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, "audit"):
- return RedirectResponse(url="/audit-full")
-
- domain_filter = request.query_params.get("domain", "")
- server_filter = request.query_params.get("server", "").strip()
-
- # Domaines + zones pour le dropdown
- all_domains = db.execute(text(
- "SELECT code, name, 'domain' as type FROM domains ORDER BY name"
- )).fetchall()
- all_zones = db.execute(text(
- "SELECT name as code, name, 'zone' as type FROM zones ORDER BY name"
- )).fetchall()
-
- # Serveurs audites pour l'autocompletion
- audited_servers = db.execute(text("""
- SELECT DISTINCT hostname FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname
- """)).fetchall()
-
- if server_filter:
- # Flux pour un serveur specifique (IN + OUT)
- flows = db.execute(text("""
- SELECT source_hostname, source_ip, dest_ip, dest_port,
- dest_hostname, process_name, direction, state,
- COUNT(*) as cnt
- FROM network_flow_map nfm
- JOIN server_audit_full saf ON nfm.audit_id = saf.id
- WHERE saf.id IN (
- SELECT DISTINCT ON (hostname) id FROM server_audit_full
- WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC
- )
- AND (nfm.source_hostname = :srv OR nfm.dest_hostname = :srv)
- AND nfm.source_hostname != nfm.dest_hostname
- AND nfm.dest_hostname IS NOT NULL
- GROUP BY source_hostname, source_ip, dest_ip, dest_port,
- dest_hostname, process_name, direction, state
- ORDER BY source_hostname
- """), {"srv": server_filter}).fetchall()
- elif domain_filter:
- # Flux pour un domaine ou une zone
- # D'abord chercher comme zone
- hostnames = [r.hostname for r in db.execute(text("""
- SELECT s.hostname FROM servers s
- JOIN zones z ON s.zone_id = z.id WHERE z.name = :name
- """), {"name": domain_filter}).fetchall()]
- if not hostnames:
- # Sinon comme domaine
- hostnames = [r.hostname for r in db.execute(text("""
- SELECT s.hostname FROM servers s
- JOIN domain_environments de ON s.domain_env_id = de.id
- JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc
- """), {"dc": domain_filter}).fetchall()]
- if hostnames:
- flows = db.execute(text("""
- SELECT source_hostname, source_ip, dest_ip, dest_port,
- dest_hostname, process_name, direction, state,
- COUNT(*) as cnt
- FROM network_flow_map nfm
- JOIN server_audit_full saf ON nfm.audit_id = saf.id
- WHERE saf.id IN (
- SELECT DISTINCT ON (hostname) id FROM server_audit_full
- WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC
- )
- AND (nfm.source_hostname = ANY(:hosts) OR nfm.dest_hostname = ANY(:hosts))
- AND nfm.source_hostname != COALESCE(nfm.dest_hostname, '')
- AND nfm.dest_hostname IS NOT NULL
- GROUP BY source_hostname, source_ip, dest_ip, dest_port,
- dest_hostname, process_name, direction, state
- ORDER BY source_hostname
- """), {"hosts": hostnames}).fetchall()
- else:
- flows = []
- else:
- flows = get_flow_map(db)
-
- app_map = get_app_map(db)
-
- ctx = base_context(request, db, user)
- ctx.update({
- "app_name": APP_NAME, "flows": flows, "app_map": app_map,
- "all_domains": all_domains, "all_zones": all_zones,
- "audited_servers": audited_servers,
- "domain_filter": domain_filter, "server_filter": server_filter,
- })
- return templates.TemplateResponse("audit_full_flowmap.html", ctx)
-
-
-@router.get("/audit-full/{audit_id}", response_class=HTMLResponse)
-async def audit_full_detail(request: Request, audit_id: int, 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, "audit"):
- return RedirectResponse(url="/audit-full")
-
- audit = get_audit_detail(db, audit_id)
- if not audit:
- return RedirectResponse(url="/audit-full")
-
- def _j(val, default):
- if val is None: return default
- if isinstance(val, (list, dict)): return val
- try: return json.loads(val)
- except: return default
-
- # Serveur partial (pas encore audite via SSH)
- is_partial = (audit.status == "partial")
-
- flows = [] if is_partial else get_flow_map_for_server(db, audit.hostname)
-
- ctx = base_context(request, db, user)
- ctx.update({
- "app_name": APP_NAME, "a": audit, "flows": flows,
- "is_partial": is_partial,
- "services": _j(audit.services, []),
- "processes": _j(audit.processes, []),
- "listen_ports": _j(audit.listen_ports, []),
- "connections": _j(audit.connections, []),
- "flux_in": _j(audit.flux_in, []),
- "flux_out": _j(audit.flux_out, []),
- "disk_usage": _j(audit.disk_usage, []),
- "interfaces": _j(audit.interfaces, []),
- "correlation": _j(audit.correlation_matrix, []),
- "outbound": _j(audit.outbound_only, []),
- "firewall": _j(audit.firewall, {}),
- "conn_wait": _j(audit.conn_wait, []),
- "traffic": _j(audit.traffic, []),
- })
- return templates.TemplateResponse("audit_full_detail.html", ctx)
diff --git a/app/routers/safe_patching.py b/app/routers/safe_patching.py
deleted file mode 100644
index 89f1a41..0000000
--- a/app/routers/safe_patching.py
+++ /dev/null
@@ -1,262 +0,0 @@
-"""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")
- perms = get_user_perms(db, user)
- if not can_view(perms, "campaigns"):
- return RedirectResponse(url="/dashboard")
-
- 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}/delete")
-async def safe_patching_delete(request: Request, campaign_id: int, db=Depends(get_db)):
- user = get_current_user(request)
- if not user:
- return RedirectResponse(url="/login")
- perms = get_user_perms(db, user)
- if perms.get("campaigns") != "admin":
- return RedirectResponse(url="/safe-patching", status_code=303)
- db.execute(text("DELETE FROM campaign_operator_limits WHERE campaign_id = :cid"), {"cid": campaign_id})
- db.execute(text("DELETE FROM patch_sessions WHERE campaign_id = :cid"), {"cid": campaign_id})
- db.execute(text("DELETE FROM campaigns WHERE id = :cid"), {"cid": campaign_id})
- db.commit()
- return RedirectResponse(url="/safe-patching?msg=deleted", status_code=303)
-
-
-@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")
- perms = get_user_perms(db, user)
- if not can_edit(perms, "campaigns"):
- return RedirectResponse(url=f"/safe-patching/{campaign_id}")
- 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")
- perms = get_user_perms(db, user)
- if not can_edit(perms, "campaigns"):
- return RedirectResponse(url=f"/safe-patching/{campaign_id}")
- 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")
- perms = get_user_perms(db, user)
- if not can_edit(perms, "campaigns"):
- return RedirectResponse(url=f"/safe-patching/{campaign_id}")
-
- # 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")
- perms = get_user_perms(db, user)
- if not can_view(perms, "campaigns"):
- return RedirectResponse(url="/safe-patching")
- 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, db=Depends(get_db)):
- """SSE endpoint — stream les logs en temps réel"""
- user = get_current_user(request)
- if not user:
- return StreamingResponse(iter([]), media_type="text/event-stream")
- 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"}
- )
diff --git a/app/services/patching_executor.py b/app/services/patching_executor.py
deleted file mode 100644
index c5a8c9e..0000000
--- a/app/services/patching_executor.py
+++ /dev/null
@@ -1,146 +0,0 @@
-"""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
diff --git a/app/services/safe_patching_service.py b/app/services/safe_patching_service.py
deleted file mode 100644
index 9d679de..0000000
--- a/app/services/safe_patching_service.py
+++ /dev/null
@@ -1,110 +0,0 @@
-"""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()
diff --git a/app/services/server_audit_full_service.py b/app/services/server_audit_full_service.py
deleted file mode 100644
index 0c636d7..0000000
--- a/app/services/server_audit_full_service.py
+++ /dev/null
@@ -1,504 +0,0 @@
-"""Service audit complet serveur — applicatif + reseau + correlation + carte flux
-Adapte du standalone SANEF corrige pour PatchCenter (FastAPI/PostgreSQL)
-"""
-import json
-import re
-import os
-import socket
-import logging
-from datetime import datetime
-from sqlalchemy import text
-
-logging.getLogger("paramiko").setLevel(logging.CRITICAL)
-logging.getLogger("paramiko.transport").setLevel(logging.CRITICAL)
-
-try:
- import paramiko
- PARAMIKO_OK = True
-except ImportError:
- PARAMIKO_OK = False
-
-SSH_KEY_FILE = "/opt/patchcenter/keys/id_rsa_cybglobal.pem"
-PSMP_HOST = "psmp.sanef.fr"
-CYBR_USER = "CYBP01336"
-TARGET_USER = "cybsecope"
-SSH_TIMEOUT = 20
-
-ENV_DOMAINS = {
- "prod": ".sanef.groupe",
- "preprod": ".sanef.groupe",
- "recette": ".sanef-rec.fr",
- "test": ".sanef-rec.fr",
- "dev": ".sanef-rec.fr",
-}
-
-BANNER_FILTERS = [
- "GROUPE SANEF", "propriete du Groupe", "accederait", "emprisonnement",
- "Article 323", "code penal", "Authorized uses only", "CyberArk",
- "This session", "session is being",
-]
-
-SCRIPT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "scripts", "server_audit.sh")
-
-
-def _load_script():
- with open(SCRIPT_PATH, "r", encoding="utf-8") as f:
- return f.read()
-
-
-def _get_psmp_password(db=None):
- if not db:
- return None
- try:
- from .secrets_service import get_secret
- return get_secret(db, "ssh_pwd_default_pass")
- except Exception:
- return None
-
-
-# ── DETECTION ENV + SSH (pattern SANEF corrige) ──
-
-def detect_env(hostname):
- h = hostname.lower()
- c = h[1] if len(h) > 1 else ""
- if c == "p": return "prod"
- elif c == "i": return "preprod"
- elif c == "r": return "recette"
- elif c == "v": return "test"
- elif c == "d": return "dev"
- return "recette"
-
-
-def _load_key():
- if not os.path.exists(SSH_KEY_FILE):
- return None
- for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
- try:
- return cls.from_private_key_file(SSH_KEY_FILE)
- except Exception:
- continue
- return None
-
-
-def _build_fqdn_candidates(hostname):
- if "." in hostname:
- return [hostname]
- c = hostname[1] if len(hostname) > 1 else ""
- if c in ("p", "i"):
- return [f"{hostname}.sanef.groupe", f"{hostname}.sanef-rec.fr", hostname]
- else:
- return [f"{hostname}.sanef-rec.fr", f"{hostname}.sanef.groupe", hostname]
-
-
-def _try_psmp(fqdn, password):
- if not password:
- return None
- try:
- username = f"{CYBR_USER}@{TARGET_USER}@{fqdn}"
- transport = paramiko.Transport((PSMP_HOST, 22))
- transport.connect()
- def handler(title, instructions, prompt_list):
- return [password] * len(prompt_list)
- transport.auth_interactive(username, handler)
- client = paramiko.SSHClient()
- client._transport = transport
- return client
- except Exception:
- return None
-
-
-def _try_key(fqdn, key):
- if not key:
- return None
- try:
- client = paramiko.SSHClient()
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
- client.connect(fqdn, port=22, username=TARGET_USER, pkey=key,
- timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False)
- return client
- except Exception:
- return None
-
-
-def ssh_connect(hostname, password=None):
- fqdn_candidates = _build_fqdn_candidates(hostname)
- key = _load_key()
- for fqdn in fqdn_candidates:
- if password:
- client = _try_psmp(fqdn, password)
- if client:
- return client, None
- if key:
- client = _try_key(fqdn, key)
- if client:
- return client, None
- return None, f"Connexion impossible sur {fqdn_candidates}"
-
-
-def ssh_run_script(client, script_content, timeout=300):
- try:
- chan = client._transport.open_session()
- chan.settimeout(timeout)
- chan.exec_command("bash -s")
- chan.sendall(script_content.encode("utf-8"))
- chan.shutdown_write()
- out = b""
- while True:
- try:
- chunk = chan.recv(8192)
- if not chunk:
- break
- out += chunk
- except Exception:
- break
- chan.close()
- out_str = out.decode("utf-8", errors="replace")
- if not out_str.strip():
- return "", "Sortie vide"
- lines = [l for l in out_str.splitlines() if not any(b in l for b in BANNER_FILTERS)]
- return "\n".join(lines), None
- except Exception as e:
- return "", str(e)
-
-
-# ── PARSING ──
-
-def parse_audit_output(raw):
- result = {
- "hostname": "", "os_release": "", "kernel": "", "uptime": "",
- "services": [], "processes": [], "services_failed": "",
- "needs_restarting": "", "reboot_required": False, "disk_usage": [],
- "interfaces": [], "routes": [], "listen_ports": [],
- "connections": [], "flux_in": [], "flux_out": [],
- "conn_wait": [], "net_stats": {}, "traffic": [],
- "firewall": {"policy": {}, "input": [], "output": [], "firewalld": []},
- "correlation_matrix": [], "outbound_only": [],
- }
- section = None
- firewall_sub = None
-
- for line in raw.splitlines():
- ls = line.strip()
- m = re.match(r"^# AUDIT COMPLET .+ (.+)$", ls)
- if m: result["hostname"] = m.group(1); continue
- m = re.match(r"^# OS: (.+)$", ls)
- if m: result["os_release"] = m.group(1); continue
- m = re.match(r"^# Kernel: (.+)$", ls)
- if m: result["kernel"] = m.group(1); continue
- m = re.match(r"^# Uptime: (.+)$", ls)
- if m: result["uptime"] = m.group(1); continue
- if "1.1 SERVICES APPLICATIFS" in ls: section = "services"; continue
- elif "1.2 PROCESSUS APPLICATIFS" in ls: section = "processes"; continue
- elif "1.3 SERVICES EN ECHEC" in ls: section = "services_failed"; continue
- elif "1.4 NEEDS-RESTARTING" in ls: section = "needs_restarting"; continue
- elif "1.5 ESPACE DISQUE" in ls: section = "disk"; continue
- elif "2.1 INTERFACES" in ls: section = "interfaces"; continue
- elif "2.2 TABLE DE ROUTAGE" in ls: section = "routes"; continue
- elif "2.3 PORTS EN ECOUTE" in ls: section = "listen_ports"; continue
- elif "2.4 CONNEXIONS ETABLIES" in ls: section = "connections"; continue
- elif "2.5 RESUME FLUX ENTRANTS" in ls: section = "flux_in"; continue
- elif "2.6 RESUME FLUX SORTANTS" in ls: section = "flux_out"; continue
- elif "2.7 CONNEXIONS EN ATTENTE" in ls: section = "conn_wait"; continue
- elif "2.8 STATISTIQUES" in ls: section = "net_stats"; continue
- elif "2.9 TRAFIC" in ls: section = "traffic"; continue
- elif "2.10 FIREWALL" in ls: section = "firewall"; firewall_sub = None; continue
- elif "3.1 MATRICE" in ls: section = "correlation"; continue
- elif "3.2 PROCESS SORTANTS" in ls: section = "outbound"; continue
- elif ls.startswith("===") or ls.startswith("###"): section = None; continue
- if not ls: continue
- headers = ["SERVICE|","PID|PPID","PROTO|","DIRECTION|","PORT|","DEST_IP|",
- "METRIC|","INTERFACE|","DESTINATION|","NUM|","PROCESS|USER",
- "ZONE|","Mont","STATE|COUNT"]
- if any(ls.startswith(h) for h in headers): continue
- parts = ls.split("|")
- if section == "services" and len(parts) >= 2:
- result["services"].append({"name":parts[0],"enabled":parts[1],"pid":parts[2] if len(parts)>2 else "","user":parts[3] if len(parts)>3 else "","exec":parts[4] if len(parts)>4 else ""})
- elif section == "processes" and len(parts) >= 6:
- result["processes"].append({"pid":parts[0],"ppid":parts[1],"user":parts[2],"exe":parts[3],"cwd":parts[4],"cmdline":parts[5],"restart_hint":parts[6] if len(parts)>6 else ""})
- elif section == "services_failed":
- if ls != "Aucun service en echec": result["services_failed"] += ls + "\n"
- elif section == "needs_restarting":
- result["needs_restarting"] += ls + "\n"
- if "EXIT_CODE=1" in ls: result["reboot_required"] = True
- elif section == "disk":
- p = ls.split()
- if len(p) >= 5 and "%" in p[-1]:
- try: result["disk_usage"].append({"mount":p[0],"size":p[1],"used":p[2],"avail":p[3],"pct":int(p[4].replace("%",""))})
- except: pass
- elif section == "interfaces" and len(parts) >= 3:
- result["interfaces"].append({"iface":parts[0],"ip":parts[1],"mask":parts[2],"state":parts[3] if len(parts)>3 else "","mac":parts[4] if len(parts)>4 else ""})
- elif section == "routes" and len(parts) >= 3:
- result["routes"].append({"dest":parts[0],"gw":parts[1],"iface":parts[2],"metric":parts[3] if len(parts)>3 else ""})
- elif section == "listen_ports" and len(parts) >= 3:
- result["listen_ports"].append({"proto":parts[0],"addr_port":parts[1],"pid":parts[2],"process":parts[3] if len(parts)>3 else "","user":parts[4] if len(parts)>4 else "","service":parts[5] if len(parts)>5 else ""})
- elif section == "connections" and len(parts) >= 5:
- result["connections"].append({"direction":parts[0],"proto":parts[1],"local":parts[2],"remote":parts[3],"pid":parts[4],"process":parts[5] if len(parts)>5 else "","user":parts[6] if len(parts)>6 else "","state":parts[7] if len(parts)>7 else ""})
- elif section == "flux_in" and len(parts) >= 3:
- result["flux_in"].append({"port":parts[0],"service":parts[1],"process":parts[2],"count":parts[3] if len(parts)>3 else "0","sources":parts[4] if len(parts)>4 else ""})
- elif section == "flux_out" and len(parts) >= 3:
- result["flux_out"].append({"dest_ip":parts[0],"dest_port":parts[1],"service":parts[2],"process":parts[3] if len(parts)>3 else "","count":parts[4] if len(parts)>4 else "1"})
- elif section == "conn_wait" and len(parts) == 2:
- result["conn_wait"].append({"state":parts[0],"count":parts[1]})
- elif section == "net_stats" and len(parts) == 2:
- result["net_stats"][parts[0].strip()] = parts[1].strip()
- elif section == "traffic" and len(parts) >= 5:
- result["traffic"].append({"iface":parts[0],"rx_bytes":parts[1],"rx_pkt":parts[2],"rx_err":parts[3],"tx_bytes":parts[4],"tx_pkt":parts[5] if len(parts)>5 else "","tx_err":parts[6] if len(parts)>6 else ""})
- elif section == "firewall":
- if "POLICY" in ls: firewall_sub = "policy"; continue
- elif "INPUT" in ls and "---" in ls: firewall_sub = "input"; continue
- elif "OUTPUT" in ls and "---" in ls: firewall_sub = "output"; continue
- elif "FIREWALLD" in ls: firewall_sub = "firewalld"; continue
- if firewall_sub == "policy" and len(parts) == 2: result["firewall"]["policy"][parts[0]] = parts[1]
- elif firewall_sub == "input" and len(parts) >= 3: result["firewall"]["input"].append(ls)
- elif firewall_sub == "output" and len(parts) >= 3: result["firewall"]["output"].append(ls)
- elif firewall_sub == "firewalld" and len(parts) >= 2: result["firewall"]["firewalld"].append({"zone":parts[0],"services":parts[1],"ports":parts[2] if len(parts)>2 else ""})
- elif section == "correlation" and len(parts) >= 4:
- result["correlation_matrix"].append({"process":parts[0],"user":parts[1],"pid":parts[2],"listen_ports":parts[3],"conn_in":parts[4] if len(parts)>4 else "0","conn_out":parts[5] if len(parts)>5 else "0","remote_dests":parts[6] if len(parts)>6 else ""})
- elif section == "outbound" and len(parts) >= 3:
- result["outbound_only"].append({"process":parts[0],"user":parts[1],"pid":parts[2],"dests":parts[3] if len(parts)>3 else ""})
- result["services_failed"] = result["services_failed"].strip()
- result["needs_restarting"] = result["needs_restarting"].strip()
- return result
-
-
-# ── STOCKAGE DB ──
-
-def _resolve_server_id(db, hostname):
- srv = db.execute(text(
- "SELECT id FROM servers WHERE LOWER(hostname) = LOWER(:h)"
- ), {"h": hostname.split(".")[0]}).fetchone()
- return srv.id if srv else None
-
-
-def _resolve_dest_server(db, dest_ip):
- # Nettoyer l'IP (retirer IPv6-mapped prefix, brackets)
- clean_ip = dest_ip.replace("[::ffff:", "").replace("]", "").strip()
- if not clean_ip or ":" in clean_ip:
- return (None, None) # IPv6 pure, skip
- try:
- row = db.execute(text("""
- SELECT s.id, s.hostname FROM servers s
- JOIN server_ips si ON s.id = si.server_id
- WHERE si.ip_address = CAST(:ip AS inet)
- LIMIT 1
- """), {"ip": clean_ip}).fetchone()
- return (row.id, row.hostname) if row else (None, None)
- except Exception:
- return (None, None)
-
-
-def save_audit_to_db(db, parsed, raw_output="", status="ok", error_msg=None):
- hostname = parsed.get("hostname", "")
- if not hostname:
- return None
- server_id = _resolve_server_id(db, hostname)
-
- row = db.execute(text("""
- INSERT INTO server_audit_full (
- server_id, hostname, audit_date, os_release, kernel, uptime,
- services, processes, services_failed, needs_restarting, reboot_required,
- disk_usage, interfaces, routes, listen_ports, connections,
- flux_in, flux_out, conn_wait, net_stats, traffic, firewall,
- correlation_matrix, outbound_only, raw_output, status, error_msg
- ) VALUES (
- :sid, :hn, NOW(), :os, :k, :up,
- :svc, :proc, :sf, :nr, :rr,
- :du, :iface, :rt, :lp, :conn,
- :fi, :fo, :cw, :ns, :tr, :fw,
- :cm, :ob, :raw, :st, :err
- ) RETURNING id
- """), {
- "sid": server_id, "hn": hostname,
- "os": parsed.get("os_release", ""), "k": parsed.get("kernel", ""),
- "up": parsed.get("uptime", ""),
- "svc": json.dumps(parsed.get("services", [])),
- "proc": json.dumps(parsed.get("processes", [])),
- "sf": parsed.get("services_failed", ""),
- "nr": parsed.get("needs_restarting", ""),
- "rr": parsed.get("reboot_required", False),
- "du": json.dumps(parsed.get("disk_usage", [])),
- "iface": json.dumps(parsed.get("interfaces", [])),
- "rt": json.dumps(parsed.get("routes", [])),
- "lp": json.dumps(parsed.get("listen_ports", [])),
- "conn": json.dumps(parsed.get("connections", [])),
- "fi": json.dumps(parsed.get("flux_in", [])),
- "fo": json.dumps(parsed.get("flux_out", [])),
- "cw": json.dumps(parsed.get("conn_wait", [])),
- "ns": json.dumps(parsed.get("net_stats", {})),
- "tr": json.dumps(parsed.get("traffic", [])),
- "fw": json.dumps(parsed.get("firewall", {})),
- "cm": json.dumps(parsed.get("correlation_matrix", [])),
- "ob": json.dumps(parsed.get("outbound_only", [])),
- "raw": raw_output, "st": status, "err": error_msg,
- }).fetchone()
-
- audit_id = row.id
- _build_flow_map(db, audit_id, hostname, server_id, parsed)
- return audit_id
-
-
-def _build_flow_map(db, audit_id, hostname, server_id, parsed):
- local_ips = [i["ip"] for i in parsed.get("interfaces", []) if i["ip"] != "127.0.0.1"]
- source_ip = local_ips[0] if local_ips else ""
- for conn in parsed.get("connections", []):
- remote = conn.get("remote", "")
- m = re.match(r'^(.+):(\d+)$', remote)
- if not m:
- continue
- dest_ip = m.group(1)
- dest_port = int(m.group(2))
- if dest_ip.startswith("127.") or dest_ip == "::1":
- continue
- dest_server_id, dest_hostname = _resolve_dest_server(db, dest_ip)
- db.execute(text("""
- INSERT INTO network_flow_map (
- audit_id, source_server_id, source_hostname, source_ip,
- dest_ip, dest_port, dest_hostname, dest_server_id,
- process_name, process_user, direction,
- connection_count, state, audit_date
- ) VALUES (
- :aid, :ssid, :shn, :sip,
- :dip, :dp, :dhn, :dsid,
- :pn, :pu, :dir, 1, :st, NOW()
- )
- """), {
- "aid": audit_id, "ssid": server_id, "shn": hostname, "sip": source_ip,
- "dip": dest_ip, "dp": dest_port, "dhn": dest_hostname, "dsid": dest_server_id,
- "pn": conn.get("process", ""), "pu": conn.get("user", ""),
- "dir": conn.get("direction", ""), "st": conn.get("state", ""),
- })
-
-
-# ── IMPORT JSON (depuis standalone) ──
-
-def import_json_report(db, json_data):
- servers = json_data.get("servers", [])
- imported = 0
- errors = 0
- for srv in servers:
- if srv.get("status") == "error":
- errors += 1
- continue
- hostname = srv.get("hostname", "")
- if not hostname:
- continue
- parsed = {k: srv.get(k, v) for k, v in {
- "hostname": "", "os_release": "", "kernel": "", "uptime": "",
- "services": [], "processes": [], "services_failed": "",
- "needs_restarting": "", "reboot_required": False, "disk_usage": [],
- "interfaces": [], "routes": [], "listen_ports": [],
- "connections": [], "flux_in": [], "flux_out": [],
- "conn_wait": [], "net_stats": {}, "traffic": [],
- "firewall": {}, "correlation_matrix": [], "outbound_only": [],
- }.items()}
- save_audit_to_db(db, parsed)
- imported += 1
- db.commit()
- return imported, errors
-
-
-# ── REQUETES ──
-
-def get_latest_audits(db, limit=100):
- return db.execute(text("""
- SELECT DISTINCT ON (hostname) id, server_id, hostname, audit_date,
- os_release, kernel, uptime, status, reboot_required,
- last_patch_date, last_patch_week, last_patch_year,
- jsonb_array_length(COALESCE(services, '[]')) as svc_count,
- jsonb_array_length(COALESCE(listen_ports, '[]')) as port_count,
- jsonb_array_length(COALESCE(connections, '[]')) as conn_count,
- jsonb_array_length(COALESCE(processes, '[]')) as proc_count
- FROM server_audit_full
- WHERE status IN ('ok','partial')
- ORDER BY hostname, audit_date DESC
- LIMIT :lim
- """), {"lim": limit}).fetchall()
-
-
-def get_audit_detail(db, audit_id):
- return db.execute(text(
- "SELECT * FROM server_audit_full WHERE id = :id"
- ), {"id": audit_id}).fetchone()
-
-
-def get_flow_map(db):
- return db.execute(text("""
- SELECT source_hostname, source_ip, dest_ip, dest_port,
- dest_hostname, process_name, direction, state,
- COUNT(*) as cnt
- FROM network_flow_map nfm
- JOIN server_audit_full saf ON nfm.audit_id = saf.id
- WHERE saf.id IN (
- SELECT DISTINCT ON (hostname) id FROM server_audit_full
- WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC
- )
- GROUP BY source_hostname, source_ip, dest_ip, dest_port,
- dest_hostname, process_name, direction, state
- ORDER BY source_hostname
- """)).fetchall()
-
-
-def get_flow_map_for_server(db, hostname):
- return db.execute(text("""
- SELECT source_hostname, source_ip, dest_ip, dest_port,
- dest_hostname, process_name, direction, state
- FROM network_flow_map
- WHERE audit_id = (
- SELECT id FROM server_audit_full WHERE hostname = :h
- ORDER BY audit_date DESC LIMIT 1
- )
- ORDER BY direction DESC, dest_ip
- """), {"h": hostname}).fetchall()
-
-
-def get_flow_map_for_domain(db, domain_code):
- return db.execute(text("""
- SELECT nfm.source_hostname, nfm.source_ip, nfm.dest_ip, nfm.dest_port,
- nfm.dest_hostname, nfm.process_name, nfm.direction, nfm.state
- FROM network_flow_map nfm
- JOIN server_audit_full saf ON nfm.audit_id = saf.id
- JOIN servers s ON saf.server_id = s.id
- JOIN domain_environments de ON s.domain_env_id = de.id
- JOIN domains d ON de.domain_id = d.id
- WHERE d.code = :dc
- AND saf.id IN (
- SELECT DISTINCT ON (hostname) id FROM server_audit_full
- WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC
- )
- ORDER BY nfm.source_hostname
- """), {"dc": domain_code}).fetchall()
-
-
-def get_app_map(db):
- audits = db.execute(text("""
- SELECT DISTINCT ON (hostname) hostname, server_id, processes, listen_ports
- FROM server_audit_full WHERE status IN ('ok','partial')
- ORDER BY hostname, audit_date DESC
- """)).fetchall()
- app_groups = {}
- for audit in audits:
- processes = audit.processes if isinstance(audit.processes, list) else json.loads(audit.processes or "[]")
- for proc in processes:
- cwd = proc.get("cwd", "")
- m = re.search(r'/applis/([^/]+)', cwd)
- if not m:
- continue
- app_name = m.group(1)
- if app_name not in app_groups:
- app_groups[app_name] = {"servers": [], "ports": set()}
- if audit.hostname not in [s["hostname"] for s in app_groups[app_name]["servers"]]:
- app_groups[app_name]["servers"].append({
- "hostname": audit.hostname,
- "server_id": audit.server_id,
- "user": proc.get("user", ""),
- "cmdline": proc.get("cmdline", "")[:100],
- "restart_hint": proc.get("restart_hint", "")[:100],
- })
- listen = audit.listen_ports if isinstance(audit.listen_ports, list) else json.loads(audit.listen_ports or "[]")
- pid = proc.get("pid", "")
- for lp in listen:
- if lp.get("pid") == pid:
- app_groups[app_name]["ports"].add(lp.get("addr_port", ""))
- for k in app_groups:
- app_groups[k]["ports"] = list(app_groups[k]["ports"])
- return app_groups
diff --git a/app/templates/audit_full_detail.html b/app/templates/audit_full_detail.html
deleted file mode 100644
index 4a0fb02..0000000
--- a/app/templates/audit_full_detail.html
+++ /dev/null
@@ -1,241 +0,0 @@
-{% extends 'base.html' %}
-{% block title %}{{ a.hostname }}{% endblock %}
-{% block content %}
-< Retour
-
-
-
{{ a.hostname }}
-
{{ a.os_release }} | {{ a.kernel }} | {{ a.uptime }}
-
-
- {% for iface in interfaces %}{% if iface.ip != '127.0.0.1' %}
- {{ iface.ip }}{{ iface.mask }} ({{ iface.iface }})
- {% endif %}{% endfor %}
-
-
-
-{% if is_partial %}
-
-
Pas encore audité
-
Ce serveur n'a pas encore été audité via SSH (Windows, EMV...)
-
{{ a.hostname }} — {{ a.os_release or 'OS inconnu' }}
- {% if a.last_patch_week %}
Dernier patch : {{ a.last_patch_week }} {{ a.last_patch_year }}
{% endif %}
-
-{% else %}
-
-
-
{{ services|length }}
Services
-
{{ processes|length }}
Process
-
{{ listen_ports|length }}
Ports
-
{{ connections|length }}
Connexions
-
{% if a.reboot_required %}Oui{% else %}Non{% endif %}
Reboot
-
{% if a.services_failed %}KO{% else %}OK{% endif %}
Failed svc
-
-
-
-
-
- {% for t, label in [('services','Services'),('processes','Processus'),('ports','Ports'),('connections','Connexions'),('flux','Flux'),('disk','Disque'),('firewall','Firewall'),('correlation','Corrélation')] %}
- {{ label }}
- {% endfor %}
-
-
-
-
-
- Service Enabled PID User Exec
-
- {% for s in services %}
- {{ s.name }}
- {{ s.enabled }}
- {{ s.pid }}
- {{ s.user }}
- {{ s.exec[:80] }}
- {% endfor %}
-
-
-
-
-
-
- PID User Exe CWD Cmdline Restart
-
- {% for p in processes %}
- {{ p.pid }}
- {{ p.user }}
- {{ p.exe or '' }}
- {{ p.cwd or '' }}
- {{ p.cmdline or '' }}
- {{ p.restart_hint or '' }}
- {% endfor %}
-
-
-
-
-
-
- Proto Addr:Port PID Process User Service
-
- {% for lp in listen_ports %}
- {{ lp.proto }}
- {{ lp.addr_port }}
- {{ lp.pid }}
- {{ lp.process }}
- {{ lp.user }}
- {{ lp.service }}
- {% endfor %}
-
-
-
-
-
-
- Dir Local Remote PID Process User State
-
- {% for c in connections %}
- {{ c.direction }}
- {{ c.local }}
- {{ c.remote }}
- {{ c.pid }}
- {{ c.process }}
- {{ c.user }}
- {{ c.state }}
- {% endfor %}
-
-
-
-
-
- {% if flux_in %}
-
-
Flux entrants
-
- Port Service Process Nb Sources
-
- {% for f in flux_in %}
- {{ f.port }}
- {{ f.service }}
- {{ f.process }}
- {{ f.count }}
- {{ f.sources }}
- {% endfor %}
-
-
- {% endif %}
- {% if flux_out %}
-
-
Flux sortants
-
- Destination Port Service Process Nb
-
- {% for f in flux_out %}
- {{ f.dest_ip }}
- {{ f.dest_port }}
- {{ f.service }}
- {{ f.process }}
- {{ f.count }}
- {% endfor %}
-
-
- {% endif %}
- {% if flows %}
-
-
Carte flux (resolu)
-
- Dir IP dest Port Serveur dest Process State
-
- {% for f in flows %}
- {{ f.direction }}
- {{ f.dest_ip }}
- {{ f.dest_port }}
- {{ f.dest_hostname or '-' }}
- {{ f.process_name }}
- {{ f.state }}
- {% endfor %}
-
-
- {% endif %}
-
-
-
-
-
- Mount Taille Utilise Dispo %
-
- {% for d in disk_usage %}
- {{ d.mount }}
- {{ d.size }}
- {{ d.used }}
- {{ d.avail }}
- {{ d.pct }}%
- {% endfor %}
-
-
-
-
-
- {% if firewall.policy %}
-
- Policy iptables :
- {% for chain, pol in firewall.policy.items() %}
- {{ chain }}={{ pol or '?' }}
- {% endfor %}
-
- {% endif %}
- {% if firewall.firewalld %}
-
-
Firewalld :
- {% for z in firewall.firewalld %}
-
Zone {{ z.zone }} : services={{ z.services }} ports={{ z.ports }}
- {% endfor %}
-
- {% endif %}
- {% if conn_wait %}
-
- Connexions en attente :
- {% for cw in conn_wait %}
- {{ cw.state }}={{ cw.count }}
- {% endfor %}
-
- {% endif %}
-
-
-
-
- {% if correlation %}
-
-
Matrice process / ports / flux
-
- Process User PID Ports IN OUT Destinations
-
- {% for c in correlation %}
- {{ c.process }}
- {{ c.user }}
- {{ c.pid }}
- {{ c.listen_ports }}
- {{ c.conn_in }}
- {{ c.conn_out }}
- {{ c.remote_dests }}
- {% endfor %}
-
-
- {% endif %}
- {% if outbound %}
-
-
Process sortants uniquement
-
- Process User PID Destinations
-
- {% for o in outbound %}
- {{ o.process }}
- {{ o.user }}
- {{ o.pid }}
- {{ o.dests }}
- {% endfor %}
-
-
- {% endif %}
-
-
-{% endif %}{# end is_partial else #}
-{% endblock %}
diff --git a/app/templates/audit_full_flowmap.html b/app/templates/audit_full_flowmap.html
deleted file mode 100644
index 52714f7..0000000
--- a/app/templates/audit_full_flowmap.html
+++ /dev/null
@@ -1,282 +0,0 @@
-{% extends 'base.html' %}
-{% block title %}Carte flux{% endblock %}
-{% block content %}
-< Retour
-
-
Carte des flux réseau
- Reset vue
-
-
-
-
-
-
- {% if server_filter %}Serveur: {{ server_filter }}
- {% elif domain_filter %}Domaine/Zone: {{ domain_filter }}
- {% else %}Vue globale{% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ---> Flux réseau
- 0 serveurs
- 0 flux
-
-
-
-{% if flows %}
-
- Tableau des flux ({{ flows|length }})
-
-
-
- Dir Source Destination
- Port Process State
-
-
- {% for f in flows %}
-
- {{ f.direction }}
- {{ f.source_hostname }}
- {{ f.dest_hostname or f.dest_ip }}
- {{ f.dest_port }}
- {{ f.process_name }}
- {{ f.state }}
-
- {% endfor %}
-
-
-
-
-{% endif %}
-
-{% if app_map %}
-
- Carte applicative ({{ app_map|length }} applis)
-
- {% for app_name, app in app_map.items() %}
-
-
{{ app_name }}
-
{{ app.servers|length }}
- {% if app.ports %}
ports: {{ app.ports|join(', ') }} {% endif %}
-
{% for s in app.servers %}{{ s.hostname }} {% if not loop.last %}, {% endif %}{% endfor %}
-
- {% endfor %}
-
-
-{% endif %}
-
-
-{% endblock %}
diff --git a/app/templates/audit_full_list.html b/app/templates/audit_full_list.html
deleted file mode 100644
index db2ac0a..0000000
--- a/app/templates/audit_full_list.html
+++ /dev/null
@@ -1,155 +0,0 @@
-{% extends 'base.html' %}
-{% block title %}Audit complet{% endblock %}
-{% block content %}
-
-
-
Audit complet serveurs
-
Applicatif + réseau + corrélation — import JSON depuis le standalone
-
-
-
-
-{% if msg %}
-
- {% if msg.startswith('imported_') %}
- {% set parts = msg.split('_') %}
- {{ parts[1] }} serveur(s) importé(s){% if parts[2]|int > 0 %}, {{ parts[2] }} erreur(s){% endif %}.
- {% elif msg.startswith('error_') %}Erreur: {{ msg[6:] }}{% endif %}
-
-{% endif %}
-
-{% if kpis %}
-
-
-
- {% for key, label, icon in [
- ('app_oracle','Oracle','db'),('app_postgres','PostgreSQL','db'),('app_mariadb','MariaDB/MySQL','db'),('app_hana','SAP HANA','db'),
- ('app_httpd','Apache','web'),('app_nginx','Nginx','web'),('app_haproxy','HAProxy','web'),
- ('app_tomcat','Tomcat','app'),('app_java','Java','app'),('app_nodejs','Node.js','app'),
- ('app_redis','Redis','db'),('app_mongodb','MongoDB','db'),('app_elastic','Elastic','db'),('app_container','Docker/Podman','app')
- ] %}
- {% set val = kpis[key]|default(0) %}
- {% if val > 0 %}
-
- {{ val }}
- {{ label }}
-
- {% endif %}
- {% endfor %}
-
-{% if filter %}
-Filtre actif :
{{ filter }} —
Tout voir
-{% endif %}
-{% endif %}
-
-
-
-
-
{{ total_filtered }} serveur(s)
-
-
-{% if audits %}
-
-
- {% if total_pages > 1 %}
- {% set qs %}{% if filter %}&filter={{ filter }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if sort %}&sort={{ sort }}&dir={{ sort_dir }}{% endif %}{% endset %}
-
-
Page {{ page }}/{{ total_pages }} ({{ total_filtered }} serveurs)
-
- {% if page > 1 %}
-
1
- {% if page > 2 %}
< {% endif %}
- {% endif %}
-
{{ page }}
- {% if page < total_pages %}
-
>
-
{{ total_pages }}
- {% endif %}
-
-
- {% endif %}
-
-{% else %}
-
-
Aucun audit{% if search or domain or filter %} correspondant aux filtres{% endif %}.
- {% if not search and not domain and not filter %}
-
Lancez le standalone sur vos serveurs puis importez le JSON ici.
- {% endif %}
-
-{% endif %}
-{% endblock %}
diff --git a/app/templates/audit_full_patching.html b/app/templates/audit_full_patching.html
deleted file mode 100644
index ff2cfec..0000000
--- a/app/templates/audit_full_patching.html
+++ /dev/null
@@ -1,214 +0,0 @@
-{% extends 'base.html' %}
-{% block title %}Patching {{ year }}{% endblock %}
-{% block content %}
-
-
-
-{% if kpis_secops and kpis_other %}
-
-{% endif %}
-
-
-{% if kpis %}
-{% set pct = (kpis.patched / kpis.total * 100)|int if kpis.total > 0 else 0 %}
-
-
-
{{ kpis.patched }}
Patchés
-
-
-
-
-
-
{{ pct }}%
-
Couverture
-
-
-
-
-
-{% if compare and year == 2026 %}
-{% set pct_current = (compare.current_patched / compare.current_total * 100)|int if compare.current_total > 0 else 0 %}
-{% set pct_prev_same = (compare.prev_at_same_week / compare.prev_total * 100)|int if compare.prev_total > 0 else 0 %}
-{% set pct_prev_total = (compare.prev_year_total / compare.current_total * 100)|int if compare.current_total > 0 else 0 %}
-{% set diff_same = pct_current - pct_prev_same %}
-
-
- Comparaison à même semaine (S{{ compare.compare_week }})
- {% if not compare.prev_data_ok %}Données 2025 incomplètes {% endif %}
-
-
-
-
-
- 2026 (S{{ compare.compare_week }})
- {{ compare.current_patched }} / {{ compare.current_total }} ({{ pct_current }}%)
-
-
-
-
-
-
- 2025 (S{{ compare.compare_week }})
- {{ compare.prev_at_same_week }} / {{ compare.prev_total }} ({{ pct_prev_same }}%)
-
-
-
-
-
-
- {% if diff_same >= 0 %}+{% endif %}{{ diff_same }} pts
-
-
vs 2025 même semaine
-
-
-
-
- 2025 année complète : {{ compare.prev_year_total }} patchés ({{ pct_prev_total }}%)
- Objectif 2026 : dépasser {{ compare.prev_year_total }}
-
-
-{% endif %}
-
-
-
- {% if patch_weekly %}
-
-
Serveurs par semaine vert=patché rouge=annulé/reporté
-
- {% set max_cnt = patch_weekly|map(attribute='patched')|map('int')|max %}
- {% for w in patch_weekly %}
- {% set total = (w.patched|int) + (w.cancelled|int) %}
- {% set max_total = max_cnt if max_cnt > 0 else 1 %}
-
-
{{ total }}
-
- {% if w.cancelled|int > 0 %}
{% endif %}
-
-
-
{{ w.week }}
-
- {% endfor %}
-
-
- {% endif %}
-
-
-{% endif %}
-
-
-
-
-
{{ total_filtered }} serveur(s)
-
-
-
-{% set qs %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if scope %}&scope={{ scope }}{% endif %}{% endset %}
-
-{% endblock %}
diff --git a/app/templates/safe_patching.html b/app/templates/safe_patching.html
deleted file mode 100644
index dbfc6be..0000000
--- a/app/templates/safe_patching.html
+++ /dev/null
@@ -1,84 +0,0 @@
-{% extends 'base.html' %}
-{% block title %}Safe Patching{% endblock %}
-{% block content %}
-
-
-
Safe Patching — Quick Win
-
Patching sans interruption de service : exclut tout ce qui nécessite un reboot ou un restart.
-
-
- {% if can_create %}
-
Nouvelle campagne
- {% endif %}
-
Planning
-
-
-
-{% if msg %}
-
- {% if msg == 'error' %}Erreur à la création (semaine déjà existante ?).{% elif msg == 'deleted' %}Campagne supprimée.{% endif %}
-
-{% endif %}
-
-
-{% if campaigns %}
-
-{% endif %}
-
-
-{% if can_create %}
-
-{% endif %}
-{% endblock %}
diff --git a/app/templates/safe_patching_detail.html b/app/templates/safe_patching_detail.html
deleted file mode 100644
index f4761b7..0000000
--- a/app/templates/safe_patching_detail.html
+++ /dev/null
@@ -1,215 +0,0 @@
-{% extends 'base.html' %}
-{% block title %}{{ c.label }}{% endblock %}
-{% block content %}
-
-
-
- {% set p = perms if perms is defined else request.state.perms %}
- {% if p.campaigns == 'admin' %}
-
- {% endif %}
-
-
-{% if msg %}
-
- {% 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 %}
-
-{% endif %}
-
-
-
-
-
Branche 1 — Hors-prod
-
- {{ qw_stats.hprod_total }} total
- {{ qw_stats.hprod_patched }} patchés
- {{ qw_stats.hprod_failed }} échoués
-
-
-
-
Branche 2 — Production
-
- {{ qw_stats.prod_total }} total
- {{ qw_stats.prod_patched }} patchés
- {{ qw_stats.prod_failed }} échoués
-
-
-
-
-
-
-
-
-
- {% for s in ['prereqs','snapshot','execute','postcheck'] %}
-
- {{ loop.index }}. {% if s == 'prereqs' %}Prérequis{% elif s == 'snapshot' %}Snapshot{% elif s == 'execute' %}Exécution{% elif s == 'postcheck' %}Post-patch{% endif %}
-
- {% endfor %}
-
-
-
-
-
-
Step 1 — Vérification prérequis
-
-
-
-
-
-
- 0
-
-
-
-
-
-
-
-
Step 2 — Snapshot vSphere
-
Créer un snapshot sur toutes les VMs avant patching. Les serveurs physiques sont ignorés.
-
-
-
-
-
-
Step 3 — Exécution Safe Patching
-
-
-
Commande yum (éditable)
-
-
{{ safe_excludes|length }} packages exclus. Modifiez si besoin avant de lancer.
-
-
-
-
- {% if qw_stats.hprod_total > 0 and qw_stats.hprod_patched == qw_stats.hprod_total %}
-
- {% else %}
- Production disponible après hors-prod à 100%
- {% endif %}
-
-
-
-
-
-
Step 4 — Vérification post-patch
-
Vérifier les services, ports et needs-restarting après patching.
-
-
-
-
-
-
- Hostname
- Env
- Statut
- Packages
- Reboot
- Services
-
-
- {% for s in sessions %}
- {% if s.status in ('patched', 'failed') %}
-
- {{ s.hostname }}
- {{ (s.environnement or '')[:6] }}
- {{ s.status }}
- {{ s.packages_updated or 0 }}
- {% if s.reboot_required %}Oui {% else %}Non {% endif %}
- {% if s.postcheck_services == 'ok' %}OK {% elif s.postcheck_services == 'ko' %}KO {% else %}— {% endif %}
-
- {% endif %}
- {% endfor %}
-
-
-
-
-
-
-{% if excluded %}
-
- {{ excluded|length }} serveur(s) exclu(s)
-
- {% for s in excluded %}{{ s.hostname }}{% if not loop.last %}, {% endif %}{% endfor %}
-
-
-{% endif %}
-
-
-{% endblock %}
diff --git a/app/templates/safe_patching_terminal.html b/app/templates/safe_patching_terminal.html
deleted file mode 100644
index 3a030bc..0000000
--- a/app/templates/safe_patching_terminal.html
+++ /dev/null
@@ -1,79 +0,0 @@
-{% extends 'base.html' %}
-{% block title %}Terminal — {{ c.label }}{% endblock %}
-{% block content %}
-
-
-
← Retour campagne
-
{{ c.label }} — Exécution {{ 'Hors-prod' if branch == 'hprod' else 'Production' }}
-
-
- En cours
- 0 traité(s)
-
-
-
-
-
-
-
-
-
- PatchCenter Terminal — Safe Patching
-
-
-
Connexion au stream...
-
-
-
-
-
-
-{% endblock %}