diff --git a/.gitignore b/.gitignore index b59570c..dce6236 100644 --- a/.gitignore +++ b/.gitignore @@ -9,10 +9,6 @@ cmdb/snapshots/ # Certificats générés (contiennent des clés privées) cert-generator/LISTS_ALL_CERT/ -# Images et fichiers importés dans les templates email (contenu utilisateur) -password-generator/images/ -password-generator/files/ - # === Environnement / build === .venv/ __pycache__/ diff --git a/README.md b/README.md index 54f279d..4298614 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,8 @@ Portail d'outils pour administrateurs systèmes et réseau, servi par un serveur - **cmdb** — inventaire (machines, réseaux, IP/MAC, salles & baies), recherche Active Directory, import/export CSV, snapshots de la base - **cert-generator** — génération de certificats SSL (clés RSA, CSR) - **switch-config** — mapper de VLAN et export de configuration switch -- **password-generator** — génération de mots de passe aléatoires, passphrases et mots de passe temporaires (préfixe configurable, envoi par mail via l'AD + templates) -Le tout accessible depuis un seul serveur, avec navigation entre les apps. +Le tout accessible depuis un seul serveur, avec navigation entre les 3 apps. ## Prérequis diff --git a/cert-generator/dashboard.html b/cert-generator/dashboard.html index f527804..e64b99d 100644 --- a/cert-generator/dashboard.html +++ b/cert-generator/dashboard.html @@ -184,7 +184,6 @@
CMDBMachines, réseaux, salles
🔐
Générateur certificatsClés RSA, CSR, SSL
🔌
Configuration switchVLANs, export config
-
🔑
Mots de passeGénérateur & envoi
PréférencesTypes, tickets, réglages
diff --git a/cmdb b/cmdb index 805f0fe..18751d7 160000 --- a/cmdb +++ b/cmdb @@ -1 +1 @@ -Subproject commit 805f0fe00b1314970a95c717c2945102130a0fd4 +Subproject commit 18751d75c88a32790a50c1f0a458a2657db6a2ad diff --git a/password-generator/password_generator.html b/password-generator/password_generator.html deleted file mode 100644 index fd36986..0000000 --- a/password-generator/password_generator.html +++ /dev/null @@ -1,1238 +0,0 @@ - - - - - -Générateur de mots de passe — Admin - - - - - - - -
- -
-
-

Générateur de mots de passe

Mots de passe aléatoires, passphrases et mots de passe temporaires

-
- -
- - - -
- - -
-
-
- - -
- ⚙ Gérer -
-
Préréglage charset + longueur + préfixe. « (Personnalisé) » = réglages manuels ci-dessous.
-
- - -
-
-
- - -
-
-
-
- - -
-

Options — mot de passe aléatoire

-
-
- - -
-
- -
- - -
-
-
-
- - - - -
-
- - - - - - -
- -
- -
-
- - - - diff --git a/server.py b/server.py index 55bfe94..b191af4 100644 --- a/server.py +++ b/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Serveur unifié du portail Infra (CMDB + Certificats + Switch + Mots de passe). +Serveur unifié du portail Infra (CMDB + Certificats + Switch). Un seul serveur, lancé à la racine du projet : @@ -11,15 +11,11 @@ Un seul serveur, lancé à la racine du projet : - CMDB : base partagée cmdb.db sourcée automatiquement (/db, versionnée) - Générateur de certificats : écrit sur le disque dans cert-generator/LISTS_ALL_CERT/ (créé au besoin), aucun prompt -- Sert les 4 applications + le switcher (liens relatifs OK) +- Sert les 3 applications + le switcher (liens relatifs OK) - Recherche AD/LDAP de la CMDB conservée (nécessite `pip install ldap3`) -- Générateur de mots de passe : envoi de mails via POST /mail/send (SMTP - configuré dans la page, module standard smtplib) """ -import sys, os, json, base64, shutil, re, smtplib, threading, mimetypes, sqlite3 -from email.message import EmailMessage -from email.utils import formataddr +import sys, os, json, base64, shutil, re, threading from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs @@ -31,13 +27,6 @@ CMDB_DIR = os.path.join(HERE, "cmdb") CERT_DIR = os.path.join(HERE, "cert-generator") WORK = os.path.join(CERT_DIR, "LISTS_ALL_CERT") # dossier de travail certs (disque) ARCH = os.path.join(WORK, "archives") -PWGEN_DIR = os.path.join(HERE, "password-generator") -PWGEN_IMG = os.path.join(PWGEN_DIR, "images") # images des templates email (disque) -PWGEN_IMG_URL = "/password-generator/images/" # préfixe d'URL servi (chdir HERE) -PWGEN_FILES = os.path.join(PWGEN_DIR, "files") # fichiers joignables (rdp, pdf…) -PWGEN_FILES_URL = "/password-generator/files/" -MAX_IMG_BYTES = 2 * 1024 * 1024 # 2 Mo par image -MAX_FILE_BYTES = 10 * 1024 * 1024 # 10 Mo par fichier joint # réutilise la logique CMDB existante (base partagée cmdb.db + LDAP) sys.path.insert(0, CMDB_DIR) @@ -57,175 +46,6 @@ def ensure_work(): os.makedirs(WORK, exist_ok=True) -def read_vlans(): - """Lit les VLANs depuis la table `networks` de la base CMDB partagée - (cmdb.db). Un réseau devient un VLAN dès qu'il porte un numéro de VLAN. - Renvoie une liste triée : [{"vlan": int, "name": str, "color": str}, ...]. - Source de vérité unique pour le mapper de switch (remplace vlan_database.js).""" - out = [] - try: - con = sqlite3.connect("file:%s?mode=ro" % C.DB_PATH, uri=True) - except sqlite3.Error: - return out - try: - con.row_factory = sqlite3.Row - for r in con.execute( - "SELECT vlan, name, color FROM networks " - "WHERE vlan IS NOT NULL ORDER BY vlan" - ): - out.append( - { - "vlan": r["vlan"], - "name": r["name"] or ("VLAN_%s" % r["vlan"]), - "color": r["color"] or "#8B5CF6", - } - ) - except sqlite3.Error: - pass # table absente / base illisible → liste vide, repli côté client - finally: - con.close() - return out - - -# --------------------------------------------------------------------------- -# Cache du listing des certificats. -# Les certs sont des fichiers sur disque (LISTS_ALL_CERT//). -# Sans cache, chaque GET /api/list re-scanne tout le disque (N×M syscalls) → -# lent quand il y a beaucoup de certs. On mémorise le résultat et on ne le -# reconstruit que si le contenu a changé : -# - empreinte légère (mtime des sous-dossiers) → détecte ajout/suppression -# et toute écriture passant par le serveur (le mtime du dossier bouge) ; -# - invalidation explicite après chaque write/delete → couvre l'écrasement -# d'un fichier en place (même nom), que le mtime du dossier ne voit pas. -# --------------------------------------------------------------------------- -_CERT_CACHE = {"sig": None, "payload": None} -_CERT_CACHE_LOCK = threading.Lock() - - -def invalidate_cert_cache(): - with _CERT_CACHE_LOCK: - _CERT_CACHE["sig"] = None - _CERT_CACHE["payload"] = None - - -def _cert_signature(): - """Empreinte du dossier certs : (nom, mtime_ns) de chaque sous-dossier. - Ne descend pas dans les fichiers → peu de syscalls. Renvoie None si le - dossier est illisible (force alors une reconstruction).""" - sig = [] - try: - with os.scandir(WORK) as it: - for e in it: - try: - sig.append((e.name, e.stat().st_mtime_ns)) - except OSError: - continue - except OSError: - return None - sig.sort() - return tuple(sig) - - -def _build_cert_list(): - """Reconstruit le listing complet via os.scandir (le stat est replié dans la - lecture du dossier → bien moins de syscalls que listdir + stat séparés).""" - certs, archives = [], [] - try: - with os.scandir(WORK) as it: - entries = sorted(it, key=lambda e: e.name) - except OSError: - entries = [] - for entry in entries: - if entry.name == "archives": - if entry.is_dir(): - try: - with os.scandir(entry.path) as ait: - for a in sorted(ait, key=lambda e: e.name): - if a.is_file(): - st = a.stat() - archives.append( - { - "name": a.name, - "size": st.st_size, - "mtime": int(st.st_mtime * 1000), - } - ) - except OSError: - pass - continue - if not entry.is_dir(): - continue - files, date = [], 0 - try: - with os.scandir(entry.path) as sit: - subs = sorted(sit, key=lambda e: e.name) - except OSError: - continue - for f in subs: - if not f.is_file(): - continue - st = f.stat() - m = int(st.st_mtime * 1000) - date = max(date, m) - files.append( - { - "name": f.name, - "ext": (f.name.rsplit(".", 1)[-1] if "." in f.name else "").lower(), - "size": st.st_size, - "mtime": m, - } - ) - if files: - certs.append({"dir": entry.name, "files": files, "date": date}) - archives.sort(key=lambda a: -a["mtime"]) - certs.sort(key=lambda c: -c["date"]) - return {"ok": True, "certs": certs, "archives": archives} - - -# src d'images pointant vers le dossier local des templates (chemin absolu ou relatif) -_LOCAL_IMG_SRC = re.compile(r'src="([^"]*?/password-generator/images/([^"?#]+))"', re.I) - - -def inline_local_images(html): - """Remplace les par des data - URI (lecture du fichier sur disque). Les images data:/http(s): sont laissées - telles quelles. Rend le mail HTML autonome au moment de l'envoi.""" - - def repl(m): - fname = os.path.basename(m.group(2)) # anti-traversal - path = os.path.join(PWGEN_IMG, fname) - if not os.path.isfile(path): - return m.group(0) - mime = mimetypes.guess_type(path)[0] or "application/octet-stream" - with open(path, "rb") as f: - data = base64.b64encode(f.read()).decode("ascii") - return 'src="data:%s;base64,%s"' % (mime, data) - - return _LOCAL_IMG_SRC.sub(repl, html) - - -def attach_files(msg, urls): - """Joint au mail les fichiers du dossier password-generator/files/ dont l'URL - figure dans la liste `urls` (pièces jointes du template). Le nom présenté au - destinataire est le nom d'origine (suffixe aléatoire de stockage retiré).""" - seen = set() - for url in urls or []: - fname = os.path.basename(str(url)) # anti-traversal - if not fname or fname in seen: - continue - seen.add(fname) - path = os.path.join(PWGEN_FILES, fname) - if not os.path.isfile(path): - continue - ctype = mimetypes.guess_type(path)[0] or "application/octet-stream" - maintype, _, subtype = ctype.partition("/") - disp = re.sub(r"-[0-9a-f]{8}(\.[^.]+)$", r"\1", fname) # nom d'origine lisible - with open(path, "rb") as f: - msg.add_attachment( - f.read(), maintype=maintype, subtype=subtype or "octet-stream", filename=disp - ) - - class Handler(SimpleHTTPRequestHandler): def log_message(self, *a): pass @@ -286,10 +106,6 @@ class Handler(SimpleHTTPRequestHandler): return self._send_db(200, C.db_bytes(), C.db_version()) if self.path == "/db/version": return self._json({"version": C.db_version()}) - # VLANs = réseaux CMDB (table networks) → consommés par le mapper switch - if self.path == "/api/vlans": - with C.DB_LOCK: - return self._json(read_vlans()) if self.path == "/db/snapshots": return self._json({"snapshots": C.list_snapshots()}) if self.path.startswith("/db/snapshot?"): @@ -361,23 +177,6 @@ class Handler(SimpleHTTPRequestHandler): ) except Exception as ex: return self._json({"error": str(ex)}, 500) - # envoi de mail (générateur de mots de passe) - if self.path == "/mail/send": - try: - return self.mail_send() - except Exception as e: - return self._err(e, 500) - # upload d'image / de fichier joint (générateur de mots de passe) - if self.path == "/pwgen/upload-image": - try: - return self.pwgen_upload_image() - except Exception as e: - return self._err(e, 500) - if self.path == "/pwgen/upload-file": - try: - return self.pwgen_upload_file() - except Exception as e: - return self._err(e, 500) # API certs (écriture) api = self._cert_api() try: @@ -393,113 +192,49 @@ class Handler(SimpleHTTPRequestHandler): return self._err(e, 500) self._err("route inconnue: %s" % self.path, 404) - # ---------- mail (générateur de mots de passe) ---------- - def mail_send(self): - """Envoie un mail texte via le serveur SMTP fourni par la page. - - Body JSON : { smtp: {host, port, security, user, pass, from, from_name}, - to, subject, body } - security : none | starttls | ssl - """ - d = self._body() - smtp = d.get("smtp") or {} - to = (d.get("to") or "").strip() - host = (smtp.get("host") or "").strip() - if not to or "@" not in to: - raise ValueError("destinataire invalide: %r" % to) - if not host: - raise ValueError("serveur SMTP non configuré") - security = (smtp.get("security") or "starttls").lower() - try: - port = int(smtp.get("port") or 0) - except (TypeError, ValueError): - port = 0 - if not port: - port = {"ssl": 465, "starttls": 587}.get(security, 25) - sender = (smtp.get("from") or smtp.get("user") or "").strip() - if not sender: - raise ValueError("adresse expéditeur non configurée") - - msg = EmailMessage() - from_name = (smtp.get("from_name") or "").strip() - msg["From"] = formataddr((from_name, sender)) if from_name else sender - msg["To"] = to - msg["Subject"] = d.get("subject") or "" - msg.set_content(d.get("body") or "") - html = (d.get("html") or "").strip() - if html: - # les images de templates stockées sur disque sont ré-intégrées en - # data URI pour que le mail reçu soit autonome (pas de lien vers le - # serveur, images visibles hors du réseau). - html = inline_local_images(html) - msg.add_alternative(html, subtype="html") - # pièces jointes du template (ex : .rdp de connexion) - attach_files(msg, d.get("attachments")) - - if security == "ssl": - server = smtplib.SMTP_SSL(host, port, timeout=15) - else: - server = smtplib.SMTP(host, port, timeout=15) - try: - if security == "starttls": - server.starttls() - user = (smtp.get("user") or "").strip() - if user: - server.login(user, smtp.get("pass") or "") - server.send_message(msg) - finally: - try: - server.quit() - except Exception: - pass - self._json({"ok": True, "to": to}) - - # ---------- uploads de templates (disque réel) ---------- - def _save_upload(self, dest_dir, url_prefix, max_bytes, default_ext, kind): - """Stocke un fichier posté ({name, b64}) sur le disque avec un nom sûr - (radical assaini + suffixe aléatoire) et renvoie son URL servie.""" - d = self._body() - raw = (d.get("name") or kind).strip() - try: - data = base64.b64decode(d.get("b64") or "", validate=True) - except (ValueError, TypeError): - raise ValueError("%s invalide (base64)" % kind) - if not data: - raise ValueError("%s vide" % kind) - if len(data) > max_bytes: - raise ValueError("%s trop lourd (max %d Mo)" % (kind, max_bytes // (1024 * 1024))) - base, _, ext = raw.rpartition(".") - ext = re.sub(r"[^A-Za-z0-9]", "", ext).lower()[:8] or default_ext - stem = re.sub(r"[^A-Za-z0-9._-]", "_", base or kind)[:40].strip("._-") or kind - fname = "%s-%s.%s" % (stem, os.urandom(4).hex(), ext) - os.makedirs(dest_dir, exist_ok=True) - with open(os.path.join(dest_dir, fname), "wb") as f: - f.write(data) - self._json({"ok": True, "url": url_prefix + fname, "name": fname}) - - def pwgen_upload_image(self): - """Image de template → password-generator/images/ (ré-intégrée en data URI à l'envoi).""" - self._save_upload(PWGEN_IMG, PWGEN_IMG_URL, MAX_IMG_BYTES, "png", "image") - - def pwgen_upload_file(self): - """Fichier joignable (rdp, pdf…) → password-generator/files/ (joint au mail à l'envoi).""" - self._save_upload(PWGEN_FILES, PWGEN_FILES_URL, MAX_FILE_BYTES, "bin", "fichier") - # ---------- certificats (disque réel) ---------- def cert_list(self): ensure_work() - sig = _cert_signature() - with _CERT_CACHE_LOCK: - if ( - sig is not None - and sig == _CERT_CACHE["sig"] - and _CERT_CACHE["payload"] is not None - ): - return _CERT_CACHE["payload"] # inchangé → réponse mémorisée - payload = _build_cert_list() - _CERT_CACHE["sig"] = sig - _CERT_CACHE["payload"] = payload - return payload + certs, archives = [], [] + for entry in sorted(os.listdir(WORK)): + full = os.path.join(WORK, entry) + if entry == "archives": + if os.path.isdir(full): + for a in sorted(os.listdir(full)): + af = os.path.join(full, a) + if os.path.isfile(af): + st = os.stat(af) + archives.append( + { + "name": a, + "size": st.st_size, + "mtime": int(st.st_mtime * 1000), + } + ) + continue + if not os.path.isdir(full): + continue + files, date = [], 0 + for fn in sorted(os.listdir(full)): + ff = os.path.join(full, fn) + if not os.path.isfile(ff): + continue + st = os.stat(ff) + m = int(st.st_mtime * 1000) + date = max(date, m) + files.append( + { + "name": fn, + "ext": (fn.rsplit(".", 1)[-1] if "." in fn else "").lower(), + "size": st.st_size, + "mtime": m, + } + ) + if files: + certs.append({"dir": entry, "files": files, "date": date}) + archives.sort(key=lambda a: -a["mtime"]) + certs.sort(key=lambda c: -c["date"]) + return {"ok": True, "certs": certs, "archives": archives} def cert_write_text(self): d = self._body() @@ -514,7 +249,6 @@ class Handler(SimpleHTTPRequestHandler): newline="", ) as f: f.write(content) - invalidate_cert_cache() self._json({"ok": True, "path": "LISTS_ALL_CERT/%s/" % dname}) def cert_write_bin(self): @@ -526,7 +260,6 @@ class Handler(SimpleHTTPRequestHandler): os.makedirs(os.path.dirname(target), exist_ok=True) with open(target, "wb") as f: f.write(base64.b64decode(d["b64"])) - invalidate_cert_cache() self._json({"ok": True, "path": "LISTS_ALL_CERT/%s" % "/".join(parts)}) def cert_delete_dir(self): @@ -537,12 +270,10 @@ class Handler(SimpleHTTPRequestHandler): shutil.rmtree(target) if d.get("archives"): self._rm_archives(dname) - invalidate_cert_cache() self._json({"ok": True}) def cert_delete_archives(self): self._rm_archives(safe_name(self._body()["dir"])) - invalidate_cert_cache() self._json({"ok": True}) def _rm_archives(self, dname): diff --git a/switch-config/switch_vlan_mapper.html b/switch-config/switch_vlan_mapper.html index 7083125..999d3e7 100644 --- a/switch-config/switch_vlan_mapper.html +++ b/switch-config/switch_vlan_mapper.html @@ -596,6 +596,7 @@ button:active { transform: scale(0.98); } background: #101216; color: #d4d4d4; } + @@ -607,7 +608,6 @@ button:active { transform: scale(0.98); }
CMDBMachines, réseaux, salles
🔐
Générateur certificatsClés RSA, CSR, SSL
🔌
Configuration switchVLANs, export config
-
🔑
Mots de passeGénérateur & envoi
PréférencesTypes, tickets, réglages
@@ -810,35 +810,51 @@ const DEFAULT_VLAN_DATABASE = `1 = Native = #8B5CF6 200 = VLAN200 = #16A34A 300 = VLAN300 = #0EA5E9`; -// Charger la base de données VLAN au démarrage. -// Source de vérité : la table `networks` de la CMDB (SQL), exposée par le -// portail via GET /api/vlans. Chaque réseau CMDB portant un numéro de VLAN -// devient une entrée (nom + couleur). Remplace l'ancien vlan_database.js. -// Replis (mode file:// ou serveur injoignable) : base par défaut intégrée. -async function loadVlanDatabase() { - if (!location.href.startsWith('file://')) { - try { - const res = await fetch('/api/vlans', { cache: 'no-store' }); - if (res.ok) { - const rows = await res.json(); - vlanDatabase = {}; - rows.forEach(r => { - if (r.vlan === null || r.vlan === undefined || r.vlan === '') return; - vlanDatabase[String(r.vlan)] = { - name: r.name || `VLAN_${r.vlan}`, - color: r.color || '#8B5CF6' - }; - }); - console.log(`✓ ${Object.keys(vlanDatabase).length} VLAN(s) chargés depuis la CMDB (table networks)`); - return; - } - console.warn('CMDB /api/vlans a répondu', res.status, '→ base VLAN par défaut'); - } catch (error) { - console.warn('CMDB injoignable, base VLAN par défaut :', error); - } +// Charger la base de données VLAN au démarrage +function loadVlanDatabase() { + // 1️⃣ Chercher d'abord dans localStorage (changements sauvegardés par l'utilisateur) + const savedDbText = localStorage.getItem('vlanDatabaseText'); + if (savedDbText) { + parseVlanDatabase(savedDbText); + console.log('✓ Base de données chargée depuis le stockage local'); + return; + } + + // 2️⃣ Vérifier si vlan_database.js a été chargé (fonctionne en mode file://) + if (typeof VLAN_DATABASE_FILE !== 'undefined') { + parseVlanDatabase(VLAN_DATABASE_FILE); + localStorage.setItem('vlanDatabaseText', VLAN_DATABASE_FILE); + console.log('✓ Base de données chargée depuis vlan_database.js'); + return; + } + + // 3️⃣ Essayer de charger depuis le fichier externe (serveur HTTP uniquement) + if (!location.href.startsWith('file://')) { + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'vlan_database.txt', true); + xhr.onload = function() { + if (xhr.status === 200) { + parseVlanDatabase(xhr.responseText); + localStorage.setItem('vlanDatabaseText', xhr.responseText); + console.log('✓ Base de données chargée depuis vlan_database.txt'); + } else { + loadDefaultDatabase(); + } + }; + xhr.onerror = function() { + loadDefaultDatabase(); + }; + + try { + xhr.send(); + return; + } catch (error) { + loadDefaultDatabase(); + } + } else { + // Mode file:// et aucun vlan_database.js trouvé - utiliser DB par défaut + loadDefaultDatabase(); } - // Mode file:// ou serveur indisponible : base par défaut intégrée - loadDefaultDatabase(); } function loadDefaultDatabase() { @@ -2459,10 +2475,8 @@ window.addEventListener('resize', () => { }); // Charger la base de données au démarrage -window.addEventListener('load', async () => { - // Attendre les VLANs (table networks CMDB) avant toute analyse : une conf - // reçue via le hash s'analyse aussitôt et a besoin des noms/couleurs. - await loadVlanDatabase(); +window.addEventListener('load', () => { + loadVlanDatabase(); syncCmdbDbStatus(); // version de la base CMDB (partagée via localStorage) // Config transmise par la CMDB via l'URL (#cfg=...) : pré-remplir et analyser diff --git a/switch-config/vlan_database.js b/switch-config/vlan_database.js new file mode 100644 index 0000000..0c72c3b --- /dev/null +++ b/switch-config/vlan_database.js @@ -0,0 +1,11 @@ +// Fichier de base de données VLAN - À charger dans le HTML +// Placer ce fichier dans le même dossier que switch_vlan_mapper.html +// Format : = = (exemple générique, à adapter) + +const VLAN_DATABASE_FILE = `10 = MGMT = #6366f1 +20 = SERVERS = #22c55e +30 = USERS = #06b6d4 +40 = VOICE = #2dd4bf +50 = DMZ = #8b5cf6 +99 = GUEST = #f97316 +`;