- Page de génération : sélection de template, variables {{ }} éditables
(pré-remplies via AD), aperçu du rendu HTML
- Serveur : routes /mail/send (smtplib), upload d'images et de fichiers ;
images inlinées en data URI, fichiers ajoutés en pièces jointes à l'envoi
- Rendu email-safe partagé (banner, listes, code, liens)
- gitignore des contenus utilisateur (images/, files/)
- Lien du générateur ajouté à la navigation des autres apps + README
- Bump du sous-module cmdb (éditeur de templates)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1287 lines
69 KiB
HTML
1287 lines
69 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Générateur de Certificats SSL</title>
|
||
<script src="vendor/forge.min.js"></script>
|
||
<script src="vendor/jszip.min.js"></script>
|
||
<style>
|
||
:root{
|
||
--bg:#0c0d10; --panel:#15171c; --panel2:#1d2026; --line:rgba(255,255,255,0.10);
|
||
--txt:#f2f3f5; --muted:#7c8189; --accent:#5b9cf5; --accent2:#4ecfa5;
|
||
--danger:#e66767; --warn:#e8b34b; --radius:12px;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html{scrollbar-color:#33363e var(--bg)}
|
||
::-webkit-scrollbar{width:10px;height:10px}
|
||
::-webkit-scrollbar-thumb{background:#33363e;border-radius:6px}
|
||
::-webkit-scrollbar-track{background:transparent}
|
||
body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--bg);
|
||
color:var(--txt);min-height:100vh;-webkit-font-smoothing:antialiased;display:flex}
|
||
.wrap{flex:1;min-width:0;max-width:920px;margin:0 auto;padding:32px 20px 80px}
|
||
/* sidebar switcher projets */
|
||
aside.appside{width:250px;flex-shrink:0;background:var(--panel);border-right:1px solid var(--line);
|
||
padding:22px 14px;position:sticky;top:0;height:100vh;overflow-y:auto;display:flex;flex-direction:column;gap:3px}
|
||
.appside .brand{display:flex;align-items:center;gap:10px;padding:0 10px 22px}
|
||
.appside .brand-mark{width:32px;height:32px;border-radius:9px;flex-shrink:0;
|
||
background:linear-gradient(135deg,var(--accent),#7c5cf5);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:15px;color:#fff}
|
||
.appside .brand b{font-size:15px;letter-spacing:.4px}
|
||
.appside .brand small{display:block;font-size:10px;color:var(--muted);font-weight:400;letter-spacing:1px;text-transform:uppercase}
|
||
.psw{display:flex;flex-direction:column;gap:3px}
|
||
.psw-title{font-size:10.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;font-weight:600;padding:0 12px 7px}
|
||
.psw a{display:flex;align-items:center;gap:10px;text-decoration:none;padding:8px 10px;border-radius:9px;color:var(--muted);transition:background .12s}
|
||
.psw a:hover{background:var(--panel2)}
|
||
.psw a.active{background:rgba(91,156,245,.14)}
|
||
.psw a.active b{color:var(--accent)}
|
||
.psw-ico{width:30px;height:30px;border-radius:8px;background:var(--panel2);display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0}
|
||
.psw a.active .psw-ico{background:rgba(91,156,245,.22)}
|
||
.psw-txt{min-width:0}
|
||
.psw-txt b{display:block;font-size:13px;color:var(--txt);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.psw-txt small{display:block;font-size:10.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
@media(max-width:760px){body{display:block}aside.appside{width:auto;height:auto;position:static;border-right:none;border-bottom:1px solid var(--line)}}
|
||
header{display:flex;align-items:center;gap:14px;margin-bottom:20px}
|
||
.logo{width:44px;height:44px;border-radius:12px;background:linear-gradient(135deg,var(--accent),#7c5cf5);
|
||
display:grid;place-items:center;font-size:22px;box-shadow:0 6px 20px rgba(91,156,245,.35)}
|
||
h1{font-size:20px;margin:0;font-weight:650}
|
||
header p{margin:2px 0 0;color:var(--muted);font-size:13px}
|
||
/* workspace bar */
|
||
.ws{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);
|
||
border-radius:12px;padding:12px 14px;margin-bottom:16px;flex-wrap:wrap}
|
||
.ws .info{flex:1;min-width:200px;font-size:13px}
|
||
.ws .info b{color:var(--accent2)}
|
||
.ws .info .none{color:var(--warn)}
|
||
.tabs{display:flex;gap:8px;margin-bottom:22px;background:var(--panel);padding:6px;border-radius:12px;border:1px solid var(--line)}
|
||
.tab{flex:1;padding:11px;border:none;background:transparent;color:var(--muted);font-weight:600;
|
||
border-radius:8px;cursor:pointer;font-size:14px;transition:.15s}
|
||
.tab.active{background:var(--panel2);color:var(--txt);box-shadow:0 2px 8px rgba(0,0,0,.3)}
|
||
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:22px;margin-bottom:18px}
|
||
.steps{display:flex;justify-content:space-between;margin-bottom:26px;position:relative}
|
||
.steps:before{content:"";position:absolute;top:16px;left:6%;right:6%;height:2px;background:var(--line)}
|
||
.step{position:relative;z-index:1;text-align:center;flex:1}
|
||
.dot{width:34px;height:34px;border-radius:50%;background:var(--panel2);border:2px solid var(--line);
|
||
display:grid;place-items:center;margin:0 auto 6px;font-weight:700;font-size:14px;color:var(--muted);transition:.2s}
|
||
.step.active .dot{border-color:var(--accent);color:#fff;background:var(--accent);box-shadow:0 0 0 5px rgba(91,156,245,.15)}
|
||
.step.done .dot{border-color:var(--accent2);background:var(--accent2);color:#0c0d10}
|
||
.step small{color:var(--muted);font-size:12px}
|
||
.step.active small{color:var(--txt)}
|
||
label{display:block;font-size:13px;font-weight:600;margin:0 0 7px}
|
||
.hint{color:var(--muted);font-weight:400;font-size:12px}
|
||
input[type=text]{width:100%;padding:12px 14px;background:var(--panel2);border:1px solid var(--line);
|
||
border-radius:10px;color:var(--txt);font-size:14px;outline:none;transition:.15s}
|
||
input[type=text]:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(91,156,245,.15)}
|
||
.row{display:flex;gap:10px}
|
||
.row input{flex:1}
|
||
button.btn{padding:12px 18px;border:none;border-radius:10px;font-weight:650;font-size:14px;cursor:pointer;
|
||
background:var(--accent);color:#fff;transition:.15s}
|
||
button.btn:hover{filter:brightness(1.1)}
|
||
button.btn.sec{background:var(--panel2);color:var(--txt);border:1px solid var(--line)}
|
||
button.btn.ghost{background:transparent;color:var(--muted);border:1px solid var(--line)}
|
||
button.btn.sm{padding:8px 12px;font-size:13px}
|
||
button.btn:disabled{opacity:.5;cursor:not-allowed}
|
||
.nav{display:flex;justify-content:space-between;margin-top:22px}
|
||
.chips{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0}
|
||
.chip{display:inline-flex;align-items:center;gap:7px;padding:7px 12px;border-radius:20px;font-size:13px;
|
||
background:var(--panel2);border:1px solid var(--line)}
|
||
.chip.dns{border-color:#2c4a7a}.chip.ip{border-color:#2c6b52}
|
||
.chip b{color:var(--muted);font-weight:600;font-size:11px;text-transform:uppercase}
|
||
.chip .x{cursor:pointer;color:var(--muted);font-weight:700;margin-left:2px}
|
||
.chip .x:hover{color:var(--danger)}
|
||
.chip.locked .x{display:none}
|
||
.file{margin:14px 0}
|
||
.file .fh{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
|
||
.file .fh span{font-family:monospace;font-size:13px;color:var(--accent)}
|
||
pre{background:#101216;border:1px solid var(--line);border-radius:10px;padding:14px;overflow:auto;
|
||
font-size:12px;line-height:1.5;color:#b8bcc4;max-height:230px;margin:0;white-space:pre-wrap;word-break:break-all}
|
||
.dl{display:flex;flex-wrap:wrap;gap:10px;margin-top:16px}
|
||
.banner{padding:12px 14px;border-radius:10px;font-size:13px;margin-bottom:14px;display:none}
|
||
.banner.warn{background:rgba(232,179,75,.12);border:1px solid var(--warn);color:var(--warn);display:block}
|
||
.banner.err{background:rgba(230,103,103,.12);border:1px solid var(--danger);color:var(--danger);display:block}
|
||
.banner.ok{background:rgba(78,207,165,.12);border:1px solid var(--accent2);color:var(--accent2);display:block}
|
||
.spin{width:18px;height:18px;border:3px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;
|
||
display:inline-block;animation:sp .7s linear infinite;vertical-align:-3px;margin-right:8px}
|
||
@keyframes sp{to{transform:rotate(360deg)}}
|
||
.search{position:relative;margin-bottom:16px}
|
||
.search input{padding-left:40px}
|
||
.search svg{position:absolute;left:13px;top:12px}
|
||
.cert{background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:10px;
|
||
display:flex;gap:16px;align-items:center}
|
||
.cert .body{flex:1;min-width:0}
|
||
.cert .cbtns{flex-shrink:0;display:flex;flex-direction:row;gap:8px;align-items:center}
|
||
.cert .cbtns .btn{white-space:nowrap}
|
||
.btn.ico{width:34px;height:34px;padding:0;border-radius:50%;font-style:normal;
|
||
font-weight:700;font-size:16px;display:grid;place-items:center}
|
||
.btn.ico.infoB{font-style:italic;font-family:Georgia,serif} /* « i » info stylisé */
|
||
.btn.ico.on{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||
/* couleurs des actions */
|
||
.btn.ico.addB{color:#3b82f6;border-color:rgba(59,130,246,.45)}
|
||
.btn.ico.addB:hover{background:rgba(59,130,246,.14)}
|
||
.btn.ico.showB{color:#16a34a;border-color:rgba(22,163,74,.45)}
|
||
.btn.ico.showB:hover{background:rgba(22,163,74,.14)}
|
||
.btn.ico.archB{color:#f59e0b;border-color:rgba(245,158,11,.45)}
|
||
.btn.ico.archB:hover{background:rgba(245,158,11,.14)}
|
||
.sectitle{font-size:13px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;
|
||
margin:6px 2px 10px}
|
||
.cert.arch{border-color:#3a3450}
|
||
#aDate{width:100%;padding:11px 14px;background:var(--panel2);border:1px solid var(--line);border-radius:10px;
|
||
color:var(--txt);font-size:14px;color-scheme:dark;margin-bottom:12px}
|
||
.btn.del{color:var(--danger);border-color:rgba(230,103,103,.4)}
|
||
.btn.del:hover{background:rgba(230,103,103,.12)}
|
||
.btn.dlB{color:#16a34a;border-color:rgba(22,163,74,.45)}
|
||
.btn.dlB:hover{background:rgba(22,163,74,.12)}
|
||
.btn svg{display:block}
|
||
.btn.delB svg{stroke:#e05656 !important} /* icône poubelle : trait rouge garanti */
|
||
.cert .body{cursor:pointer}
|
||
.achoice{display:flex;flex-direction:column;gap:10px}
|
||
.achoice .btn{padding:16px;font-size:15px}
|
||
.badge{font-size:11px;font-weight:700;padding:2px 9px;border-radius:12px;white-space:nowrap;vertical-align:middle}
|
||
.badge.ok{background:rgba(78,207,165,.15);color:var(--accent2);border:1px solid rgba(78,207,165,.4)}
|
||
.badge.soon{background:rgba(232,179,75,.15);color:var(--warn);border:1px solid rgba(232,179,75,.45)}
|
||
.badge.exp{background:rgba(230,103,103,.15);color:var(--danger);border:1px solid rgba(230,103,103,.5)}
|
||
.keyopts{display:flex;gap:8px;flex-wrap:wrap}
|
||
.keyopt{flex:1;min-width:110px;padding:10px 12px;border-radius:10px;background:var(--panel2);
|
||
border:1px solid var(--line);color:var(--txt);cursor:pointer;text-align:left;transition:.15s}
|
||
.keyopt b{display:block;font-size:13px}
|
||
.keyopt small{color:var(--muted);font-size:11px}
|
||
.keyopt.on{border-color:var(--accent);background:rgba(91,156,245,.12);box-shadow:0 0 0 3px rgba(91,156,245,.12)}
|
||
.keyopt.on b{color:var(--accent)}
|
||
.dngrid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
|
||
.dngrid label{font-weight:500;font-size:12px;margin-bottom:4px}
|
||
.dngrid input{padding:9px 12px}
|
||
.dngrid>div:last-child:nth-child(odd){grid-column:1/-1}
|
||
.cert h3{margin:0;font-size:15px;font-family:monospace;word-break:break-all}
|
||
.cert .meta{color:var(--muted);font-size:12px;margin-top:4px}
|
||
.cert .sans{margin-top:8px;display:flex;flex-wrap:wrap;gap:6px}
|
||
/* modal */
|
||
.modal{position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(3px);
|
||
display:none;align-items:center;justify-content:center;padding:20px;z-index:50}
|
||
.modal.show{display:flex}
|
||
.modal .box{background:var(--panel);border:1px solid var(--line);border-radius:16px;width:100%;max-width:640px;
|
||
max-height:85vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.5)}
|
||
.modal .mhead{padding:18px 20px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
|
||
.modal .mhead h2{margin:0;font-size:16px;font-family:monospace;word-break:break-all}
|
||
.modal .mhead .sub{color:var(--muted);font-size:12px;margin-top:5px}
|
||
.modal .xClose{background:none;border:none;color:var(--muted);font-size:22px;cursor:pointer;line-height:1}
|
||
.modal .mbody{padding:12px 20px;overflow:auto}
|
||
.frow{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--line)}
|
||
.frow:last-child{border-bottom:none}
|
||
.frow .fico{font-size:18px}
|
||
.frow .fname{flex:1;font-family:monospace;font-size:13px;word-break:break-all}
|
||
.frow .fsz{color:var(--muted);font-size:12px;white-space:nowrap}
|
||
.modal .mfoot{padding:16px 20px;border-top:1px solid var(--line);display:flex;gap:10px;flex-wrap:wrap}
|
||
.modal .mfoot:empty{display:none}
|
||
.tag{font-size:11px;padding:3px 9px;border-radius:12px;background:#101216;border:1px solid var(--line);color:var(--muted)}
|
||
.empty{text-align:center;color:var(--muted);padding:40px 0;font-size:14px}
|
||
.hidden{display:none}
|
||
code{color:#b8bcc4}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<aside class="appside">
|
||
<div class="brand"><div class="brand-mark">◧</div><div><b>Outils Infra</b><small>portail</small></div></div>
|
||
<div class="psw">
|
||
<div class="psw-title">Projets</div>
|
||
<a href="../cmdb/cmdb.html"><div class="psw-ico">◧</div><div class="psw-txt"><b>CMDB</b><small>Machines, réseaux, salles</small></div></a>
|
||
<a class="active" href="dashboard.html"><div class="psw-ico">🔐</div><div class="psw-txt"><b>Générateur certificats</b><small>Clés RSA, CSR, SSL</small></div></a>
|
||
<a href="../switch-config/switch_vlan_mapper.html"><div class="psw-ico">🔌</div><div class="psw-txt"><b>Configuration switch</b><small>VLANs, export config</small></div></a>
|
||
<a href="../password-generator/password_generator.html"><div class="psw-ico">🔑</div><div class="psw-txt"><b>Mots de passe</b><small>Générateur & envoi</small></div></a>
|
||
</div>
|
||
<div class="psw" style="margin-top:auto;padding-top:14px;border-top:1px solid var(--line)">
|
||
<a href="../cmdb/preferences.html"><div class="psw-ico">⚙</div><div class="psw-txt"><b>Préférences</b><small>Types, tickets, réglages</small></div></a>
|
||
<div id="dbStatus" style="font-size:11px;color:var(--muted);padding:4px 8px;word-break:break-word;line-height:1.5;margin-top:6px">● Base CMDB</div>
|
||
</div>
|
||
</aside>
|
||
<div class="wrap">
|
||
<header>
|
||
<div class="logo">🔐</div>
|
||
<div>
|
||
<h1>Générateur de Certificats SSL</h1>
|
||
<p>Génère clé RSA, fichier .cnf et CSR — équivalent navigateur de <code>test.sh</code></p>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- workspace bar -->
|
||
<div class="ws">
|
||
<div class="info" id="wsInfo">📂 Dossier de travail : <span class="none">initialisation…</span></div>
|
||
<button class="btn sm sec" id="prefsBtn">⚙ Réglages certificats</button>
|
||
<button class="btn sm" id="pickDir" title="Optionnel : écrire dans un vrai dossier disque au lieu du stockage navigateur">📂 Dossier disque…</button>
|
||
</div>
|
||
<div class="banner" id="wsBanner"></div>
|
||
|
||
<div class="tabs">
|
||
<button class="tab active" data-tab="gen">➕ Générer</button>
|
||
<button class="tab" data-tab="search">🔎 Certificats existants</button>
|
||
</div>
|
||
|
||
<!-- ================= GENERATE ================= -->
|
||
<section id="tab-gen">
|
||
<div class="card">
|
||
<div class="steps">
|
||
<div class="step active" data-s="1"><div class="dot">1</div><small>Nom serveur</small></div>
|
||
<div class="step" data-s="2"><div class="dot">2</div><small>SAN (DNS / IP)</small></div>
|
||
<div class="step" data-s="3"><div class="dot">3</div><small>Génération</small></div>
|
||
</div>
|
||
|
||
<div class="panel" data-p="1">
|
||
<label>Nom du serveur <span class="hint">(ex: monserveur-tss)</span></label>
|
||
<input type="text" id="server" placeholder="monserveur-tss" autocomplete="off">
|
||
<div class="banner" id="b1"></div>
|
||
<label style="margin-top:16px">Force de la clé</label>
|
||
<div class="keyopts" id="bits1"></div>
|
||
<p class="hint" style="margin-top:14px">Identité (DN) reprise depuis les <b>Préférences</b> (⚙ en haut). Dossier créé : <code id="dirPreview"><nom>.gemalto.com</code></p>
|
||
<div class="nav"><span></span><button class="btn" id="to2">Suivant →</button></div>
|
||
</div>
|
||
|
||
<div class="panel hidden" data-p="2">
|
||
<label>SAN supplémentaires <span class="hint">DNS ou IP — Entrée pour ajouter</span></label>
|
||
<div class="row">
|
||
<input type="text" id="sanInput" placeholder="alias1.gemalto.com ou 192.168.1.10" autocomplete="off">
|
||
<button class="btn sec" id="addSan">Ajouter</button>
|
||
</div>
|
||
<p class="hint" style="margin-top:12px">SAN par défaut (verrouillés, ajoutés par le script) :</p>
|
||
<div class="chips" id="chips"></div>
|
||
<div class="nav">
|
||
<button class="btn ghost" id="back1">← Retour</button>
|
||
<button class="btn" id="to3">Suivant →</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel hidden" data-p="3">
|
||
<div class="banner ok" id="b3" style="display:none"></div>
|
||
<div id="genArea"></div>
|
||
<div id="result" class="hidden">
|
||
<div class="file">
|
||
<div class="fh"><span id="nCnf"></span><button class="btn ghost sm" data-cp="cnf">Copier</button></div>
|
||
<pre id="outCnf"></pre>
|
||
</div>
|
||
<div class="file">
|
||
<div class="fh"><span id="nKey"></span><button class="btn ghost sm" data-cp="key">Copier</button></div>
|
||
<pre id="outKey"></pre>
|
||
</div>
|
||
<div class="file">
|
||
<div class="fh"><span id="nCsr"></span><button class="btn ghost sm" data-cp="csr">Copier</button></div>
|
||
<pre id="outCsr"></pre>
|
||
</div>
|
||
<div class="dl">
|
||
<button class="btn" id="dlZip">📦 Télécharger le dossier (.zip)</button>
|
||
<button class="btn sec" data-dl="key">⬇ .key</button>
|
||
<button class="btn sec" data-dl="cnf">⬇ .cnf</button>
|
||
<button class="btn sec" data-dl="csr">⬇ .csr</button>
|
||
<button class="btn ghost" id="genNew">+ Nouveau</button>
|
||
</div>
|
||
<div id="ticketRow" style="display:none; text-align:right; margin-top:12px">
|
||
<button class="btn" onclick="openCertTicket()">🎫 Ouvrir un Ticket ↗</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ================= SEARCH ================= -->
|
||
<section id="tab-search" class="hidden">
|
||
<div class="card">
|
||
<div class="search">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#7c8189" stroke-width="2">
|
||
<circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>
|
||
<input type="text" id="q" placeholder="Rechercher par nom de dossier ou fichier...">
|
||
</div>
|
||
<div id="list"></div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- modal certificats -->
|
||
<div class="modal" id="modal">
|
||
<div class="box">
|
||
<div class="mhead">
|
||
<div><h2 id="mTitle"></h2><div class="sub" id="mSub"></div></div>
|
||
<button class="xClose" id="mClose">✕</button>
|
||
</div>
|
||
<div class="mbody" id="mBody"></div>
|
||
<div class="mfoot" id="mFoot"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- modal préférences -->
|
||
<div class="modal" id="pmodal">
|
||
<div class="box" style="max-width:460px">
|
||
<div class="mhead">
|
||
<div><h2>⚙ Préférences</h2><div class="sub">Valeurs par défaut des certificats</div></div>
|
||
<button class="xClose" id="pClose">✕</button>
|
||
</div>
|
||
<div class="mbody">
|
||
<label>Domaine <span class="hint">(suffixe des dossiers, ex: gemalto.com)</span></label>
|
||
<input type="text" id="pDomain" autocomplete="off">
|
||
<label style="margin-top:14px">Email (emailAddress du cert)</label>
|
||
<input type="text" id="pEmail" autocomplete="off">
|
||
<label style="margin-top:14px">Force de clé par défaut</label>
|
||
<div class="keyopts" id="pBits"></div>
|
||
<label style="margin-top:14px">Identité (DN) par défaut</label>
|
||
<div class="dngrid" id="pDn"></div>
|
||
<label style="margin-top:14px">Lien ticket certificat <span class="hint">(proposé à la fin d'une génération)</span></label>
|
||
<input type="text" id="pTicket" autocomplete="off" placeholder="https://itsm.exemple.com/nouveau-ticket">
|
||
<div class="banner" id="pMsg"></div>
|
||
</div>
|
||
<div class="mfoot"><button class="btn" id="pSave">💾 Enregistrer</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- modal archive -->
|
||
<div class="modal" id="amodal">
|
||
<div class="box" style="max-width:440px">
|
||
<div class="mhead">
|
||
<div><h2 id="aTitle"></h2><div class="sub" id="aSub"></div></div>
|
||
<button class="xClose" id="aClose">✕</button>
|
||
</div>
|
||
<div class="mbody" id="aBody"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const PREF_KEY="ssl_prefs_v1";
|
||
const DEF_PREFS={domain:"gemalto.com", email:"mey.it@thalesgroup.com", bits:2048,
|
||
C:"", ST:"", L:"", O:"", OU:"", ticket_url:""};
|
||
const DN_FIELDS=[
|
||
{k:'C', label:'Pays (C)', ph:'FR'},
|
||
{k:'ST',label:'Région (ST)', ph:'Ile-de-France'},
|
||
{k:'L', label:'Ville (L)', ph:'Meudon'},
|
||
{k:'O', label:'Organisation (O)', ph:'Thales'},
|
||
{k:'OU',label:'Unité (OU)', ph:'IT'}
|
||
];
|
||
let PREFS=loadPrefs(), DOMAIN, EMAIL;
|
||
function loadPrefs(){try{return {...DEF_PREFS,...JSON.parse(localStorage.getItem(PREF_KEY))};}catch(e){return {...DEF_PREFS};}}
|
||
function applyPrefs(){DOMAIN=PREFS.domain;EMAIL=PREFS.email;}
|
||
applyPrefs();
|
||
function prefsDn(){return {C:PREFS.C,ST:PREFS.ST,L:PREFS.L,O:PREFS.O,OU:PREFS.OU};}
|
||
let state={server:"",dns:[],ip:[],locked:0,gen:null,bits:PREFS.bits,dn:prefsDn()};
|
||
let rootHandle=null; // dossier de travail (File System Access API)
|
||
const HAS_FS = 'showDirectoryPicker' in window;
|
||
const WORKDIR='LISTS_ALL_CERT'; // sous-dossier de travail (créé si absent)
|
||
const HAS_OPFS = !!(navigator.storage && navigator.storage.getDirectory);
|
||
let wsKind=''; // 'disk' | 'opfs' | ''
|
||
/* renvoie (créant au besoin) le sous-dossier LISTS_ALL_CERT du dossier choisi.
|
||
si le handle EST déjà LISTS_ALL_CERT, le renvoie tel quel. */
|
||
async function ensureWorkdir(h){
|
||
if(h.name===WORKDIR) return h;
|
||
return await h.getDirectoryHandle(WORKDIR,{create:true});
|
||
}
|
||
/* dossier de travail OPFS (Origin Private File System) : créé automatiquement,
|
||
AUCUN prompt / sélection. Persistant par origine. */
|
||
async function opfsWorkdir(){
|
||
const root=await navigator.storage.getDirectory();
|
||
return await root.getDirectoryHandle(WORKDIR,{create:true});
|
||
}
|
||
|
||
/* =============== backend serveur (cert_server.py) — écriture disque réelle =============== */
|
||
const API={on:false};
|
||
async function probeBackend(){
|
||
try{
|
||
const r=await fetch('api/ping',{cache:'no-store'});
|
||
if(r.ok){ const j=await r.json(); if(j&&j.ok){ API.on=true; return true; } }
|
||
}catch(e){}
|
||
return false;
|
||
}
|
||
async function apiPost(route,payload){
|
||
const r=await fetch('api/'+route,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||
const j=await r.json().catch(()=>({}));
|
||
if(!r.ok||!j.ok) throw new Error(j.error||('HTTP '+r.status));
|
||
return j;
|
||
}
|
||
async function apiWriteText(dir,files){ return apiPost('write',{dir,files}); }
|
||
async function apiWriteBin(path,blob){
|
||
const b64=await blobToB64(blob);
|
||
return apiPost('write_bin',{path,b64});
|
||
}
|
||
async function apiDeleteDir(dir,archives){ return apiPost('delete',{dir,archives:!!archives}); }
|
||
async function apiDeleteFile(dir,name){ return apiPost('delete_file',{dir,name}); }
|
||
async function apiDeleteArchives(dir){ return apiPost('delete_archives',{dir}); }
|
||
async function apiList(){ const r=await fetch('api/list',{cache:'no-store'}); return r.json(); }
|
||
function blobToB64(blob){
|
||
return new Promise((res,rej)=>{const r=new FileReader();
|
||
r.onload=()=>res(String(r.result).split(',')[1]||'');r.onerror=()=>rej(r.error);r.readAsDataURL(blob);});
|
||
}
|
||
/* URL statique d'un fichier du dossier de travail (téléchargement / lecture) */
|
||
function workUrl(dir,name){ return `${WORKDIR}/${encodeURIComponent(dir)}/${encodeURIComponent(name)}`; }
|
||
/* fabrique un objet fichier compatible (getFile) à partir d'une URL servie */
|
||
function serverFile(url,meta){
|
||
return { name:meta.name, ext:meta.ext, size:meta.size, mtime:meta.mtime,
|
||
handle:{ getFile: async()=>{ const resp=await fetch(url,{cache:'no-store'});
|
||
const blob=await resp.blob(); return new File([blob],meta.name,{lastModified:meta.mtime||Date.now()}); } } };
|
||
}
|
||
/* scan via serveur — même forme que scanDisk() */
|
||
async function scanServer(){
|
||
const j=await apiList();
|
||
const certs=(j.certs||[]).map(c=>{
|
||
const base=DOMAIN&&c.dir.endsWith('.'+DOMAIN)?c.dir.slice(0,-(DOMAIN.length+1)):c.dir;
|
||
const files=c.files.map(f=>serverFile(workUrl(c.dir,f.name),f));
|
||
return {name:base,dir:c.dir,files,date:c.date,info:null,handle:null};
|
||
});
|
||
const archives=(j.archives||[]).map(a=>({name:a.name,size:a.size,mtime:a.mtime,
|
||
handle:{ getFile: async()=>{ const resp=await fetch(`${WORKDIR}/archives/${encodeURIComponent(a.name)}`,{cache:'no-store'});
|
||
return new File([await resp.blob()],a.name,{lastModified:a.mtime||Date.now()}); } } }));
|
||
return {certs,archives};
|
||
}
|
||
|
||
/* =============== IndexedDB : mémorise le handle du dossier =============== */
|
||
function idb(){return new Promise((res,rej)=>{const r=indexedDB.open('ssl_ws',1);
|
||
r.onupgradeneeded=()=>r.result.createObjectStore('kv');
|
||
r.onsuccess=()=>res(r.result);r.onerror=()=>rej(r.error);});}
|
||
async function idbSet(k,v){const db=await idb();return new Promise((res,rej)=>{
|
||
const tx=db.transaction('kv','readwrite');tx.objectStore('kv').put(v,k);
|
||
tx.oncomplete=res;tx.onerror=()=>rej(tx.error);});}
|
||
async function idbGet(k){const db=await idb();return new Promise((res,rej)=>{
|
||
const tx=db.transaction('kv','readonly');const rq=tx.objectStore('kv').get(k);
|
||
rq.onsuccess=()=>res(rq.result);rq.onerror=()=>rej(rq.error);});}
|
||
|
||
async function verifyPerm(handle,write){
|
||
const opt={mode:write?'readwrite':'read'};
|
||
if(await handle.queryPermission(opt)==='granted')return true;
|
||
if(await handle.requestPermission(opt)==='granted')return true;
|
||
return false;
|
||
}
|
||
function wsBanner(cls,msg){const b=document.getElementById('wsBanner');
|
||
b.className='banner '+cls;b.textContent=msg;}
|
||
function renderWs(){
|
||
const i=document.getElementById('wsInfo');
|
||
if(rootHandle){
|
||
const tag = wsKind==='opfs' ? ' <span class="none">(stockage navigateur)</span>'
|
||
: wsKind==='disk' ? ' <span style="color:var(--accent2)">(disque)</span>' : '';
|
||
i.innerHTML=`📂 Dossier de travail : <b>${rootHandle.name}/</b>${tag}`;
|
||
}
|
||
else i.innerHTML=`📂 Dossier de travail : <span class="none">non sélectionné</span>`;
|
||
}
|
||
document.getElementById('pickDir').onclick=pickDir;
|
||
async function pickDir(){
|
||
if(!HAS_FS){wsBanner('err','⚠ File System Access API indisponible dans ce navigateur. Utilise Chrome/Edge (et sert la page via http://localhost). Sinon: fallback téléchargement .zip.');return;}
|
||
try{
|
||
const h=await window.showDirectoryPicker({mode:'readwrite',id:'ssl-ws',startIn:'downloads'});
|
||
if(!await verifyPerm(h,true)){wsBanner('err','Permission refusée sur le dossier.');return;}
|
||
const existed = await dirExists(h,WORKDIR);
|
||
rootHandle=await ensureWorkdir(h);wsKind='disk';await idbSet('root',rootHandle);
|
||
renderWs();wsBanner('ok',`✅ Dossier de travail : « ${h.name}/${WORKDIR}/ » ${existed?'lié':'créé'}. Les certificats y seront créés et recherchés.`);
|
||
}catch(e){ if(e.name!=='AbortError') wsBanner('err','Erreur: '+e.message+' (astuce: sert la page via http://localhost si tu es en file://).'); }
|
||
}
|
||
/* test présence sous-dossier sans le créer */
|
||
async function dirExists(parent,name){
|
||
try{ await parent.getDirectoryHandle(name); return true; }catch(e){ return false; }
|
||
}
|
||
/* au chargement : dossier LISTS_ALL_CERT prêt AUTOMATIQUEMENT (aucun prompt).
|
||
1) serveur cert_server.py → écriture disque réelle (recommandé)
|
||
2) dossier disque déjà autorisé (File System Access) → disque
|
||
3) OPFS (stockage navigateur) → repli
|
||
4) téléchargement .zip */
|
||
function wsInfoText(html){ document.getElementById('wsInfo').innerHTML='📂 Dossier de travail : '+html; }
|
||
(async()=>{
|
||
try{
|
||
// 1) serveur présent → écriture disque directe, zéro prompt
|
||
if(await probeBackend()){
|
||
wsKind='server'; rootHandle=null;
|
||
wsInfoText(`<b>${WORKDIR}/</b> <span style="color:var(--accent2)">(disque, serveur)</span>`);
|
||
wsBanner('ok',`✅ Serveur détecté. Certificats et archives écrits sur le disque dans ${WORKDIR}/ automatiquement.`);
|
||
return;
|
||
}
|
||
// 2) dossier disque déjà choisi + autorisé précédemment
|
||
const h=await idbGet('root');
|
||
if(h && h.queryPermission && await h.queryPermission({mode:'readwrite'})==='granted'){
|
||
rootHandle=await ensureWorkdir(h);
|
||
if(rootHandle!==h) await idbSet('root',rootHandle);
|
||
wsKind='disk'; renderWs(); return;
|
||
}
|
||
// 3) OPFS auto (stockage navigateur)
|
||
if(HAS_OPFS){
|
||
rootHandle=await opfsWorkdir(); wsKind='opfs'; renderWs();
|
||
wsBanner('warn',`⚠ Pas de serveur (python -m http.server est lecture seule). Fichiers dans le stockage navigateur — récupère-les via ⬇ / .zip. Pour écrire un VRAI dossier disque : lance « python cert_server.py ».`);
|
||
return;
|
||
}
|
||
// 4) rien → téléchargement
|
||
wsInfoText('<span class="none">téléchargement .zip</span>');
|
||
wsBanner('warn','ℹ Écriture disque indisponible → fichiers téléchargés (.zip). Pour écrire sur disque : « python cert_server.py ».');
|
||
}catch(e){
|
||
try{ if(HAS_OPFS){ rootHandle=await opfsWorkdir(); wsKind='opfs'; renderWs(); return; } }catch(_){}
|
||
wsInfoText('<span class="none">téléchargement .zip</span>');
|
||
wsBanner('warn','ℹ Stockage auto indisponible : '+e.message+' → téléchargement (.zip).');
|
||
}
|
||
})();
|
||
|
||
/* =============== Index des certificats partagé avec la CMDB =============== */
|
||
function readCertIndex(){ try{ return JSON.parse(localStorage.getItem('cert_index')||'[]'); }catch(e){ return []; } }
|
||
function writeCertIndex(arr){ try{ localStorage.setItem('cert_index',JSON.stringify(arr)); }catch(e){} }
|
||
function upsertCertIndex(g){
|
||
const arr=readCertIndex().filter(c=>c.dir!==g.dir);
|
||
arr.push({name:g.name,dir:g.dir,date:g.date,dns:g.dns||[],ip:g.ip||[]});
|
||
writeCertIndex(arr);
|
||
}
|
||
/* Reconstruit l'index complet (DNS/IP lus dans les .cnf) → localStorage 'cert_index'. */
|
||
async function publishCertIndex(certs){
|
||
const out=[];
|
||
for(const c of certs){
|
||
let info={dns:[],ip:[]};
|
||
try{ info=await loadInfo(c); }catch(e){}
|
||
out.push({name:c.name,dir:c.dir,date:c.date,dns:info.dns||[],ip:info.ip||[]});
|
||
}
|
||
writeCertIndex(out);
|
||
}
|
||
|
||
/* Handoff CMDB → recherche : #find=<dossier> ouvre l'onglet Certificats + filtre.
|
||
Sur 'load' : les handlers d'onglets sont déjà attachés (click effectif). */
|
||
window.addEventListener('load', ()=>{
|
||
// version de la base CMDB (partagée via localStorage)
|
||
try{ const s=localStorage.getItem('cmdb_db_status'); const el=document.getElementById('dbStatus'); if(s&&el) el.textContent=s; }catch(e){}
|
||
const m=(location.hash||'').match(/[#&]find=([^&]+)/);
|
||
if(!m) return;
|
||
let q=''; try{ q=decodeURIComponent(m[1]); }catch(e){}
|
||
const qi=document.getElementById('q'); if(qi) qi.value=q;
|
||
history.replaceState(null,'',location.pathname+location.search);
|
||
const searchTab=document.querySelector('.tab[data-tab="search"]');
|
||
if(searchTab) searchTab.click();
|
||
// Le workspace (serveur / disque / OPFS) s'initialise en asynchrone : le 1er
|
||
// renderList peut tomber avant qu'il soit prêt (liste vide). On relance dès qu'il
|
||
// est disponible, sinon la recherche n'aboutit qu'après une frappe manuelle.
|
||
let tries=0;
|
||
const iv=setInterval(()=>{
|
||
if(API.on || rootHandle){ clearInterval(iv); renderList(); }
|
||
else if(++tries>60){ clearInterval(iv); } // ~6 s max
|
||
},100);
|
||
});
|
||
|
||
/* =============== force de clé (radios) =============== */
|
||
const KEY_OPTS=[
|
||
{bits:2048,label:'RSA 2048',note:'standard, rapide'},
|
||
{bits:3072,label:'RSA 3072',note:'renforcé'},
|
||
{bits:4096,label:'RSA 4096',note:'max sécurité, plus lent'}
|
||
];
|
||
function renderKeyOpts(container,selected,onPick){
|
||
container.innerHTML='';
|
||
KEY_OPTS.forEach(o=>{
|
||
const b=document.createElement('button');
|
||
b.type='button';b.className='keyopt'+(o.bits===selected?' on':'');
|
||
b.innerHTML=`<b>${o.label}</b><small>${o.note}</small>`;
|
||
b.onclick=()=>{selected=o.bits;[...container.children].forEach(x=>x.classList.remove('on'));b.classList.add('on');onPick(o.bits);};
|
||
container.appendChild(b);
|
||
});
|
||
}
|
||
renderKeyOpts(document.getElementById('bits1'), state.bits, v=>state.bits=v);
|
||
|
||
/* =============== champs DN (C/ST/L/O/OU) =============== */
|
||
function renderDn(container,vals){
|
||
container.innerHTML='';
|
||
DN_FIELDS.forEach(f=>{
|
||
const w=document.createElement('div');
|
||
w.innerHTML=`<label>${f.label}</label><input type="text" data-dn="${f.k}" placeholder="${f.ph}" autocomplete="off" value="${(vals[f.k]||'').replace(/"/g,'"')}">`;
|
||
container.appendChild(w);
|
||
});
|
||
}
|
||
function readDn(container){
|
||
const o={};
|
||
container.querySelectorAll('[data-dn]').forEach(i=>o[i.dataset.dn]=i.value.trim());
|
||
return o;
|
||
}
|
||
|
||
/* =============== préférences =============== */
|
||
document.getElementById('prefsBtn').onclick=()=>{
|
||
document.getElementById('pDomain').value=PREFS.domain;
|
||
document.getElementById('pEmail').value=PREFS.email;
|
||
document.getElementById('pTicket').value=PREFS.ticket_url||'';
|
||
renderKeyOpts(document.getElementById('pBits'),PREFS.bits,v=>PREFS._bits=v);
|
||
PREFS._bits=PREFS.bits;
|
||
renderDn(document.getElementById('pDn'),PREFS);
|
||
document.getElementById('pMsg').className='banner';document.getElementById('pMsg').textContent='';
|
||
document.getElementById('pmodal').classList.add('show');
|
||
};
|
||
document.getElementById('pClose').onclick=()=>document.getElementById('pmodal').classList.remove('show');
|
||
document.getElementById('pmodal').onclick=e=>{if(e.target.id==='pmodal')document.getElementById('pmodal').classList.remove('show');};
|
||
document.getElementById('pSave').onclick=()=>{
|
||
const d=document.getElementById('pDomain').value.trim(), em=document.getElementById('pEmail').value.trim();
|
||
const m=document.getElementById('pMsg');
|
||
if(!d){m.className='banner err';m.textContent='Domaine requis.';return;}
|
||
const dn=readDn(document.getElementById('pDn'));
|
||
let tkUrl=document.getElementById('pTicket').value.trim();
|
||
if(tkUrl && !/^https?:\/\//i.test(tkUrl)) tkUrl='https://'+tkUrl;
|
||
PREFS={domain:d, email:em, bits:PREFS._bits||PREFS.bits, ...dn, ticket_url:tkUrl};
|
||
localStorage.setItem(PREF_KEY,JSON.stringify({domain:d,email:em,bits:PREFS.bits,...dn,ticket_url:tkUrl}));
|
||
applyPrefs();
|
||
state.bits=PREFS.bits; state.dn=prefsDn();
|
||
renderKeyOpts(document.getElementById('bits1'), state.bits, v=>state.bits=v);
|
||
document.getElementById('dirPreview').textContent=`${srv.value.trim()||'<nom>'}.${DOMAIN}`;
|
||
m.className='banner ok';m.textContent='✅ Préférences enregistrées.';
|
||
setTimeout(()=>document.getElementById('pmodal').classList.remove('show'),700);
|
||
};
|
||
|
||
/* =============== tabs =============== */
|
||
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
|
||
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
|
||
t.classList.add('active');
|
||
const g=t.dataset.tab==='gen';
|
||
document.getElementById('tab-gen').classList.toggle('hidden',!g);
|
||
document.getElementById('tab-search').classList.toggle('hidden',g);
|
||
if(!g) renderList();
|
||
});
|
||
|
||
/* =============== stepper =============== */
|
||
function goStep(n){
|
||
document.querySelectorAll('.step').forEach(s=>{const i=+s.dataset.s;
|
||
s.classList.toggle('active',i===n);s.classList.toggle('done',i<n);});
|
||
document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('hidden',+p.dataset.p!==n));
|
||
}
|
||
|
||
/* =============== step 1 =============== */
|
||
const srv=document.getElementById('server');
|
||
srv.oninput=()=>{const v=srv.value.trim();
|
||
document.getElementById('dirPreview').textContent=`${v||'<nom>'}.${DOMAIN}`;};
|
||
document.getElementById('to2').onclick=()=>{
|
||
const v=srv.value.trim(); const b=document.getElementById('b1');
|
||
if(!v){b.className='banner err';b.textContent='❌ Vous devez fournir un nom de serveur.';return;}
|
||
b.className='banner';b.textContent='';
|
||
state.server=v;
|
||
state.dns=[`${v}.${DOMAIN}`, v]; // défauts du script
|
||
state.ip=[]; state.locked=2;
|
||
renderChips(); resetGenArea();
|
||
goStep(2);
|
||
};
|
||
|
||
/* =============== step 2 : SAN =============== */
|
||
const IP_RE=/^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/;
|
||
function renderChips(){
|
||
const c=document.getElementById('chips');c.innerHTML='';
|
||
state.dns.forEach((d,i)=>c.appendChild(chip('DNS','dns',d,i<state.locked,()=>{state.dns.splice(i,1);renderChips();})));
|
||
state.ip.forEach((p,i)=>c.appendChild(chip('IP','ip',p,false,()=>{state.ip.splice(i,1);renderChips();})));
|
||
}
|
||
function chip(lbl,cls,val,locked,onDel){
|
||
const e=document.createElement('span');e.className='chip '+cls+(locked?' locked':'');
|
||
e.innerHTML=`<b>${lbl}</b>${val}<span class="x">✕</span>`;
|
||
e.querySelector('.x').onclick=onDel;return e;
|
||
}
|
||
function addSan(){
|
||
const inp=document.getElementById('sanInput');const v=inp.value.trim();if(!v)return;
|
||
if(IP_RE.test(v)) state.ip.push(v); else state.dns.push(v);
|
||
inp.value='';renderChips();inp.focus();
|
||
}
|
||
document.getElementById('addSan').onclick=addSan;
|
||
document.getElementById('sanInput').onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();addSan();}};
|
||
document.getElementById('back1').onclick=()=>goStep(1);
|
||
document.getElementById('to3').onclick=()=>goStep(3);
|
||
|
||
/* =============== .cnf builder (identique au script) =============== */
|
||
function buildCnf(server,dns,ip,dn){
|
||
dn=dn||{};
|
||
let d='';
|
||
['C','ST','L','O','OU'].forEach(k=>{if(dn[k]) d+=`${k} = ${dn[k]}\n`;});
|
||
let s=`[req]\nprompt=no\ndistinguished_name = dn\nreq_extensions = ext\n\n`+
|
||
`[dn]\n${d}CN = ${server}.${DOMAIN}\nemailAddress = ${EMAIL}\n\n`+
|
||
`[ext]\nsubjectAltName = @alt_names\n\n[alt_names]\n`;
|
||
dns.forEach((d,i)=>s+=`DNS.${i+1} = ${d}\n`);
|
||
ip.forEach((p,i)=>s+=`IP.${i+1} = ${p}\n`);
|
||
return s;
|
||
}
|
||
|
||
/* =============== génération =============== */
|
||
function resetGenArea(){
|
||
const area=document.getElementById('genArea');
|
||
area.classList.remove('hidden');
|
||
document.getElementById('result').classList.add('hidden');
|
||
document.getElementById('b3').style.display='none';
|
||
const dest = API.on ? `<b style="color:var(--accent2)">${WORKDIR}/${state.server}.${DOMAIN}/</b> (disque)`
|
||
: rootHandle ? `<b style="color:var(--accent2)">${rootHandle.name}/${state.server}.${DOMAIN}/</b>`
|
||
: `téléchargement .zip (aucun dossier lié)`;
|
||
area.innerHTML=`<p class="hint">Récap — clé RSA <b>${state.bits}</b>, CN = <code>${state.server}.${DOMAIN}</code>, email <code>${EMAIL}</code>.<br>Destination : ${dest}</p>
|
||
<div class="nav"><button class="btn ghost" id="back2">← Retour</button><button class="btn" id="doGen">⚡ Générer les fichiers</button></div>`;
|
||
document.getElementById('back2').onclick=()=>goStep(2);
|
||
document.getElementById('doGen').onclick=doGen;
|
||
}
|
||
function doGen(){
|
||
state.dn=prefsDn(); // DN repris des Préférences
|
||
const area=document.getElementById('genArea');
|
||
area.innerHTML=`<p class="hint"><span class="spin"></span>Génération clé RSA ${state.bits} + CSR…</p>`;
|
||
setTimeout(()=>runGen(area),50);
|
||
}
|
||
function genErr(area,msg){
|
||
area.innerHTML=`<div class="banner err" style="display:block">${msg}</div>
|
||
<div class="nav"><button class="btn ghost" id="back2b">← Retour</button><button class="btn" id="doGen2">↻ Réessayer</button></div>`;
|
||
document.getElementById('back2b').onclick=()=>goStep(2);
|
||
document.getElementById('doGen2').onclick=doGen;
|
||
}
|
||
/* base64url (composant JWK) → forge BigInteger */
|
||
function jwkBig(s){
|
||
s = s.replace(/-/g,'+').replace(/_/g,'/');
|
||
while(s.length % 4) s += '=';
|
||
const bytes = forge.util.decode64(s);
|
||
return new forge.jsbn.BigInteger(forge.util.createBuffer(bytes).toHex(), 16);
|
||
}
|
||
/* Génère une paire RSA sans figer le PC :
|
||
- WebCrypto natif (rapide, non bloquant, même en 4096) → converti en clé forge ;
|
||
- repli : génération forge asynchrone (par étapes) si WebCrypto indisponible. */
|
||
async function genKeyPair(bits){
|
||
if(window.crypto && crypto.subtle){
|
||
try{
|
||
const kp = await crypto.subtle.generateKey(
|
||
{ name:'RSASSA-PKCS1-v1_5', modulusLength:bits,
|
||
publicExponent:new Uint8Array([1,0,1]), hash:'SHA-256' },
|
||
true, ['sign','verify']);
|
||
const j = await crypto.subtle.exportKey('jwk', kp.privateKey);
|
||
const privateKey = forge.pki.rsa.setPrivateKey(
|
||
jwkBig(j.n), jwkBig(j.e), jwkBig(j.d),
|
||
jwkBig(j.p), jwkBig(j.q), jwkBig(j.dp), jwkBig(j.dq), jwkBig(j.qi));
|
||
return { privateKey, publicKey: forge.pki.rsa.setPublicKey(jwkBig(j.n), jwkBig(j.e)) };
|
||
}catch(err){ console.warn('WebCrypto indisponible, repli forge:', err); }
|
||
}
|
||
return await new Promise((res,rej)=>
|
||
forge.pki.rsa.generateKeyPair({ bits }, (er,kp)=> er?rej(er):res(kp)));
|
||
}
|
||
async function runGen(area){
|
||
if(typeof forge==='undefined' || !forge.pki){
|
||
genErr(area,'❌ Librairie <b>node-forge</b> non chargée (vendor/forge.min.js introuvable). Vérifie que le dossier <code>vendor/</code> est bien servi.');
|
||
return;
|
||
}
|
||
try{
|
||
// Génération non bloquante (WebCrypto natif) — plus de gel/RAM en RSA 4096.
|
||
const keys=await genKeyPair(state.bits);
|
||
const cnf=buildCnf(state.server,state.dns,state.ip,state.dn);
|
||
const csr=forge.pki.createCertificationRequest();
|
||
csr.publicKey=keys.publicKey;
|
||
const subj=[];
|
||
['C','ST','L','O','OU'].forEach(k=>{if(state.dn[k]) subj.push({shortName:k,value:state.dn[k]});});
|
||
subj.push({name:'commonName',value:`${state.server}.${DOMAIN}`});
|
||
subj.push({name:'emailAddress',value:EMAIL});
|
||
csr.setSubject(subj);
|
||
const altNames=[...state.dns.map(d=>({type:2,value:d})),...state.ip.map(p=>({type:7,ip:p}))];
|
||
csr.setAttributes([{name:'extensionRequest',extensions:[{name:'subjectAltName',altNames}]}]);
|
||
csr.sign(keys.privateKey,forge.md.sha256.create());
|
||
const g={name:state.server, dir:`${state.server}.${DOMAIN}`,
|
||
key:forge.pki.privateKeyToPem(keys.privateKey), cnf,
|
||
csr:forge.pki.certificationRequestToPem(csr),
|
||
dns:[...state.dns], ip:[...state.ip], date:new Date().toISOString()};
|
||
state.gen=g;
|
||
let msg=`✅ Fichiers générés (${g.dir}). DNS: ${g.dns.join(', ')||'—'} | IP: ${g.ip.join(', ')||'—'}`;
|
||
if(API.on){
|
||
try{ await apiWriteText(g.dir,{[`${g.name}.key`]:g.key,[`${g.name}.cnf`]:g.cnf,[`${g.name}.csr`]:g.csr});
|
||
msg=`✅ Écrits sur le disque : ${WORKDIR}/${g.dir}/`; }
|
||
catch(e){ msg=`⚠ Génération OK mais écriture serveur échouée: ${e.message}. Utilise le .zip.`; }
|
||
}else if(rootHandle){
|
||
try{ await writeToDisk(g); msg=`✅ Écrits dans ${rootHandle.name}/${g.dir}/${wsKind==='opfs'?' (stockage navigateur — bouton ⬇ pour récupérer)':''}`; }
|
||
catch(e){ msg=`⚠ Génération OK mais écriture échouée: ${e.message}. Utilise le .zip.`; }
|
||
}
|
||
showResult(g,area,msg);
|
||
upsertCertIndex(g); // publie le cert (dns/ip) pour la CMDB
|
||
}catch(e){ genErr(area,'❌ Erreur génération: '+e.message); }
|
||
}
|
||
/* Lien du ticket certificat : préférences locales du générateur (fiable) puis,
|
||
en repli, valeur partagée par la CMDB via localStorage 'cmdb_tickets'. */
|
||
function certTicketUrl(){
|
||
if(PREFS && PREFS.ticket_url) return PREFS.ticket_url;
|
||
try{ const m=JSON.parse(localStorage.getItem('cmdb_tickets')||'{}'); return (m.certificat&&m.certificat.url)||''; }
|
||
catch(e){ return ''; }
|
||
}
|
||
function openCertTicket(){
|
||
const url=certTicketUrl();
|
||
if(url) window.open(url,'_blank');
|
||
else alert('Aucun lien de ticket configuré (Préférences ⚙ → « Lien ticket certificat »).');
|
||
}
|
||
async function writeToDisk(g){
|
||
const dir=await rootHandle.getDirectoryHandle(g.dir,{create:true});
|
||
await writeFile(dir,`${g.name}.key`,g.key);
|
||
await writeFile(dir,`${g.name}.cnf`,g.cnf);
|
||
await writeFile(dir,`${g.name}.csr`,g.csr);
|
||
}
|
||
async function writeFile(dir,name,content){
|
||
const fh=await dir.getFileHandle(name,{create:true});
|
||
const w=await fh.createWritable();await w.write(content);await w.close();
|
||
}
|
||
function showResult(g,area,msg){
|
||
area.classList.add('hidden');
|
||
document.getElementById('result').classList.remove('hidden');
|
||
document.getElementById('nCnf').textContent=`${g.dir}/${g.name}.cnf`;
|
||
document.getElementById('nKey').textContent=`${g.dir}/${g.name}.key`;
|
||
document.getElementById('nCsr').textContent=`${g.dir}/${g.name}.csr`;
|
||
document.getElementById('outCnf').textContent=g.cnf;
|
||
document.getElementById('outKey').textContent=g.key;
|
||
document.getElementById('outCsr').textContent=g.csr;
|
||
const b=document.getElementById('b3');b.style.display='block';b.className='banner ok';b.textContent=msg;
|
||
const tr=document.getElementById('ticketRow'); if(tr) tr.style.display=certTicketUrl()?'block':'none';
|
||
}
|
||
|
||
/* copy / download simple */
|
||
function fileMap(g){return{key:[`${g.name}.key`,g.key],cnf:[`${g.name}.cnf`,g.cnf],csr:[`${g.name}.csr`,g.csr]};}
|
||
document.addEventListener('click',e=>{
|
||
const cp=e.target.dataset.cp;
|
||
if(cp&&state.gen){navigator.clipboard.writeText(state.gen[cp]);e.target.textContent='Copié ✓';setTimeout(()=>e.target.textContent='Copier',1200);}
|
||
const dl=e.target.dataset.dl;
|
||
if(dl&&state.gen){const[n,c]=fileMap(state.gen)[dl];download(n,c);}
|
||
});
|
||
function download(name,content){
|
||
const a=document.createElement('a');
|
||
a.href=URL.createObjectURL(new Blob([content],{type:'application/octet-stream'}));
|
||
a.download=name;a.click();URL.revokeObjectURL(a.href);
|
||
}
|
||
async function downloadZip(g){
|
||
const zip=new JSZip();const f=zip.folder(g.dir);
|
||
f.file(`${g.name}.key`,g.key);f.file(`${g.name}.cnf`,g.cnf);f.file(`${g.name}.csr`,g.csr);
|
||
const blob=await zip.generateAsync({type:'blob'});
|
||
const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`${g.dir}.zip`;a.click();URL.revokeObjectURL(a.href);
|
||
}
|
||
document.getElementById('dlZip').onclick=()=>downloadZip(state.gen);
|
||
document.getElementById('genNew').onclick=()=>{
|
||
state={server:"",dns:[],ip:[],locked:0,gen:null,bits:PREFS.bits,dn:prefsDn()};
|
||
srv.value='';document.getElementById('dirPreview').textContent=`<nom>.${DOMAIN}`;
|
||
document.getElementById('b1').textContent='';document.getElementById('b1').className='banner';
|
||
renderKeyOpts(document.getElementById('bits1'), state.bits, v=>state.bits=v);
|
||
goStep(1);
|
||
};
|
||
|
||
/* =============== recherche (scan disque réel) =============== */
|
||
document.getElementById('q').oninput=renderList;
|
||
function parseCnf(txt){
|
||
const dns=[],ip=[];
|
||
txt.split('\n').forEach(l=>{
|
||
let m=l.match(/^\s*DNS\.\d+\s*=\s*(.+?)\s*$/); if(m){dns.push(m[1]);return;}
|
||
m=l.match(/^\s*IP\.\d+\s*=\s*(.+?)\s*$/); if(m)ip.push(m[1]);
|
||
});
|
||
return {dns,ip};
|
||
}
|
||
const ARCH_EXT=['zip','tar','gz','tgz','tar.gz','7z','xz','bz2'];
|
||
function isArchive(n){const l=n.toLowerCase();return ARCH_EXT.some(e=>l.endsWith('.'+e));}
|
||
async function scanDisk(){
|
||
// 1) collecte dossiers *.gemalto.com + archives (racine + dossier archives/)
|
||
const dirs=[], archives=[];
|
||
for await (const [name,handle] of rootHandle.entries()){
|
||
if(handle.kind==='directory' && name.endsWith('.'+DOMAIN)) dirs.push([name,handle]);
|
||
else if(handle.kind==='directory' && name==='archives'){
|
||
for await (const [an,ah] of handle.entries())
|
||
if(ah.kind==='file') archives.push({name:an,handle:ah,size:0,mtime:null});
|
||
}
|
||
else if(handle.kind==='file' && isArchive(name)) archives.push({name,handle,size:0,mtime:null});
|
||
}
|
||
await Promise.all(archives.map(async a=>{try{const f=await a.handle.getFile();a.size=f.size;a.mtime=f.lastModified;}catch(e){}}));
|
||
archives.sort((a,b)=>(b.mtime||0)-(a.mtime||0));
|
||
// 2) parallèle : liste TOUS les fichiers du dossier (pas de lecture cnf au scan)
|
||
const out=await Promise.all(dirs.map(async ([name,handle])=>{
|
||
const base=name.slice(0,-(DOMAIN.length+1));
|
||
const c={name:base,dir:name,handle,files:[],date:null,info:null}; // info: chargé au clic
|
||
const jobs=[];
|
||
for await (const [fn,fh] of handle.entries()){
|
||
if(fh.kind!=='file') continue;
|
||
const ext=(fn.split('.').pop()||'').toLowerCase();
|
||
const f={name:fn,ext,handle:fh,size:0,mtime:null};
|
||
c.files.push(f);
|
||
jobs.push((async()=>{
|
||
try{
|
||
const file=await fh.getFile(); // métadonnées seules (pas de lecture cnf)
|
||
f.size=file.size; f.mtime=file.lastModified;
|
||
if(!c.date||file.lastModified>c.date) c.date=file.lastModified;
|
||
}catch(e){}
|
||
})());
|
||
}
|
||
await Promise.all(jobs);
|
||
c.files.sort((a,b)=>a.name.localeCompare(b.name));
|
||
return c.files.length ? c : null;
|
||
}));
|
||
const certs=out.filter(Boolean).sort((a,b)=>(b.date||0)-(a.date||0));
|
||
return {certs, archives};
|
||
}
|
||
// télécharge un fichier (texte OU binaire .der/.p7b) directement depuis le disque
|
||
async function dlFile(f){
|
||
const file=await f.handle.getFile();
|
||
const a=document.createElement('a');a.href=URL.createObjectURL(file);a.download=f.name;a.click();
|
||
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
|
||
}
|
||
async function zipAll(c){
|
||
const zip=new JSZip();const fold=zip.folder(c.dir);
|
||
for(const f of c.files) fold.file(f.name, await f.handle.getFile());
|
||
const blob=await zip.generateAsync({type:'blob'});
|
||
const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`${c.dir}.zip`;a.click();
|
||
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
|
||
}
|
||
function fmtSize(n){return n<1024?n+' o':n<1048576?(n/1024).toFixed(1)+' Ko':(n/1048576).toFixed(1)+' Mo';}
|
||
/* ----- archivage (.zip ; tar non dispo en navigateur) ----- */
|
||
let archCert=null;
|
||
function openArchiveModal(c){
|
||
archCert=c;
|
||
document.getElementById('aTitle').textContent=c.dir;
|
||
archChoice();
|
||
document.getElementById('amodal').classList.add('show');
|
||
}
|
||
// vue 1 : choix
|
||
function archChoice(){
|
||
document.getElementById('aSub').textContent="Que veux-tu faire ?";
|
||
document.getElementById('aBody').innerHTML=`
|
||
<div class="achoice">
|
||
<button class="btn" id="acCreate">➕ Créer une archive</button>
|
||
<button class="btn sec" id="acSearch">🔍 Chercher des archives</button>
|
||
</div>`;
|
||
document.getElementById('acCreate').onclick=archCreateView;
|
||
document.getElementById('acSearch').onclick=archSearchView;
|
||
}
|
||
// vue 2 : créer
|
||
function archCreateView(){
|
||
document.getElementById('aSub').textContent="Créer une archive .zip du dossier";
|
||
document.getElementById('aBody').innerHTML=`
|
||
<button class="btn ghost sm aBack">← Retour</button>
|
||
<label style="margin-top:14px">Date de l'archive</label>
|
||
<input type="date" id="aDate">
|
||
<p class="hint">Enregistrée dans <code>archives/<dossier>_<date>.zip</code> (ou téléchargée si aucun dossier lié). Tar non supporté par le navigateur → zip.</p>
|
||
<div class="banner" id="aMsg"></div>
|
||
<button class="btn" id="aGo" style="margin-top:8px">🗄 Créer l'archive</button>`;
|
||
document.getElementById('aDate').value=new Date().toISOString().slice(0,10);
|
||
document.querySelector('.aBack').onclick=archChoice;
|
||
document.getElementById('aGo').onclick=doArchive;
|
||
}
|
||
// vue 3 : chercher
|
||
async function archSearchView(){
|
||
document.getElementById('aSub').textContent="Archives existantes pour ce dossier";
|
||
const b=document.getElementById('aBody');
|
||
b.innerHTML=`<button class="btn ghost sm aBack">← Retour</button>
|
||
<div id="aList"><div class="empty"><span class="spin"></span>Recherche…</div></div>`;
|
||
document.querySelector('.aBack').onclick=archChoice;
|
||
const list=await listArchives(archCert.dir);
|
||
const L=document.getElementById('aList');
|
||
if(!list.length){L.innerHTML=`<div class="empty">Aucune archive trouvée pour « ${archCert.dir} ».</div>`;return;}
|
||
L.innerHTML='';
|
||
list.forEach(a=>{
|
||
const row=document.createElement('div');row.className='frow';
|
||
row.innerHTML=`<span class="fico">📦</span>
|
||
<span class="fname">${a.name}<br><small style="color:var(--muted)">${a.mtime?new Date(a.mtime).toLocaleString('fr-FR'):''} · ${fmtSize(a.size)}</small></span>
|
||
<button class="btn sec sm">⬇ Récupérer</button>`;
|
||
row.querySelector('button').onclick=()=>dlFile(a);
|
||
L.appendChild(row);
|
||
});
|
||
}
|
||
// liste les archives (archives/) filtrées par nom de dossier
|
||
async function listArchives(dir){
|
||
if(API.on){
|
||
const j=await apiList();
|
||
const d=(dir||'').toLowerCase();
|
||
return (j.archives||[]).filter(a=>!d||a.name.toLowerCase().includes(d))
|
||
.map(a=>({name:a.name,size:a.size,mtime:a.mtime,
|
||
handle:{ getFile: async()=>{ const resp=await fetch(`${WORKDIR}/archives/${encodeURIComponent(a.name)}`,{cache:'no-store'});
|
||
return new File([await resp.blob()],a.name,{lastModified:a.mtime||Date.now()}); } }}))
|
||
.sort((a,b)=>(b.mtime||0)-(a.mtime||0));
|
||
}
|
||
if(!rootHandle) return [];
|
||
const out=[];
|
||
async function grab(handle){
|
||
for await (const [n,h] of handle.entries()){
|
||
if(h.kind==='file' && isArchive(n) && (!dir||n.toLowerCase().includes(dir.toLowerCase())))
|
||
out.push({name:n,handle:h,size:0,mtime:null});
|
||
}
|
||
}
|
||
await grab(rootHandle);
|
||
try{ await grab(await rootHandle.getDirectoryHandle('archives')); }catch(e){}
|
||
await Promise.all(out.map(async a=>{try{const f=await a.handle.getFile();a.size=f.size;a.mtime=f.lastModified;}catch(e){}}));
|
||
return out.sort((a,b)=>(b.mtime||0)-(a.mtime||0));
|
||
}
|
||
async function doArchive(){
|
||
const c=archCert; if(!c) return;
|
||
const date=document.getElementById('aDate').value||new Date().toISOString().slice(0,10);
|
||
const m=document.getElementById('aMsg');m.className='banner';m.innerHTML='<span class="spin"></span>Création…';
|
||
try{
|
||
const zip=new JSZip();const fold=zip.folder(c.dir);
|
||
for(const f of c.files) fold.file(f.name, await f.handle.getFile());
|
||
const blob=await zip.generateAsync({type:'blob'});
|
||
const fname=`${c.dir}_${date}.zip`;
|
||
if(API.on){ // serveur → archives/ sur disque
|
||
await apiWriteBin(`archives/${fname}`,blob);
|
||
m.className='banner ok';m.textContent=`✅ Archive créée sur le disque : ${WORKDIR}/archives/${fname}`;
|
||
}else if(rootHandle){ // écrit dans <dossier>/archives/
|
||
const arch=await rootHandle.getDirectoryHandle('archives',{create:true});
|
||
const fh=await arch.getFileHandle(fname,{create:true});
|
||
const w=await fh.createWritable();await w.write(blob);await w.close();
|
||
m.className='banner ok';m.textContent=`✅ Archive créée : archives/${fname}`;
|
||
}else{ // fallback téléchargement
|
||
const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();
|
||
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
|
||
m.className='banner ok';m.textContent=`✅ Archive téléchargée : ${fname}`;
|
||
}
|
||
}catch(e){m.className='banner err';m.textContent='Erreur: '+e.message;}
|
||
}
|
||
/* ----- modal fichiers ----- */
|
||
function openCertModal(c){
|
||
document.getElementById('mTitle').textContent=c.dir;
|
||
renderFilesView(c);
|
||
document.getElementById('modal').classList.add('show');
|
||
}
|
||
function renderFilesView(c){
|
||
document.getElementById('mSub').textContent=`${c.files.length} fichier(s)`;
|
||
const body=document.getElementById('mBody');body.innerHTML='';
|
||
c.files.forEach(f=>{
|
||
const row=document.createElement('div');row.className='frow';
|
||
row.innerHTML=`<span class="fico">${extIcon(f.ext)}</span>
|
||
<span class="fname">${f.name}</span>
|
||
<span class="fsz">${fmtSize(f.size)}</span>
|
||
<button class="btn sec sm dlB" title="Télécharger">⬇</button>
|
||
<button class="btn sec sm del delB" title="Supprimer ce fichier">${SVG_TRASH}</button>`;
|
||
row.querySelector('.dlB').onclick=()=>dlFile(f);
|
||
row.querySelector('.delB').onclick=()=>deleteFile(c,f);
|
||
body.appendChild(row);
|
||
});
|
||
document.getElementById('mFoot').innerHTML=
|
||
`<button class="btn" id="mZip">📦 .zip</button>
|
||
<button class="btn sec" id="mFc">🔗 Construire fullchain.pem</button>`;
|
||
document.getElementById('mZip').onclick=()=>zipAll(c);
|
||
document.getElementById('mFc').onclick=()=>showChainView(c);
|
||
}
|
||
/* Supprime un fichier du dossier du certificat (un par un).
|
||
Mode serveur : PAS de route dédiée requise — on bufferise les autres fichiers,
|
||
on supprime le dossier (route `delete` existante), puis on réécrit les gardés
|
||
(route `write_bin` existante). Fonctionne sans redémarrer cert_server.py. */
|
||
async function deleteFile(c,f){
|
||
if(!confirm(`Supprimer le fichier « ${f.name} » ?\nCette action est irréversible.`)) return;
|
||
try{
|
||
if(c.handle){ // disque / OPFS : suppression directe
|
||
await c.handle.removeEntry(f.name);
|
||
}else if(API.on){ // serveur : reconstruit le dossier sans le fichier
|
||
const keep = c.files.filter(x => x !== f);
|
||
const bufs = [];
|
||
for(const k of keep) bufs.push([k.name, await k.handle.getFile()]); // bufferise AVANT toute suppression
|
||
await apiDeleteDir(c.dir, false); // supprime le dossier (garde les archives)
|
||
for(const [name, blob] of bufs) await apiWriteBin(`${c.dir}/${name}`, blob);
|
||
}else{
|
||
alert('Suppression impossible sans dossier de travail.'); return;
|
||
}
|
||
c.files = c.files.filter(x => x !== f);
|
||
renderFilesView(c);
|
||
renderList(); // rafraîchit la liste + l'index
|
||
}catch(e){ alert('Suppression échouée : '+e.message); }
|
||
}
|
||
|
||
/* ----- fullchain ----- */
|
||
function dnStr(attrs){return attrs.map(a=>`${a.shortName||a.type}=${a.value}`).join(',');}
|
||
function certCN(cert){const f=cert.subject.getField('CN');return f?f.value:'(sans CN)';}
|
||
async function collectCerts(c){
|
||
const out=[];
|
||
for(const f of c.files){
|
||
try{
|
||
const file=await f.handle.getFile();
|
||
const txt=await file.text();
|
||
if(txt.includes('BEGIN CERTIFICATE')){
|
||
(txt.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g)||[])
|
||
.forEach(b=>{try{out.push({cert:forge.pki.certificateFromPem(b),from:f.name});}catch(e){}});
|
||
}else if(['der','cer','crt'].includes(f.ext)){
|
||
try{const u8=new Uint8Array(await file.arrayBuffer());let bin='';for(let i=0;i<u8.length;i++)bin+=String.fromCharCode(u8[i]);
|
||
out.push({cert:forge.pki.certificateFromAsn1(forge.asn1.fromDer(bin)),from:f.name});}catch(e){}
|
||
}
|
||
}catch(e){}
|
||
}
|
||
const seen=new Set(),res=[]; // dedup
|
||
for(const x of out){const p=forge.pki.certificateToPem(x.cert);if(!seen.has(p)){seen.add(p);res.push(x);}}
|
||
return res;
|
||
}
|
||
// ordonne leaf → intermédiaire(s) → root via liens issuer/subject
|
||
function buildChain(items){
|
||
const certs=items.map(x=>x.cert);
|
||
const bySubj={}; certs.forEach(c=>{bySubj[dnStr(c.subject.attributes)]=c;});
|
||
const issuers=new Set(certs.map(c=>dnStr(c.issuer.attributes)));
|
||
const leaf=certs.find(c=>!issuers.has(dnStr(c.subject.attributes)))||certs[0];
|
||
const chain=[],used=new Set(); let cur=leaf;
|
||
while(cur && !used.has(dnStr(cur.subject.attributes))){
|
||
used.add(dnStr(cur.subject.attributes));
|
||
const self=dnStr(cur.subject.attributes)===dnStr(cur.issuer.attributes);
|
||
chain.push({cert:cur,self});
|
||
if(self) break;
|
||
cur=bySubj[dnStr(cur.issuer.attributes)];
|
||
}
|
||
certs.forEach(c=>{const s=dnStr(c.subject.attributes);if(!used.has(s)){used.add(s);chain.push({cert:c,self:s===dnStr(c.issuer.attributes)});}});
|
||
return chain.map((x,i)=>({cert:x.cert, role:x.self?'root':(i===0?'leaf':'intermediate')}));
|
||
}
|
||
async function showChainView(c){
|
||
document.getElementById('mSub').textContent='Assemblage fullchain.pem';
|
||
const body=document.getElementById('mBody');
|
||
body.innerHTML=`<button class="btn ghost sm" id="fcBack">← Retour</button>
|
||
<div id="fcArea"><div class="empty"><span class="spin"></span>Analyse des certificats…</div></div>`;
|
||
document.getElementById('mFoot').innerHTML='';
|
||
document.getElementById('fcBack').onclick=()=>renderFilesView(c);
|
||
const found=await collectCerts(c);
|
||
const area=document.getElementById('fcArea');
|
||
if(!found.length){area.innerHTML=`<div class="empty">Aucun certificat (PEM/DER) trouvé dans le dossier.<br>Ajoute le cert signé + les intermédiaires de la CA.</div>`;return;}
|
||
const chain=buildChain(found);
|
||
const hasRoot=chain.some(x=>x.role==='root');
|
||
area.innerHTML=`
|
||
<p class="hint">Ordre détecté (${chain.length} cert) :</p>
|
||
${chain.map((x,i)=>`<div class="frow"><span class="fico">${x.role==='leaf'?'🌿':x.role==='root'?'🏛️':'🔗'}</span>
|
||
<span class="fname">${i+1}. ${certCN(x.cert)}<br><small style="color:var(--muted)">${ROLE_FR[x.role]}</small></span></div>`).join('')}
|
||
<label style="display:flex;align-items:center;gap:8px;margin:14px 0;font-weight:500">
|
||
<input type="checkbox" id="fcRoot" ${hasRoot?'':'disabled'}> Inclure la racine (root) ${hasRoot?'':'(non trouvée)'}
|
||
</label>
|
||
<div class="banner" id="fcMsg"></div>
|
||
<button class="btn" id="fcGo">🔗 Créer fullchain.pem</button>`;
|
||
document.getElementById('fcGo').onclick=()=>makeFullchain(c,chain);
|
||
}
|
||
const ROLE_FR={leaf:'Certificat serveur (feuille)',intermediate:'Intermédiaire CA',root:'Autorité racine'};
|
||
async function makeFullchain(c,chain){
|
||
const inclRoot=document.getElementById('fcRoot').checked;
|
||
const items=chain.filter(x=>inclRoot||x.role!=='root');
|
||
const pem=items.map(x=>forge.pki.certificateToPem(x.cert).trim()).join('\n')+'\n';
|
||
const m=document.getElementById('fcMsg');
|
||
try{
|
||
if(API.on){
|
||
await apiWriteText(c.dir,{'fullchain.pem':pem});
|
||
m.className='banner ok';m.textContent=`✅ ${WORKDIR}/${c.dir}/fullchain.pem écrit sur le disque (${items.length} cert).`;
|
||
}else if(rootHandle){
|
||
const fh=await c.handle.getFileHandle('fullchain.pem',{create:true});
|
||
const w=await fh.createWritable();await w.write(pem);await w.close();
|
||
m.className='banner ok';m.textContent=`✅ ${c.dir}/fullchain.pem écrit (${items.length} cert).`;
|
||
}else{
|
||
m.className='banner ok';m.textContent=`✅ fullchain.pem généré (${items.length} cert).`;
|
||
}
|
||
download('fullchain.pem',pem); // + téléchargement
|
||
}catch(e){m.className='banner err';m.textContent='Erreur: '+e.message;}
|
||
}
|
||
function extIcon(e){return({key:'🔑',csr:'📝',cnf:'⚙️',pem:'📜',crt:'📜',cer:'📜',der:'🧩',p7b:'📦',p12:'🔐',pfx:'🔐',bundle:'📚'})[e]||'📄';}
|
||
/* icône corbeille SVG (couleur = currentColor → rouge via .del) */
|
||
const SVG_TRASH='<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6M10 11v6M14 11v6"/></svg>';
|
||
/* Ajoute manuellement des fichiers (depuis le PC) dans le dossier du certificat. */
|
||
async function addFilesToCert(c){
|
||
if(!API.on && !c.handle){
|
||
wsBanner('warn','⚠ Ajout impossible sans dossier de travail (lance « python cert_server.py » ou choisis un dossier disque).');
|
||
return;
|
||
}
|
||
const inp=document.createElement('input'); inp.type='file'; inp.multiple=true;
|
||
inp.onchange=async()=>{
|
||
const files=[...inp.files]; if(!files.length) return;
|
||
try{
|
||
for(const f of files){
|
||
if(API.on){ await apiWriteBin(`${c.dir}/${f.name}`, f); }
|
||
else {
|
||
const fh=await c.handle.getFileHandle(f.name,{create:true});
|
||
const w=await fh.createWritable(); await w.write(f); await w.close();
|
||
}
|
||
}
|
||
wsBanner('ok',`✅ ${files.length} fichier(s) ajouté(s) à ${c.dir}.`);
|
||
renderList();
|
||
}catch(e){ wsBanner('err','❌ Ajout échoué : '+e.message); }
|
||
};
|
||
inp.click();
|
||
}
|
||
|
||
/* charge DNS/IP à la demande (lit le .cnf seulement au clic, puis met en cache) */
|
||
async function loadInfo(c){
|
||
if(c.info) return c.info;
|
||
const cnf=c.files.find(f=>f.ext==='cnf');
|
||
if(!cnf){c.info={dns:[],ip:[],none:true};return c.info;}
|
||
try{c.info=parseCnf(await (await cnf.handle.getFile()).text());}
|
||
catch(e){c.info={dns:[],ip:[],err:e.message};}
|
||
return c.info;
|
||
}
|
||
document.getElementById('mClose').onclick=()=>document.getElementById('modal').classList.remove('show');
|
||
document.getElementById('modal').onclick=e=>{if(e.target.id==='modal')document.getElementById('modal').classList.remove('show');};
|
||
async function renderList(){
|
||
const L=document.getElementById('list');
|
||
if(!API.on && !rootHandle){
|
||
L.innerHTML=`<div class="empty">Aucun dossier de travail.<br><br>Lance « <b>python cert_server.py</b> » pour écrire/scanner sur le disque, ou clique « <b>📂 Dossier disque…</b> » en haut.${HAS_FS?'':'<br><span style="color:var(--warn)">(navigateur non compatible — utilise Chrome/Edge)</span>'}</div>`;
|
||
return;
|
||
}
|
||
L.innerHTML=`<div class="empty"><span class="spin"></span>Scan du dossier…</div>`;
|
||
let res={certs:[],archives:[]};
|
||
try{res=API.on ? await scanServer() : await scanDisk();}catch(e){L.innerHTML=`<div class="empty">Erreur scan: ${e.message}</div>`;return;}
|
||
const q=document.getElementById('q').value.trim().toLowerCase();
|
||
const match=n=>!q||n.toLowerCase().includes(q);
|
||
const certs=res.certs.filter(c=>match(c.dir)||c.files.some(f=>match(f.name)));
|
||
L.innerHTML='';
|
||
|
||
if(!certs.length){
|
||
const e=document.createElement('div');e.className='empty';
|
||
e.textContent=res.certs.length?'Aucun certificat ne correspond.':`Aucun certificat (*.${DOMAIN}) trouvé dans le dossier.`;
|
||
L.appendChild(e);
|
||
return;
|
||
}
|
||
certs.forEach(c=>{
|
||
const el=document.createElement('div');el.className='cert';
|
||
el.innerHTML=`<div class="body">
|
||
<h3>${c.dir} <span class="badge-slot"></span></h3>
|
||
<div class="meta">${c.date?'Modifié le '+new Date(c.date).toLocaleString('fr-FR'):''} · ${c.files.length} fichier(s)</div>
|
||
<div class="sans"></div>
|
||
</div>
|
||
<div class="cbtns">
|
||
<button class="btn sec ico infoB" title="Plus d'informations (DNS / IP)">i</button>
|
||
<button class="btn sec ico addB" title="Ajouter des fichiers (depuis le PC)">+</button>
|
||
<button class="btn sec ico showB" title="Télécharger">⬇</button>
|
||
<button class="btn sec ico archB" title="Archiver">🗄</button>
|
||
<button class="btn sec ico del delB" title="Supprimer le dossier">${SVG_TRASH}</button>
|
||
</div>`;
|
||
const sans=el.querySelector('.sans'), infoB=el.querySelector('.infoB');
|
||
infoB.onclick=async()=>{
|
||
if(sans.dataset.open==='1'){sans.innerHTML='';sans.dataset.open='0';infoB.classList.remove('on');return;}
|
||
infoB.disabled=true;
|
||
const info=await loadInfo(c);
|
||
sans.innerHTML = info.none ? '<span class="tag">Aucun .cnf (pas de SAN)</span>'
|
||
: info.err ? `<span class="tag">Erreur: ${info.err}</span>`
|
||
: (info.dns.map(d=>`<span class="tag">DNS ${d}</span>`).join('')
|
||
+info.ip.map(p=>`<span class="tag">IP ${p}</span>`).join('')
|
||
|| '<span class="tag">Aucun SAN</span>');
|
||
sans.dataset.open='1';infoB.disabled=false;infoB.classList.add('on');
|
||
};
|
||
el.querySelector('.body').onclick=()=>openCertModal(c); // clic sur la carte = ouvrir
|
||
el.querySelector('.addB').onclick=()=>addFilesToCert(c);
|
||
el.querySelector('.showB').onclick=()=>openCertModal(c);
|
||
el.querySelector('.archB').onclick=()=>openArchiveModal(c);
|
||
el.querySelector('.delB').onclick=()=>deleteCert(c);
|
||
L.appendChild(el);
|
||
// badge expiration : chargé en arrière-plan (n'attend pas / ne bloque pas le scan)
|
||
loadExpiry(c).then(e=>{const s=el.querySelector('.badge-slot');if(s)s.innerHTML=expBadge(e);});
|
||
});
|
||
// publie l'index complet (DNS/IP) pour la CMDB — en arrière-plan
|
||
publishCertIndex(res.certs);
|
||
}
|
||
/* lit un fichier cert (PEM ou DER) et renvoie {notAfter, days} — lazy, mis en cache */
|
||
async function parseCertFile(f){
|
||
const file=await f.handle.getFile();
|
||
const txt=await file.text();
|
||
if(txt.includes('BEGIN CERTIFICATE')) return forge.pki.certificateFromPem(txt);
|
||
const u8=new Uint8Array(await file.arrayBuffer()); // DER binaire → binary string
|
||
let bin=''; for(let i=0;i<u8.length;i++) bin+=String.fromCharCode(u8[i]);
|
||
return forge.pki.certificateFromAsn1(forge.asn1.fromDer(bin));
|
||
}
|
||
async function loadExpiry(c){
|
||
if(c.expiry!==undefined) return c.expiry;
|
||
const cands=c.files.filter(f=>['crt','cer','pem','der'].includes(f.ext));
|
||
for(const f of cands){
|
||
try{
|
||
const cert=await parseCertFile(f);
|
||
const na=cert.validity.notAfter;
|
||
c.expiry={notAfter:na, days:Math.floor((na.getTime()-Date.now())/86400000)};
|
||
return c.expiry;
|
||
}catch(e){}
|
||
}
|
||
c.expiry=null; return null; // pas de cert signé (ex: CSR seul)
|
||
}
|
||
function expBadge(e){
|
||
if(!e) return '';
|
||
const d=new Date(e.notAfter).toLocaleDateString('fr-FR');
|
||
if(e.days<0) return `<span class="badge exp" title="Expiré le ${d}">⛔ expiré</span>`;
|
||
if(e.days<=30) return `<span class="badge soon" title="Expire le ${d}">⚠ expire dans ${e.days}j</span>`;
|
||
return `<span class="badge ok" title="Valide jusqu'au ${d}">✔ valide ${e.days}j</span>`;
|
||
}
|
||
async function deleteCert(c){
|
||
if(!confirm(`⚠ SUPPRESSION DÉFINITIVE\n\nSupprimer le dossier « ${c.dir} » et TOUS ses fichiers (${c.files.length}) du disque ?\n\nCette action est IRRÉVERSIBLE.`)) return;
|
||
// demande pour les archives liées
|
||
let delArch=false;
|
||
const arch=await listArchives(c.dir);
|
||
if(arch.length) delArch=confirm(`${arch.length} archive(s) liée(s) à « ${c.dir} » trouvée(s) :\n${arch.map(a=>' • '+a.name).join('\n')}\n\nLes supprimer aussi ? (Annuler = garder les archives)`);
|
||
try{
|
||
if(API.on){
|
||
await apiDeleteDir(c.dir,delArch);
|
||
}else{
|
||
await rootHandle.removeEntry(c.dir,{recursive:true});
|
||
if(delArch) await deleteArchives(c.dir);
|
||
}
|
||
renderList();
|
||
}catch(e){ alert('Erreur suppression: '+e.message); }
|
||
}
|
||
// supprime les fichiers archive (archives/) contenant <dir> dans le nom
|
||
async function deleteArchives(dir){
|
||
if(API.on){ await apiDeleteArchives(dir); return; }
|
||
const d=dir.toLowerCase();
|
||
for await (const [n,h] of rootHandle.entries())
|
||
if(h.kind==='file' && isArchive(n) && n.toLowerCase().includes(d)) await rootHandle.removeEntry(n);
|
||
try{
|
||
const ah=await rootHandle.getDirectoryHandle('archives');
|
||
for await (const [n,h] of ah.entries())
|
||
if(h.kind==='file' && isArchive(n) && n.toLowerCase().includes(d)) await ah.removeEntry(n);
|
||
}catch(e){}
|
||
}
|
||
/* handlers modal archive */
|
||
document.getElementById('aClose').onclick=()=>document.getElementById('amodal').classList.remove('show');
|
||
document.getElementById('amodal').onclick=e=>{if(e.target.id==='amodal')document.getElementById('amodal').classList.remove('show');};
|
||
|
||
resetGenArea();
|
||
</script>
|
||
</body>
|
||
</html>
|