Remove: safe-patching (remplace par QuickWin) + audit-full
- Safe Patching v1 redondant avec QuickWin, supprime - audit-full: page supprimee, tables DB preservees - menu + main.py nettoyes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3f47fea8e6
commit
ba0bff0f6e
@ -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)
|
|
||||||
@ -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"}
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
@ -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()
|
|
||||||
@ -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
|
|
||||||
@ -1,241 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}{{ a.hostname }}{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<a href="/audit-full" class="text-xs text-gray-500 hover:text-gray-300">< Retour</a>
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-bold text-cyber-accent">{{ a.hostname }}</h2>
|
|
||||||
<p class="text-xs text-gray-500">{{ a.os_release }} | {{ a.kernel }} | {{ a.uptime }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 text-xs">
|
|
||||||
{% for iface in interfaces %}{% if iface.ip != '127.0.0.1' %}
|
|
||||||
<span class="badge badge-blue">{{ iface.ip }}{{ iface.mask }} ({{ iface.iface }})</span>
|
|
||||||
{% endif %}{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if is_partial %}
|
|
||||||
<div class="card p-8 text-center mb-4" style="background:#111827;">
|
|
||||||
<div class="text-3xl font-bold text-gray-500 mb-2">Pas encore audité</div>
|
|
||||||
<p class="text-sm text-gray-600">Ce serveur n'a pas encore été audité via SSH (Windows, EMV...)</p>
|
|
||||||
<p class="text-xs text-gray-600 mt-2">{{ a.hostname }} — {{ a.os_release or 'OS inconnu' }}</p>
|
|
||||||
{% if a.last_patch_week %}<p class="text-xs text-cyber-green mt-2">Dernier patch : {{ a.last_patch_week }} {{ a.last_patch_year }}</p>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<!-- KPI -->
|
|
||||||
<div style="display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:16px;">
|
|
||||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-base font-bold text-cyber-accent">{{ services|length }}</div><div class="text-xs text-gray-500">Services</div></div>
|
|
||||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-base font-bold text-cyber-accent">{{ processes|length }}</div><div class="text-xs text-gray-500">Process</div></div>
|
|
||||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-base font-bold text-cyber-accent">{{ listen_ports|length }}</div><div class="text-xs text-gray-500">Ports</div></div>
|
|
||||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-base font-bold text-cyber-accent">{{ connections|length }}</div><div class="text-xs text-gray-500">Connexions</div></div>
|
|
||||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-base font-bold {% if a.reboot_required %}text-cyber-red{% else %}text-cyber-green{% endif %}">{% if a.reboot_required %}Oui{% else %}Non{% endif %}</div><div class="text-xs text-gray-500">Reboot</div></div>
|
|
||||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-base font-bold {% if a.services_failed %}text-cyber-red{% else %}text-cyber-green{% endif %}">{% if a.services_failed %}KO{% else %}OK{% endif %}</div><div class="text-xs text-gray-500">Failed svc</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Onglets -->
|
|
||||||
<div x-data="{ tab: 'services' }">
|
|
||||||
<div class="flex gap-1 mb-3 flex-wrap">
|
|
||||||
{% for t, label in [('services','Services'),('processes','Processus'),('ports','Ports'),('connections','Connexions'),('flux','Flux'),('disk','Disque'),('firewall','Firewall'),('correlation','Corrélation')] %}
|
|
||||||
<button @click="tab='{{ t }}'" class="px-3 py-1 text-xs rounded" :class="tab==='{{ t }}' ? 'bg-cyber-accent text-black font-bold' : 'bg-cyber-border text-gray-400'">{{ label }}</button>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Services -->
|
|
||||||
<div x-show="tab==='services'" class="card overflow-x-auto">
|
|
||||||
<table class="w-full table-cyber text-xs"><thead><tr>
|
|
||||||
<th class="text-left p-2">Service</th><th class="p-2">Enabled</th><th class="p-2">PID</th><th class="p-2">User</th><th class="text-left p-2">Exec</th>
|
|
||||||
</tr></thead><tbody>
|
|
||||||
{% for s in services %}<tr>
|
|
||||||
<td class="p-2 font-mono text-cyber-accent">{{ s.name }}</td>
|
|
||||||
<td class="p-2 text-center"><span class="badge {% if s.enabled == 'enabled' %}badge-green{% else %}badge-gray{% endif %}">{{ s.enabled }}</span></td>
|
|
||||||
<td class="p-2 text-center font-mono">{{ s.pid }}</td>
|
|
||||||
<td class="p-2 text-center">{{ s.user }}</td>
|
|
||||||
<td class="p-2 text-gray-400">{{ s.exec[:80] }}</td>
|
|
||||||
</tr>{% endfor %}
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Processus -->
|
|
||||||
<div x-show="tab==='processes'" class="card overflow-x-auto">
|
|
||||||
<table class="w-full table-cyber text-xs"><thead><tr>
|
|
||||||
<th class="p-2">PID</th><th class="p-2">User</th><th class="text-left p-2">Exe</th><th class="text-left p-2">CWD</th><th class="text-left p-2">Cmdline</th><th class="text-left p-2">Restart</th>
|
|
||||||
</tr></thead><tbody>
|
|
||||||
{% for p in processes %}<tr class="{% if '/applis' in (p.cwd or '') %}bg-green-900/10{% endif %}">
|
|
||||||
<td class="p-2 font-mono text-center">{{ p.pid }}</td>
|
|
||||||
<td class="p-2 text-center">{{ p.user }}</td>
|
|
||||||
<td class="p-2 font-mono text-gray-400" style="max-width:200px;word-break:break-all;">{{ p.exe or '' }}</td>
|
|
||||||
<td class="p-2 font-mono text-gray-500" style="max-width:180px;word-break:break-all;">{{ p.cwd or '' }}</td>
|
|
||||||
<td class="p-2 font-mono text-cyber-accent" style="max-width:300px;word-break:break-all;">{{ p.cmdline or '' }}</td>
|
|
||||||
<td class="p-2 font-mono text-cyber-yellow" style="word-break:break-all;">{{ p.restart_hint or '' }}</td>
|
|
||||||
</tr>{% endfor %}
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ports -->
|
|
||||||
<div x-show="tab==='ports'" class="card overflow-x-auto">
|
|
||||||
<table class="w-full table-cyber text-xs"><thead><tr>
|
|
||||||
<th class="p-2">Proto</th><th class="p-2">Addr:Port</th><th class="p-2">PID</th><th class="p-2">Process</th><th class="p-2">User</th><th class="p-2">Service</th>
|
|
||||||
</tr></thead><tbody>
|
|
||||||
{% for lp in listen_ports %}<tr>
|
|
||||||
<td class="p-2 text-center">{{ lp.proto }}</td>
|
|
||||||
<td class="p-2 font-mono text-cyber-accent">{{ lp.addr_port }}</td>
|
|
||||||
<td class="p-2 text-center font-mono">{{ lp.pid }}</td>
|
|
||||||
<td class="p-2 text-center">{{ lp.process }}</td>
|
|
||||||
<td class="p-2 text-center">{{ lp.user }}</td>
|
|
||||||
<td class="p-2 text-center text-gray-400">{{ lp.service }}</td>
|
|
||||||
</tr>{% endfor %}
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connexions -->
|
|
||||||
<div x-show="tab==='connections'" class="card overflow-x-auto">
|
|
||||||
<table class="w-full table-cyber text-xs"><thead><tr>
|
|
||||||
<th class="p-2">Dir</th><th class="p-2">Local</th><th class="p-2">Remote</th><th class="p-2">PID</th><th class="p-2">Process</th><th class="p-2">User</th><th class="p-2">State</th>
|
|
||||||
</tr></thead><tbody>
|
|
||||||
{% for c in connections %}<tr class="{% if c.state == 'CLOSE-WAIT' %}bg-red-900/10{% endif %}">
|
|
||||||
<td class="p-2 text-center"><span class="badge {% if c.direction == 'IN' %}badge-green{% else %}badge-yellow{% endif %}">{{ c.direction }}</span></td>
|
|
||||||
<td class="p-2 font-mono">{{ c.local }}</td>
|
|
||||||
<td class="p-2 font-mono text-cyber-accent">{{ c.remote }}</td>
|
|
||||||
<td class="p-2 text-center font-mono">{{ c.pid }}</td>
|
|
||||||
<td class="p-2 text-center">{{ c.process }}</td>
|
|
||||||
<td class="p-2 text-center">{{ c.user }}</td>
|
|
||||||
<td class="p-2 text-center"><span class="badge {% if c.state == 'ESTAB' %}badge-green{% elif c.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %}">{{ c.state }}</span></td>
|
|
||||||
</tr>{% endfor %}
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Flux -->
|
|
||||||
<div x-show="tab==='flux'" class="space-y-3">
|
|
||||||
{% if flux_in %}
|
|
||||||
<div class="card overflow-x-auto">
|
|
||||||
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-green">Flux entrants</span></div>
|
|
||||||
<table class="w-full table-cyber text-xs"><thead><tr>
|
|
||||||
<th class="p-2">Port</th><th class="p-2">Service</th><th class="p-2">Process</th><th class="p-2">Nb</th><th class="text-left p-2">Sources</th>
|
|
||||||
</tr></thead><tbody>
|
|
||||||
{% for f in flux_in %}<tr>
|
|
||||||
<td class="p-2 text-center font-mono text-cyber-accent">{{ f.port }}</td>
|
|
||||||
<td class="p-2 text-center">{{ f.service }}</td>
|
|
||||||
<td class="p-2 text-center">{{ f.process }}</td>
|
|
||||||
<td class="p-2 text-center font-bold">{{ f.count }}</td>
|
|
||||||
<td class="p-2 font-mono text-gray-400">{{ f.sources }}</td>
|
|
||||||
</tr>{% endfor %}
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if flux_out %}
|
|
||||||
<div class="card overflow-x-auto">
|
|
||||||
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-yellow">Flux sortants</span></div>
|
|
||||||
<table class="w-full table-cyber text-xs"><thead><tr>
|
|
||||||
<th class="text-left p-2">Destination</th><th class="p-2">Port</th><th class="p-2">Service</th><th class="p-2">Process</th><th class="p-2">Nb</th>
|
|
||||||
</tr></thead><tbody>
|
|
||||||
{% for f in flux_out %}<tr>
|
|
||||||
<td class="p-2 font-mono text-cyber-accent">{{ f.dest_ip }}</td>
|
|
||||||
<td class="p-2 text-center">{{ f.dest_port }}</td>
|
|
||||||
<td class="p-2 text-center">{{ f.service }}</td>
|
|
||||||
<td class="p-2 text-center">{{ f.process }}</td>
|
|
||||||
<td class="p-2 text-center font-bold">{{ f.count }}</td>
|
|
||||||
</tr>{% endfor %}
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if flows %}
|
|
||||||
<div class="card overflow-x-auto">
|
|
||||||
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-accent">Carte flux (resolu)</span></div>
|
|
||||||
<table class="w-full table-cyber text-xs"><thead><tr>
|
|
||||||
<th class="p-2">Dir</th><th class="p-2">IP dest</th><th class="p-2">Port</th><th class="p-2">Serveur dest</th><th class="p-2">Process</th><th class="p-2">State</th>
|
|
||||||
</tr></thead><tbody>
|
|
||||||
{% for f in flows %}<tr>
|
|
||||||
<td class="p-2 text-center"><span class="badge {% if f.direction == 'IN' %}badge-green{% else %}badge-yellow{% endif %}">{{ f.direction }}</span></td>
|
|
||||||
<td class="p-2 font-mono">{{ f.dest_ip }}</td>
|
|
||||||
<td class="p-2 text-center">{{ f.dest_port }}</td>
|
|
||||||
<td class="p-2 font-mono text-cyber-accent">{{ f.dest_hostname or '-' }}</td>
|
|
||||||
<td class="p-2 text-center">{{ f.process_name }}</td>
|
|
||||||
<td class="p-2 text-center"><span class="badge {% if f.state == 'ESTAB' %}badge-green{% elif f.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %}">{{ f.state }}</span></td>
|
|
||||||
</tr>{% endfor %}
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Disque -->
|
|
||||||
<div x-show="tab==='disk'" class="card overflow-x-auto">
|
|
||||||
<table class="w-full table-cyber text-xs"><thead><tr>
|
|
||||||
<th class="text-left p-2">Mount</th><th class="p-2">Taille</th><th class="p-2">Utilise</th><th class="p-2">Dispo</th><th class="p-2">%</th>
|
|
||||||
</tr></thead><tbody>
|
|
||||||
{% for d in disk_usage %}<tr class="{% if d.pct >= 90 %}bg-red-900/20{% elif d.pct >= 80 %}bg-yellow-900/10{% endif %}">
|
|
||||||
<td class="p-2 font-mono">{{ d.mount }}</td>
|
|
||||||
<td class="p-2 text-center">{{ d.size }}</td>
|
|
||||||
<td class="p-2 text-center">{{ d.used }}</td>
|
|
||||||
<td class="p-2 text-center">{{ d.avail }}</td>
|
|
||||||
<td class="p-2 text-center font-bold {% if d.pct >= 90 %}text-cyber-red{% elif d.pct >= 80 %}text-cyber-yellow{% else %}text-cyber-green{% endif %}">{{ d.pct }}%</td>
|
|
||||||
</tr>{% endfor %}
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Firewall -->
|
|
||||||
<div x-show="tab==='firewall'" class="space-y-3">
|
|
||||||
{% if firewall.policy %}
|
|
||||||
<div class="card p-3">
|
|
||||||
<span class="text-xs font-bold text-cyber-accent">Policy iptables :</span>
|
|
||||||
{% for chain, pol in firewall.policy.items() %}
|
|
||||||
<span class="badge {% if pol == 'DROP' %}badge-red{% elif pol == 'ACCEPT' %}badge-green{% else %}badge-gray{% endif %} ml-2">{{ chain }}={{ pol or '?' }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if firewall.firewalld %}
|
|
||||||
<div class="card p-3">
|
|
||||||
<span class="text-xs font-bold text-cyber-accent">Firewalld :</span>
|
|
||||||
{% for z in firewall.firewalld %}
|
|
||||||
<div class="mt-1 text-xs">Zone <span class="text-cyber-yellow">{{ z.zone }}</span> : services={{ z.services }} ports={{ z.ports }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if conn_wait %}
|
|
||||||
<div class="card p-3">
|
|
||||||
<span class="text-xs font-bold text-cyber-accent">Connexions en attente :</span>
|
|
||||||
{% for cw in conn_wait %}
|
|
||||||
<span class="badge {% if cw.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %} ml-2">{{ cw.state }}={{ cw.count }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Corrélation -->
|
|
||||||
<div x-show="tab==='correlation'" class="space-y-3">
|
|
||||||
{% if correlation %}
|
|
||||||
<div class="card overflow-x-auto">
|
|
||||||
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-accent">Matrice process / ports / flux</span></div>
|
|
||||||
<table class="w-full table-cyber text-xs"><thead><tr>
|
|
||||||
<th class="text-left p-2">Process</th><th class="p-2">User</th><th class="p-2">PID</th><th class="p-2">Ports</th><th class="p-2">IN</th><th class="p-2">OUT</th><th class="text-left p-2">Destinations</th>
|
|
||||||
</tr></thead><tbody>
|
|
||||||
{% for c in correlation %}<tr>
|
|
||||||
<td class="p-2 font-mono text-cyber-accent">{{ c.process }}</td>
|
|
||||||
<td class="p-2 text-center">{{ c.user }}</td>
|
|
||||||
<td class="p-2 text-center font-mono">{{ c.pid }}</td>
|
|
||||||
<td class="p-2 text-center font-mono text-cyber-yellow">{{ c.listen_ports }}</td>
|
|
||||||
<td class="p-2 text-center font-bold text-cyber-green">{{ c.conn_in }}</td>
|
|
||||||
<td class="p-2 text-center font-bold text-cyber-yellow">{{ c.conn_out }}</td>
|
|
||||||
<td class="p-2 font-mono text-gray-400">{{ c.remote_dests }}</td>
|
|
||||||
</tr>{% endfor %}
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if outbound %}
|
|
||||||
<div class="card overflow-x-auto">
|
|
||||||
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-yellow">Process sortants uniquement</span></div>
|
|
||||||
<table class="w-full table-cyber text-xs"><thead><tr>
|
|
||||||
<th class="text-left p-2">Process</th><th class="p-2">User</th><th class="p-2">PID</th><th class="text-left p-2">Destinations</th>
|
|
||||||
</tr></thead><tbody>
|
|
||||||
{% for o in outbound %}<tr>
|
|
||||||
<td class="p-2 font-mono text-cyber-accent">{{ o.process }}</td>
|
|
||||||
<td class="p-2 text-center">{{ o.user }}</td>
|
|
||||||
<td class="p-2 text-center font-mono">{{ o.pid }}</td>
|
|
||||||
<td class="p-2 font-mono text-gray-400">{{ o.dests }}</td>
|
|
||||||
</tr>{% endfor %}
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}{# end is_partial else #}
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,282 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}Carte flux{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<a href="/audit-full" class="text-xs text-gray-500 hover:text-gray-300">< Retour</a>
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h2 class="text-xl font-bold text-cyber-accent">Carte des flux réseau</h2>
|
|
||||||
<button onclick="resetZoom()" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">Reset vue</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filtres -->
|
|
||||||
<div class="card p-3 mb-4 flex gap-3 items-center flex-wrap">
|
|
||||||
<form method="GET" action="/audit-full/flow-map" class="flex gap-2 items-center flex-1">
|
|
||||||
<select name="domain" class="text-xs py-1 px-2" onchange="this.form.submit()">
|
|
||||||
<option value="">Tous</option>
|
|
||||||
<optgroup label="Zones">
|
|
||||||
{% for z in all_zones %}<option value="{{ z.code }}" {% if domain_filter == z.code %}selected{% endif %}>{{ z.name }}</option>{% endfor %}
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Domaines">
|
|
||||||
{% for d in all_domains %}<option value="{{ d.code }}" {% if domain_filter == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
<div style="position:relative" class="flex-1">
|
|
||||||
<input type="text" name="server" value="{{ server_filter }}" placeholder="Serveur (ex: vptrabkme1)..." class="text-xs py-1 px-2 w-full font-mono" id="server-input" autocomplete="off" list="server-list">
|
|
||||||
<datalist id="server-list">
|
|
||||||
{% for s in audited_servers %}<option value="{{ s.hostname }}">{% endfor %}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Generer</button>
|
|
||||||
{% if domain_filter or server_filter %}<a href="/audit-full/flow-map" class="text-xs text-gray-400 hover:text-cyber-accent">Reset</a>{% endif %}
|
|
||||||
</form>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
{% if server_filter %}Serveur: <span class="text-cyber-accent font-mono">{{ server_filter }}</span>
|
|
||||||
{% elif domain_filter %}Domaine/Zone: <span class="text-cyber-accent">{{ domain_filter }}</span>
|
|
||||||
{% else %}Vue globale{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Map SVG -->
|
|
||||||
<div class="card" style="position:relative;overflow:hidden;height:700px;background:#0a0e17;" id="map-container">
|
|
||||||
<svg id="flow-svg" width="100%" height="100%" style="cursor:grab;">
|
|
||||||
<defs>
|
|
||||||
<marker id="arrow" viewBox="0 0 10 6" refX="10" refY="3" markerWidth="8" markerHeight="6" orient="auto">
|
|
||||||
<path d="M0,0 L10,3 L0,6 Z" fill="#22c55e" opacity="0.6"/>
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
<g id="svg-root">
|
|
||||||
<g id="links-layer"></g>
|
|
||||||
<g id="nodes-layer"></g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<div id="tooltip" style="display:none;position:absolute;background:#1a1f2e;border:1px solid #22c55e;padding:6px 10px;border-radius:4px;font-size:11px;color:#e2e8f0;pointer-events:none;z-index:10;max-width:300px;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Legende -->
|
|
||||||
<div class="flex gap-4 mt-2 text-xs text-gray-500">
|
|
||||||
<span><span style="color:#22c55e;">---></span> Flux réseau</span>
|
|
||||||
<span id="stats-nodes">0 serveurs</span>
|
|
||||||
<span id="stats-links">0 flux</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tableau flux en dessous -->
|
|
||||||
{% if flows %}
|
|
||||||
<details class="card mt-4">
|
|
||||||
<summary class="p-3 cursor-pointer text-sm text-gray-400">Tableau des flux ({{ flows|length }})</summary>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full table-cyber text-xs">
|
|
||||||
<thead><tr>
|
|
||||||
<th class="p-2">Dir</th><th class="text-left p-2">Source</th><th class="text-left p-2">Destination</th>
|
|
||||||
<th class="p-2">Port</th><th class="p-2">Process</th><th class="p-2">State</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for f in flows %}
|
|
||||||
<tr class="{% if f.state == 'CLOSE-WAIT' %}bg-red-900/10{% endif %}">
|
|
||||||
<td class="p-2 text-center"><span class="badge {% if f.direction == 'IN' %}badge-green{% else %}badge-yellow{% endif %}">{{ f.direction }}</span></td>
|
|
||||||
<td class="p-2 font-mono text-cyber-accent">{{ f.source_hostname }}</td>
|
|
||||||
<td class="p-2 font-mono {% if f.dest_hostname %}text-cyber-accent{% else %}text-gray-400{% endif %}">{{ f.dest_hostname or f.dest_ip }}</td>
|
|
||||||
<td class="p-2 text-center font-bold">{{ f.dest_port }}</td>
|
|
||||||
<td class="p-2 text-center">{{ f.process_name }}</td>
|
|
||||||
<td class="p-2 text-center"><span class="badge {% if f.state == 'ESTAB' %}badge-green{% elif f.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %}">{{ f.state }}</span></td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if app_map %}
|
|
||||||
<details class="card mt-4">
|
|
||||||
<summary class="p-3 cursor-pointer text-sm text-gray-400">Carte applicative ({{ app_map|length }} applis)</summary>
|
|
||||||
<div class="grid grid-cols-2 gap-3 p-3">
|
|
||||||
{% for app_name, app in app_map.items() %}
|
|
||||||
<div class="card p-3">
|
|
||||||
<span class="text-sm font-bold text-cyber-yellow">{{ app_name }}</span>
|
|
||||||
<span class="badge badge-blue ml-2">{{ app.servers|length }}</span>
|
|
||||||
{% if app.ports %}<span class="text-xs text-gray-500 ml-2">ports: {{ app.ports|join(', ') }}</span>{% endif %}
|
|
||||||
<div class="mt-1">{% for s in app.servers %}<span class="text-xs font-mono text-cyber-accent">{{ s.hostname }}</span>{% if not loop.last %}, {% endif %}{% endfor %}</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Donnees flux depuis le serveur — uniquement inter-serveurs (dest_hostname connu), pas de self-loop, dedupliques
|
|
||||||
var rawFlows = [
|
|
||||||
{% for f in flows %}{% if f.dest_hostname and f.source_hostname != f.dest_hostname %}
|
|
||||||
{src:"{{f.source_hostname}}",dst:"{{f.dest_hostname}}",port:{{f.dest_port}},proc:"{{f.process_name}}",state:"{{f.state}}",cnt:{{f.cnt}}},
|
|
||||||
{% endif %}{% endfor %}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Deduplication: une seule fleche par paire src->dst (agglomerer les ports)
|
|
||||||
var linkMap = {};
|
|
||||||
rawFlows.forEach(function(f) {
|
|
||||||
var key = f.src + ">" + f.dst;
|
|
||||||
if (!linkMap[key]) {
|
|
||||||
linkMap[key] = {src: f.src, dst: f.dst, ports: [], procs: [], cnt: 0};
|
|
||||||
}
|
|
||||||
if (linkMap[key].ports.indexOf(f.port) === -1) linkMap[key].ports.push(f.port);
|
|
||||||
if (f.proc && linkMap[key].procs.indexOf(f.proc) === -1) linkMap[key].procs.push(f.proc);
|
|
||||||
linkMap[key].cnt += f.cnt;
|
|
||||||
});
|
|
||||||
var links = Object.values(linkMap);
|
|
||||||
|
|
||||||
// Noeuds uniques
|
|
||||||
var nodeSet = {};
|
|
||||||
links.forEach(function(l) { nodeSet[l.src] = true; nodeSet[l.dst] = true; });
|
|
||||||
var nodes = Object.keys(nodeSet).map(function(name, i) {
|
|
||||||
return {id: name, x: 400 + Math.random() * 600, y: 200 + Math.random() * 400, vx: 0, vy: 0};
|
|
||||||
});
|
|
||||||
var nodeIdx = {};
|
|
||||||
nodes.forEach(function(n, i) { nodeIdx[n.id] = i; });
|
|
||||||
|
|
||||||
document.getElementById("stats-nodes").textContent = nodes.length + " serveurs";
|
|
||||||
document.getElementById("stats-links").textContent = links.length + " flux";
|
|
||||||
|
|
||||||
// Force-directed layout
|
|
||||||
var W = document.getElementById("map-container").clientWidth;
|
|
||||||
var H = 700;
|
|
||||||
var repulsion = 800;
|
|
||||||
var attraction = 0.005;
|
|
||||||
var damping = 0.85;
|
|
||||||
var iterations = 300;
|
|
||||||
|
|
||||||
for (var iter = 0; iter < iterations; iter++) {
|
|
||||||
// Repulsion entre noeuds
|
|
||||||
for (var i = 0; i < nodes.length; i++) {
|
|
||||||
for (var j = i + 1; j < nodes.length; j++) {
|
|
||||||
var dx = nodes[i].x - nodes[j].x;
|
|
||||||
var dy = nodes[i].y - nodes[j].y;
|
|
||||||
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
||||||
var force = repulsion / (dist * dist);
|
|
||||||
var fx = dx / dist * force;
|
|
||||||
var fy = dy / dist * force;
|
|
||||||
nodes[i].vx += fx; nodes[i].vy += fy;
|
|
||||||
nodes[j].vx -= fx; nodes[j].vy -= fy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Attraction via liens
|
|
||||||
links.forEach(function(l) {
|
|
||||||
var a = nodes[nodeIdx[l.src]], b = nodes[nodeIdx[l.dst]];
|
|
||||||
if (!a || !b) return;
|
|
||||||
var dx = b.x - a.x, dy = b.y - a.y;
|
|
||||||
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
||||||
var force = dist * attraction;
|
|
||||||
a.vx += dx / dist * force; a.vy += dy / dist * force;
|
|
||||||
b.vx -= dx / dist * force; b.vy -= dy / dist * force;
|
|
||||||
});
|
|
||||||
// Appliquer + damping
|
|
||||||
nodes.forEach(function(n) {
|
|
||||||
n.x += n.vx; n.y += n.vy;
|
|
||||||
n.vx *= damping; n.vy *= damping;
|
|
||||||
n.x = Math.max(60, Math.min(W - 60, n.x));
|
|
||||||
n.y = Math.max(40, Math.min(H - 40, n.y));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendu SVG
|
|
||||||
var svgRoot = document.getElementById("svg-root");
|
|
||||||
var linksLayer = document.getElementById("links-layer");
|
|
||||||
var nodesLayer = document.getElementById("nodes-layer");
|
|
||||||
var tooltip = document.getElementById("tooltip");
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
linksLayer.innerHTML = "";
|
|
||||||
nodesLayer.innerHTML = "";
|
|
||||||
|
|
||||||
links.forEach(function(l) {
|
|
||||||
var a = nodes[nodeIdx[l.src]], b = nodes[nodeIdx[l.dst]];
|
|
||||||
if (!a || !b || a.hidden || b.hidden) return;
|
|
||||||
var line = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
|
||||||
line.setAttribute("x1", a.x); line.setAttribute("y1", a.y);
|
|
||||||
line.setAttribute("x2", b.x); line.setAttribute("y2", b.y);
|
|
||||||
line.setAttribute("stroke", "#22c55e");
|
|
||||||
line.setAttribute("stroke-width", Math.min(3, 0.5 + l.cnt * 0.1));
|
|
||||||
line.setAttribute("stroke-opacity", "0.5");
|
|
||||||
line.setAttribute("marker-end", "url(#arrow)");
|
|
||||||
line.onmouseenter = function(e) {
|
|
||||||
tooltip.style.display = "block";
|
|
||||||
tooltip.style.left = e.offsetX + 10 + "px";
|
|
||||||
tooltip.style.top = e.offsetY - 10 + "px";
|
|
||||||
tooltip.innerHTML = "<b>" + l.src + "</b> → <b>" + l.dst + "</b><br>Ports: " + l.ports.join(", ") + "<br>Process: " + l.procs.join(", ") + "<br>Connexions: " + l.cnt;
|
|
||||||
};
|
|
||||||
line.onmouseleave = function() { tooltip.style.display = "none"; };
|
|
||||||
linksLayer.appendChild(line);
|
|
||||||
});
|
|
||||||
|
|
||||||
nodes.forEach(function(n) {
|
|
||||||
if (n.hidden) return;
|
|
||||||
var g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
||||||
g.setAttribute("transform", "translate(" + n.x + "," + n.y + ")");
|
|
||||||
g.style.cursor = "pointer";
|
|
||||||
|
|
||||||
var isTarget = (serverFilter && n.id === serverFilter);
|
|
||||||
var circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
|
||||||
circle.setAttribute("r", isTarget ? "10" : "6");
|
|
||||||
circle.setAttribute("fill", isTarget ? "#facc15" : "#22c55e");
|
|
||||||
circle.setAttribute("stroke", isTarget ? "#facc15" : "#0a0e17");
|
|
||||||
circle.setAttribute("stroke-width", "2");
|
|
||||||
g.appendChild(circle);
|
|
||||||
|
|
||||||
var text = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
||||||
text.setAttribute("x", "9"); text.setAttribute("y", "4");
|
|
||||||
text.setAttribute("fill", "#94a3b8");
|
|
||||||
text.setAttribute("font-size", "9");
|
|
||||||
text.setAttribute("font-family", "monospace");
|
|
||||||
text.textContent = n.id;
|
|
||||||
g.appendChild(text);
|
|
||||||
|
|
||||||
g.onclick = function() { location.href = "/audit-full?q=" + n.id; };
|
|
||||||
g.onmouseenter = function() { circle.setAttribute("r", "8"); circle.setAttribute("fill", "#facc15"); };
|
|
||||||
g.onmouseleave = function() { circle.setAttribute("r", "6"); circle.setAttribute("fill", "#22c55e"); };
|
|
||||||
|
|
||||||
nodesLayer.appendChild(g);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
|
|
||||||
// Pan + Zoom
|
|
||||||
var svg = document.getElementById("flow-svg");
|
|
||||||
var viewBox = {x: 0, y: 0, w: W, h: H};
|
|
||||||
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
|
|
||||||
|
|
||||||
var isPanning = false, startX, startY;
|
|
||||||
svg.onmousedown = function(e) { isPanning = true; startX = e.clientX; startY = e.clientY; svg.style.cursor = "grabbing"; };
|
|
||||||
svg.onmousemove = function(e) {
|
|
||||||
if (!isPanning) return;
|
|
||||||
var dx = (e.clientX - startX) * viewBox.w / svg.clientWidth;
|
|
||||||
var dy = (e.clientY - startY) * viewBox.h / svg.clientHeight;
|
|
||||||
viewBox.x -= dx; viewBox.y -= dy;
|
|
||||||
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
|
|
||||||
startX = e.clientX; startY = e.clientY;
|
|
||||||
};
|
|
||||||
svg.onmouseup = function() { isPanning = false; svg.style.cursor = "grab"; };
|
|
||||||
svg.onmouseleave = function() { isPanning = false; svg.style.cursor = "grab"; };
|
|
||||||
svg.onwheel = function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var scale = e.deltaY > 0 ? 1.1 : 0.9;
|
|
||||||
var mx = e.offsetX / svg.clientWidth * viewBox.w + viewBox.x;
|
|
||||||
var my = e.offsetY / svg.clientHeight * viewBox.h + viewBox.y;
|
|
||||||
viewBox.w *= scale; viewBox.h *= scale;
|
|
||||||
viewBox.x = mx - (mx - viewBox.x) * scale;
|
|
||||||
viewBox.y = my - (my - viewBox.y) * scale;
|
|
||||||
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
|
|
||||||
};
|
|
||||||
|
|
||||||
function resetZoom() {
|
|
||||||
viewBox = {x: 0, y: 0, w: W, h: H};
|
|
||||||
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highlight du serveur filtre
|
|
||||||
var serverFilter = "{{ server_filter }}";
|
|
||||||
if (serverFilter && nodeIdx[serverFilter] !== undefined) {
|
|
||||||
var targetNode = nodes[nodeIdx[serverFilter]];
|
|
||||||
// Centrer la vue sur ce noeud
|
|
||||||
viewBox.x = targetNode.x - W / 2;
|
|
||||||
viewBox.y = targetNode.y - H / 2;
|
|
||||||
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}Audit complet{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-bold text-cyber-accent">Audit complet serveurs</h2>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Applicatif + réseau + corrélation — import JSON depuis le standalone</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 items-center">
|
|
||||||
<form method="POST" action="/audit-full/import" enctype="multipart/form-data" class="flex gap-2 items-center">
|
|
||||||
<input type="file" name="file" accept=".json" class="text-xs" required>
|
|
||||||
<button type="submit" class="btn-primary px-4 py-2 text-sm" data-loading="Import en cours...|Insertion des données">Importer JSON</button>
|
|
||||||
</form>
|
|
||||||
<a href="/audit-full/export-csv{% if filter %}?filter={{ filter }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="btn-sm bg-cyber-green text-black px-4 py-2">Exporter CSV</a>
|
|
||||||
<a href="/audit-full/flow-map" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Carte flux</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if msg %}
|
|
||||||
<div class="mb-3 p-2 rounded text-sm {% if 'error' in msg %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
|
|
||||||
{% 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 %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if kpis %}
|
|
||||||
<div class="flex flex-nowrap gap-2 mb-4" style="display:flex;flex-wrap:nowrap;">
|
|
||||||
<a href="/audit-full" class="card p-2 text-center flex-1 hover:bg-cyber-hover {% if not filter %}ring-1 ring-cyber-accent{% endif %}" style="min-width:0">
|
|
||||||
<div class="text-base font-bold text-cyber-accent">{{ kpis.total }}</div>
|
|
||||||
<div class="text-xs text-gray-500">Total</div>
|
|
||||||
</a>
|
|
||||||
<a href="/audit-full?filter=reboot" class="card p-2 text-center flex-1 hover:bg-cyber-hover {% if filter == 'reboot' %}ring-1 ring-cyber-red{% endif %}" style="min-width:0">
|
|
||||||
<div class="text-base font-bold {% if kpis.needs_reboot > 0 %}text-cyber-red{% else %}text-cyber-green{% endif %}">{{ kpis.needs_reboot }}</div>
|
|
||||||
<div class="text-xs text-gray-500">Reboot</div>
|
|
||||||
</a>
|
|
||||||
<a href="/audit-full?filter=disk_critical" class="card p-2 text-center flex-1 hover:bg-cyber-hover {% if filter == 'disk_critical' %}ring-1 ring-cyber-red{% endif %}" style="min-width:0">
|
|
||||||
<div class="text-base font-bold {% if kpis.disk_critical > 0 %}text-cyber-red{% else %}text-cyber-green{% endif %}">{{ kpis.disk_critical }}</div>
|
|
||||||
<div class="text-xs text-gray-500">Disque >= 90%</div>
|
|
||||||
</a>
|
|
||||||
<a href="/audit-full?filter=disk_warning" class="card p-2 text-center flex-1 hover:bg-cyber-hover {% if filter == 'disk_warning' %}ring-1 ring-cyber-yellow{% endif %}" style="min-width:0">
|
|
||||||
<div class="text-base font-bold {% if kpis.disk_warning > 0 %}text-cyber-yellow{% else %}text-cyber-green{% endif %}">{{ kpis.disk_warning }}</div>
|
|
||||||
<div class="text-xs text-gray-500">Disque >= 80%</div>
|
|
||||||
</a>
|
|
||||||
<a href="/audit-full?filter=uptime" class="card p-2 text-center flex-1 hover:bg-cyber-hover {% if filter == 'uptime' %}ring-1 ring-cyber-yellow{% endif %}" style="min-width:0">
|
|
||||||
<div class="text-base font-bold {% if kpis.uptime_long > 0 %}text-cyber-yellow{% else %}text-cyber-green{% endif %}">{{ kpis.uptime_long }}</div>
|
|
||||||
<div class="text-xs text-gray-500">Uptime > 4m</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<!-- KPIs applicatifs -->
|
|
||||||
<div style="display:flex;flex-wrap:nowrap;gap:4px;margin-bottom:12px;">
|
|
||||||
{% 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 %}
|
|
||||||
<a href="/audit-full?filter={{ key }}" class="card px-2 py-1 text-center hover:bg-cyber-hover {% if filter == key %}ring-1 ring-cyber-accent{% endif %}" style="min-width:0;flex:0 1 auto;">
|
|
||||||
<div class="text-sm font-bold {% if icon == 'db' %}text-blue-400{% elif icon == 'web' %}text-green-400{% else %}text-purple-400{% endif %}">{{ val }}</div>
|
|
||||||
<div style="font-size:10px;" class="text-gray-500 whitespace-nowrap">{{ label }}</div>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% if filter %}
|
|
||||||
<div class="mb-3 text-xs text-gray-400">Filtre actif : <span class="text-cyber-accent">{{ filter }}</span> — <a href="/audit-full" class="text-cyber-accent underline">Tout voir</a></div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Recherche + filtre domaine -->
|
|
||||||
<div class="card p-3 mb-4 flex gap-3 items-center flex-wrap">
|
|
||||||
<form method="GET" action="/audit-full" class="flex gap-2 items-center flex-1">
|
|
||||||
{% if filter %}<input type="hidden" name="filter" value="{{ filter }}">{% endif %}
|
|
||||||
<input type="text" name="q" value="{{ search }}" placeholder="Rechercher un serveur..." class="text-xs py-1 px-3 flex-1 min-w-[200px] font-mono">
|
|
||||||
<select name="domain" class="text-xs py-1 px-2" onchange="this.form.submit()">
|
|
||||||
<option value="">Tous</option>
|
|
||||||
<optgroup label="Zones">
|
|
||||||
{% for z in all_zones %}<option value="{{ z.code }}" {% if domain == z.code %}selected{% endif %}>{{ z.name }}</option>{% endfor %}
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Domaines">
|
|
||||||
{% for d in all_domains %}<option value="{{ d.code }}" {% if domain == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
|
|
||||||
{% if search or domain %}<a href="/audit-full{% if filter %}?filter={{ filter }}{% endif %}" class="text-xs text-gray-400 hover:text-cyber-accent">Reset</a>{% endif %}
|
|
||||||
</form>
|
|
||||||
<span class="text-xs text-gray-500">{{ total_filtered }} serveur(s)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if audits %}
|
|
||||||
<div class="card overflow-x-auto">
|
|
||||||
<table class="w-full table-cyber text-xs">
|
|
||||||
<thead><tr>
|
|
||||||
<th class="text-left p-2"><a href="/audit-full?sort=hostname&dir={% if sort == 'hostname' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}{% if filter %}&filter={{ filter }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="hover:text-cyber-accent">Hostname {% if sort == 'hostname' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
|
|
||||||
<th class="p-2">OS</th>
|
|
||||||
<th class="p-2">Kernel</th>
|
|
||||||
<th class="p-2"><a href="/audit-full?sort=uptime&dir={% if sort == 'uptime' and sort_dir == 'desc' %}asc{% else %}desc{% endif %}{% if filter %}&filter={{ filter }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="hover:text-cyber-accent">Uptime {% if sort == 'uptime' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
|
|
||||||
<th class="p-2">Services</th>
|
|
||||||
<th class="p-2">Process</th>
|
|
||||||
<th class="p-2">Ports</th>
|
|
||||||
<th class="p-2">Conn</th>
|
|
||||||
<th class="p-2"><a href="/audit-full?sort=reboot&dir={% if sort == 'reboot' and sort_dir == 'desc' %}asc{% else %}desc{% endif %}{% if filter %}&filter={{ filter }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="hover:text-cyber-accent">Reboot {% if sort == 'reboot' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
|
|
||||||
<th class="p-2"><a href="/audit-full?sort=patch&dir={% if sort == 'patch' and sort_dir == 'desc' %}asc{% else %}desc{% endif %}{% if filter %}&filter={{ filter }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="hover:text-cyber-accent">Dernier patch {% if sort == 'patch' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
|
|
||||||
<th class="p-2">Date</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for a in audits %}
|
|
||||||
<tr class="hover:bg-cyber-hover cursor-pointer" onclick="location.href='/audit-full/{{ a.id }}'">
|
|
||||||
<td class="p-2 font-mono text-cyber-accent font-bold">{{ a.hostname }}</td>
|
|
||||||
<td class="p-2 text-center text-gray-400">{{ (a.os_release or '')[:30] }}</td>
|
|
||||||
<td class="p-2 text-center font-mono text-gray-500">{{ (a.kernel or '')[:25] }}</td>
|
|
||||||
<td class="p-2 text-center text-gray-400">{{ (a.uptime or '')[:20] }}</td>
|
|
||||||
<td class="p-2 text-center">{{ a.svc_count }}</td>
|
|
||||||
<td class="p-2 text-center">{{ a.proc_count }}</td>
|
|
||||||
<td class="p-2 text-center">{{ a.port_count }}</td>
|
|
||||||
<td class="p-2 text-center">{{ a.conn_count }}</td>
|
|
||||||
<td class="p-2 text-center">{% if a.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 font-mono {% if not a.last_patch_week %}text-cyber-red{% elif a.last_patch_year == 2026 %}text-cyber-green{% else %}text-cyber-yellow{% endif %}">{% if a.last_patch_date %}{{ a.last_patch_date }}{% elif a.last_patch_week %}{{ a.last_patch_week }} {{ a.last_patch_year }}{% else %}-{% endif %}</td>
|
|
||||||
<td class="p-2 text-center text-gray-500">{{ a.audit_date.strftime('%d/%m %H:%M') if a.audit_date else '-' }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!-- Pagination -->
|
|
||||||
{% 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 %}
|
|
||||||
<div class="flex justify-between items-center p-3 border-t border-cyber-border">
|
|
||||||
<span class="text-xs text-gray-500">Page {{ page }}/{{ total_pages }} ({{ total_filtered }} serveurs)</span>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
{% if page > 1 %}
|
|
||||||
<a href="/audit-full?page=1{{ qs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">1</a>
|
|
||||||
{% if page > 2 %}<a href="/audit-full?page={{ page - 1 }}{{ qs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs"><</a>{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
<span class="btn-sm bg-cyber-accent text-black px-2 py-1 text-xs font-bold">{{ page }}</span>
|
|
||||||
{% if page < total_pages %}
|
|
||||||
<a href="/audit-full?page={{ page + 1 }}{{ qs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">></a>
|
|
||||||
<a href="/audit-full?page={{ total_pages }}{{ qs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">{{ total_pages }}</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="card p-8 text-center text-gray-500">
|
|
||||||
<p class="text-sm">Aucun audit{% if search or domain or filter %} correspondant aux filtres{% endif %}.</p>
|
|
||||||
{% if not search and not domain and not filter %}
|
|
||||||
<p class="text-xs mt-2">Lancez le standalone sur vos serveurs puis importez le JSON ici.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}Patching {{ year }}{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<div>
|
|
||||||
<a href="/audit-full" class="text-xs text-gray-500 hover:text-gray-300">< Audit complet</a>
|
|
||||||
<h2 class="text-xl font-bold text-cyber-accent">Patching {{ year }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 items-center">
|
|
||||||
<a href="/audit-full/patching?year=2025" class="btn-sm {% if year == 2025 %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">2025</a>
|
|
||||||
<a href="/audit-full/patching?year=2026" class="btn-sm {% if year == 2026 %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">2026</a>
|
|
||||||
<span class="text-gray-600 mx-1">|</span>
|
|
||||||
<a href="/audit-full/patching?year={{ year }}" class="btn-sm {% if not scope %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">Tous</a>
|
|
||||||
<a href="/audit-full/patching?year={{ year }}&scope=secops" class="btn-sm {% if scope == 'secops' %}bg-cyber-green text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">SecOps</a>
|
|
||||||
<a href="/audit-full/patching?year={{ year }}&scope=other" class="btn-sm {% if scope == 'other' %}bg-cyber-yellow text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">Hors SecOps</a>
|
|
||||||
<span class="text-gray-600 mx-1">|</span>
|
|
||||||
<a href="/audit-full/patching/export-csv?year={{ year }}{% if scope %}&scope={{ scope }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="btn-sm bg-cyber-green text-black px-3 py-1">CSV</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- KPIs par perimetre -->
|
|
||||||
{% if kpis_secops and kpis_other %}
|
|
||||||
<div style="display:flex;flex-wrap:nowrap;gap:6px;margin-bottom:8px;">
|
|
||||||
<a href="/audit-full/patching?year={{ year }}&scope=secops" class="card p-2 text-center hover:bg-cyber-hover {% if scope == 'secops' %}ring-1 ring-cyber-green{% endif %}" style="flex:1;min-width:0;background:#111827;">
|
|
||||||
<div class="text-xs text-gray-500 mb-1">SecOps</div>
|
|
||||||
<div class="text-lg font-bold text-cyber-green">{{ kpis_secops.patched }}<span class="text-gray-500 text-xs">/{{ kpis_secops.total }}</span></div>
|
|
||||||
{% set pct_s = (kpis_secops.patched / kpis_secops.total * 100)|int if kpis_secops.total > 0 else 0 %}
|
|
||||||
<div style="height:4px;background:#1f2937;border-radius:2px;margin-top:4px;"><div style="height:100%;width:{{ pct_s }}%;background:#22c55e;border-radius:2px;"></div></div>
|
|
||||||
<div style="font-size:10px;" class="{% if pct_s >= 80 %}text-cyber-green{% else %}text-cyber-yellow{% endif %} mt-1">{{ pct_s }}%</div>
|
|
||||||
</a>
|
|
||||||
<a href="/audit-full/patching?year={{ year }}&scope=other" class="card p-2 text-center hover:bg-cyber-hover {% if scope == 'other' %}ring-1 ring-cyber-yellow{% endif %}" style="flex:1;min-width:0;background:#111827;">
|
|
||||||
<div class="text-xs text-gray-500 mb-1">Hors SecOps</div>
|
|
||||||
<div class="text-lg font-bold text-cyber-yellow">{{ kpis_other.patched }}<span class="text-gray-500 text-xs">/{{ kpis_other.total }}</span></div>
|
|
||||||
{% set pct_o = (kpis_other.patched / kpis_other.total * 100)|int if kpis_other.total > 0 else 0 %}
|
|
||||||
<div style="height:4px;background:#1f2937;border-radius:2px;margin-top:4px;"><div style="height:100%;width:{{ pct_o }}%;background:#eab308;border-radius:2px;"></div></div>
|
|
||||||
<div style="font-size:10px;" class="{% if pct_o >= 80 %}text-cyber-green{% else %}text-cyber-yellow{% endif %} mt-1">{{ pct_o }}%</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- KPIs globaux -->
|
|
||||||
{% if kpis %}
|
|
||||||
{% set pct = (kpis.patched / kpis.total * 100)|int if kpis.total > 0 else 0 %}
|
|
||||||
<div style="display:flex;flex-wrap:nowrap;gap:6px;margin-bottom:12px;">
|
|
||||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-cyber-accent">{{ kpis.total }}</div><div style="font-size:10px;" class="text-gray-500">Total</div></div>
|
|
||||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-cyber-green">{{ kpis.patched }}</div><div style="font-size:10px;" class="text-gray-500">Patchés</div></div>
|
|
||||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-green-300">{{ kpis.once }}</div><div style="font-size:10px;" class="text-gray-500">1 fois</div></div>
|
|
||||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-blue-400">{{ kpis.twice }}</div><div style="font-size:10px;" class="text-gray-500">2+ fois</div></div>
|
|
||||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-purple-400">{{ kpis.thrice }}</div><div style="font-size:10px;" class="text-gray-500">3+ fois</div></div>
|
|
||||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-cyber-red">{{ kpis.never }}</div><div style="font-size:10px;" class="text-gray-500">Jamais</div></div>
|
|
||||||
<div class="card p-2 text-center" style="flex:2;min-width:0">
|
|
||||||
<div class="text-xl font-bold {% if pct >= 80 %}text-cyber-green{% elif pct >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %}">{{ pct }}%</div>
|
|
||||||
<div style="font-size:10px;" class="text-gray-500">Couverture</div>
|
|
||||||
<div style="height:4px;background:#1f2937;border-radius:2px;margin-top:4px;">
|
|
||||||
<div style="height:100%;width:{{ pct }}%;background:{% if pct >= 80 %}#22c55e{% elif pct >= 50 %}#eab308{% else %}#ef4444{% endif %};border-radius:2px;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Comparaison Y-1 -->
|
|
||||||
{% 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 %}
|
|
||||||
<div class="card p-3 mb-4">
|
|
||||||
<div class="text-xs text-gray-500 mb-2">
|
|
||||||
Comparaison à même semaine (S{{ compare.compare_week }})
|
|
||||||
{% if not compare.prev_data_ok %}<span class="text-cyber-yellow ml-2">Données 2025 incomplètes</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:12px;align-items:center;">
|
|
||||||
<!-- 2026 en cours -->
|
|
||||||
<div style="flex:1;">
|
|
||||||
<div class="flex justify-between text-xs mb-1">
|
|
||||||
<span class="text-cyber-accent font-bold">2026 (S{{ compare.compare_week }})</span>
|
|
||||||
<span class="font-bold {% if pct_current >= 80 %}text-cyber-green{% elif pct_current >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %}">{{ compare.current_patched }} / {{ compare.current_total }} ({{ pct_current }}%)</span>
|
|
||||||
</div>
|
|
||||||
<div style="height:10px;background:#1f2937;border-radius:4px;overflow:hidden;">
|
|
||||||
<div style="height:100%;width:{{ pct_current }}%;background:#22c55e;border-radius:4px;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 2025 meme semaine -->
|
|
||||||
<div style="flex:1;">
|
|
||||||
<div class="flex justify-between text-xs mb-1">
|
|
||||||
<span class="text-gray-400">2025 (S{{ compare.compare_week }})</span>
|
|
||||||
<span class="text-gray-400">{{ compare.prev_at_same_week }} / {{ compare.prev_total }} ({{ pct_prev_same }}%)</span>
|
|
||||||
</div>
|
|
||||||
<div style="height:10px;background:#1f2937;border-radius:4px;overflow:hidden;">
|
|
||||||
<div style="height:100%;width:{{ pct_prev_same }}%;background:#6b7280;border-radius:4px;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Ecart -->
|
|
||||||
<div style="min-width:110px;text-align:center;">
|
|
||||||
<div class="text-lg font-bold {% if diff_same >= 0 %}text-cyber-green{% else %}text-cyber-red{% endif %}">
|
|
||||||
{% if diff_same >= 0 %}+{% endif %}{{ diff_same }} pts
|
|
||||||
</div>
|
|
||||||
<div style="font-size:10px;" class="text-gray-500">vs 2025 même semaine</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Ligne 2025 total -->
|
|
||||||
<div class="mt-2 flex justify-between text-xs text-gray-500">
|
|
||||||
<span>2025 année complète : {{ compare.prev_year_total }} patchés ({{ pct_prev_total }}%)</span>
|
|
||||||
<span>Objectif 2026 : dépasser {{ compare.prev_year_total }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Graphe + domaines -->
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
{% if patch_weekly %}
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="text-xs text-gray-500 mb-2">Serveurs par semaine <span class="text-cyber-green">vert=patché</span> <span class="text-cyber-red">rouge=annulé/reporté</span></div>
|
|
||||||
<div style="display:flex;align-items:flex-end;gap:2px;height:100px;">
|
|
||||||
{% 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 %}
|
|
||||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;" title="{{ w.week }}: {{ w.patched }} patchés, {{ w.cancelled }} annulés">
|
|
||||||
<div style="font-size:8px;color:#94a3b8;">{{ total }}</div>
|
|
||||||
<div style="width:100%;display:flex;flex-direction:column;justify-content:flex-end;height:{{ (total / max_total * 100)|int }}%;min-height:2px;">
|
|
||||||
{% if w.cancelled|int > 0 %}<div style="width:100%;background:#ef4444;min-height:2px;height:{{ (w.cancelled|int / total * 100)|int }}%;opacity:0.8;border-radius:2px 2px 0 0;"></div>{% endif %}
|
|
||||||
<div style="width:100%;background:#22c55e;min-height:2px;flex:1;opacity:0.8;{% if w.cancelled|int == 0 %}border-radius:2px 2px 0 0;{% endif %}"></div>
|
|
||||||
</div>
|
|
||||||
<div style="font-size:7px;color:#6b7280;margin-top:2px;transform:rotate(-45deg);white-space:nowrap;">{{ w.week }}</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="text-xs text-gray-500 mb-2">Par domaine</div>
|
|
||||||
<table class="w-full text-xs">
|
|
||||||
<thead><tr><th class="text-left p-1">Domaine</th><th class="p-1">Total</th><th class="p-1">OK</th><th class="p-1">2x</th><th class="p-1">Jamais</th><th class="p-1">%</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for d in patch_by_domain %}
|
|
||||||
{% set dp = (d.patched / d.total * 100)|int if d.total > 0 else 0 %}
|
|
||||||
<tr>
|
|
||||||
<td class="p-1"><a href="/audit-full/patching?year={{ year }}&domain={{ d.code }}" class="hover:text-cyber-accent">{{ d.domain }}</a></td>
|
|
||||||
<td class="p-1 text-center"><a href="/audit-full/patching?year={{ year }}&domain={{ d.code }}" class="hover:text-cyber-accent">{{ d.total }}</a></td>
|
|
||||||
<td class="p-1 text-center"><a href="/audit-full/patching?year={{ year }}&domain={{ d.code }}&sort=count&dir=desc" class="text-cyber-green hover:underline">{{ d.patched }}</a></td>
|
|
||||||
<td class="p-1 text-center"><a href="/audit-full/patching?year={{ year }}&domain={{ d.code }}&sort=count&dir=desc" class="text-blue-400 hover:underline">{{ d.twice }}</a></td>
|
|
||||||
<td class="p-1 text-center"><a href="/audit-full/patching?year={{ year }}&domain={{ d.code }}&sort=count&dir=asc" class="text-cyber-red hover:underline">{{ d.never }}</a></td>
|
|
||||||
<td class="p-1 text-center">
|
|
||||||
<div style="display:inline-block;width:40px;height:6px;background:#1f2937;border-radius:3px;vertical-align:middle;">
|
|
||||||
<div style="height:100%;width:{{ dp }}%;background:{% if dp >= 80 %}#22c55e{% elif dp >= 50 %}#eab308{% else %}#ef4444{% endif %};border-radius:3px;"></div>
|
|
||||||
</div>
|
|
||||||
<span class="{% if dp >= 80 %}text-cyber-green{% elif dp >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %} font-bold ml-1">{{ dp }}%</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Filtres -->
|
|
||||||
<div class="card p-3 mb-4 flex gap-3 items-center flex-wrap">
|
|
||||||
<form method="GET" action="/audit-full/patching" class="flex gap-2 items-center flex-1">
|
|
||||||
<input type="hidden" name="year" value="{{ year }}">
|
|
||||||
{% if scope %}<input type="hidden" name="scope" value="{{ scope }}">{% endif %}
|
|
||||||
<input type="text" name="q" value="{{ search }}" placeholder="Rechercher..." class="text-xs py-1 px-2 flex-1 font-mono">
|
|
||||||
<select name="domain" class="text-xs py-1 px-2" onchange="this.form.submit()">
|
|
||||||
<option value="">Tous</option>
|
|
||||||
<optgroup label="Zones">{% for z in all_zones %}<option value="{{ z.code }}" {% if domain == z.code %}selected{% endif %}>{{ z.name }}</option>{% endfor %}</optgroup>
|
|
||||||
<optgroup label="Domaines">{% for d in all_domains %}<option value="{{ d.code }}" {% if domain == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}</optgroup>
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
|
|
||||||
{% if search or domain %}<a href="/audit-full/patching?year={{ year }}" class="text-xs text-gray-400">Reset</a>{% endif %}
|
|
||||||
</form>
|
|
||||||
<span class="text-xs text-gray-500">{{ total_filtered }} serveur(s)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tableau serveurs -->
|
|
||||||
{% set qs %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if scope %}&scope={{ scope }}{% endif %}{% endset %}
|
|
||||||
<div class="card overflow-x-auto">
|
|
||||||
<table class="w-full table-cyber text-xs">
|
|
||||||
<thead><tr>
|
|
||||||
<th class="text-left p-2"><a href="/audit-full/patching?year={{ year }}&sort=hostname&dir={% if sort == 'hostname' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}{{ qs }}" class="hover:text-cyber-accent">Hostname {% if sort == 'hostname' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
|
|
||||||
<th class="p-2">Domaine</th>
|
|
||||||
<th class="p-2">Env</th>
|
|
||||||
<th class="p-2">Zone</th>
|
|
||||||
<th class="p-2"><a href="/audit-full/patching?year={{ year }}&sort=count&dir={% if sort == 'count' and sort_dir == 'desc' %}asc{% else %}desc{% endif %}{{ qs }}" class="hover:text-cyber-accent">Nb patchs {% if sort == 'count' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
|
|
||||||
<th class="text-left p-2">Semaines</th>
|
|
||||||
<th class="p-2"><a href="/audit-full/patching?year={{ year }}&sort=last&dir={% if sort == 'last' and sort_dir == 'desc' %}asc{% else %}desc{% endif %}{{ qs }}" class="hover:text-cyber-accent">Dernier {% if sort == 'last' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for s in servers %}
|
|
||||||
<tr class="hover:bg-cyber-hover cursor-pointer" onclick="location.href='/audit-full/{{ s.id }}'">
|
|
||||||
<td class="p-2 font-mono text-cyber-accent font-bold">{{ s.hostname }}</td>
|
|
||||||
<td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</td>
|
|
||||||
<td class="p-2 text-center"><span class="badge {% if s.env == 'Production' %}badge-green{% elif s.env == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}">{{ (s.env or '-')[:6] }}</span></td>
|
|
||||||
<td class="p-2 text-center">{% if s.zone == 'DMZ' %}<span class="badge badge-red">DMZ</span>{% else %}{{ s.zone or '-' }}{% endif %}</td>
|
|
||||||
<td class="p-2 text-center font-bold {% if (s.patch_count or 0) >= 2 %}text-cyber-green{% elif (s.patch_count or 0) == 1 %}text-green-300{% else %}text-cyber-red{% endif %}">{{ s.patch_count or 0 }}</td>
|
|
||||||
<td class="p-2 font-mono text-gray-400">{% if s.patch_weeks %}{% for w in s.patch_weeks.split(',') %}<span class="inline-block px-1 rounded text-xs {% if w == 'S15' %}bg-green-900/30 text-cyber-green{% else %}bg-cyber-border text-gray-400{% endif %} mr-1">{{ w }}</span>{% endfor %}{% else %}-{% endif %}</td>
|
|
||||||
<td class="p-2 text-center font-mono {% if s.last_patch_year == year %}text-cyber-green{% elif s.last_patch_date %}text-cyber-yellow{% else %}text-cyber-red{% endif %}">{% if s.last_patch_date %}{{ s.last_patch_date }}{% elif s.last_patch_week %}{{ s.last_patch_week }}{% else %}-{% endif %}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{% if total_pages > 1 %}
|
|
||||||
{% set pqs %}&year={{ year }}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if scope %}&scope={{ scope }}{% endif %}{% if sort %}&sort={{ sort }}&dir={{ sort_dir }}{% endif %}{% endset %}
|
|
||||||
<div class="flex justify-between items-center p-3 border-t border-cyber-border">
|
|
||||||
<span class="text-xs text-gray-500">Page {{ page }}/{{ total_pages }}</span>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
{% if page > 1 %}<a href="/audit-full/patching?page=1{{ pqs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">1</a>{% if page > 2 %}<a href="/audit-full/patching?page={{ page-1 }}{{ pqs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs"><</a>{% endif %}{% endif %}
|
|
||||||
<span class="btn-sm bg-cyber-accent text-black px-2 py-1 text-xs font-bold">{{ page }}</span>
|
|
||||||
{% if page < total_pages %}<a href="/audit-full/patching?page={{ page+1 }}{{ pqs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">></a><a href="/audit-full/patching?page={{ total_pages }}{{ pqs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">{{ total_pages }}</a>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}Safe Patching{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-bold text-cyber-accent">Safe Patching — Quick Win</h2>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Patching sans interruption de service : exclut tout ce qui nécessite un reboot ou un restart.</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
{% if can_create %}
|
|
||||||
<button onclick="document.getElementById('create-form').style.display = document.getElementById('create-form').style.display === 'none' ? 'block' : 'none'" class="btn-primary px-4 py-2 text-sm">Nouvelle campagne</button>
|
|
||||||
{% endif %}
|
|
||||||
<a href="/planning" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Planning</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% 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 ?).{% elif msg == 'deleted' %}Campagne supprimée.{% 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 %}
|
|
||||||
|
|
||||||
<!-- Formulaire création -->
|
|
||||||
{% if can_create %}
|
|
||||||
<div id="create-form" class="card p-5 mb-4" style="display:none">
|
|
||||||
<h3 class="text-sm font-bold text-cyber-accent mb-3">Nouvelle campagne Quick Win</h3>
|
|
||||||
<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>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,215 +0,0 @@
|
|||||||
{% 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>
|
|
||||||
{% set p = perms if perms is defined else request.state.perms %}
|
|
||||||
{% if p.campaigns == 'admin' %}
|
|
||||||
<form method="POST" action="/safe-patching/{{ c.id }}/delete">
|
|
||||||
<button class="btn-sm bg-red-900/50 text-cyber-red px-4 py-2" onclick="return confirm('SUPPRIMER définitivement cette campagne Quick Win ?')">Supprimer</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</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 %}"title="{{ s.environnement or '' }}">{{ (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 %}"title="{{ s.environnement or '' }}">{{ (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 %}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
{% 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