#!/usr/bin/env python3 """ Serveur unifié du portail Infra (CMDB + Certificats + Switch + Mots de passe). Un seul serveur, lancé à la racine du projet : python3 server.py # http://127.0.0.1:8000 → CMDB python3 server.py 8080 # port custom - http://127.0.0.1:8000/ → redirige vers la CMDB - 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) - 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 from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs _arg = sys.argv[1].strip() if len(sys.argv) > 1 else "" PORT = int(_arg) if _arg else int(os.environ.get("PORT", 8000)) HERE = os.path.dirname(os.path.abspath(__file__)) # racine projet 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) import cmdb_server as C # ne démarre rien (guarded __main__) SAFE = re.compile(r"^[A-Za-z0-9._@\- ]+$") def safe_name(n): n = (n or "").strip() if not n or n in (".", "..") or "/" in n or "\\" in n or not SAFE.match(n): raise ValueError("nom invalide: %r" % n) return n 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 # ---------- helpers ---------- def _json(self, obj, code=200): body = json.dumps(obj).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) def _err(self, msg, code=400): self._json({"ok": False, "error": str(msg)}, code) def _send_db(self, code, data, version): self.send_response(code) self.send_header("Content-Type", "application/octet-stream") self.send_header("X-DB-Version", str(version)) self.send_header("Cache-Control", "no-store") self.send_header("Content-Length", str(len(data))) self.end_headers() if data: self.wfile.write(data) def _send_download(self, data, filename): self.send_response(200) self.send_header("Content-Type", "application/octet-stream") self.send_header("Content-Disposition", 'attachment; filename="%s"' % filename) self.send_header("Cache-Control", "no-store") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def _body(self): n = int(self.headers.get("Content-Length", 0)) return json.loads(self.rfile.read(n) or b"{}") def _cert_api(self): p = self.path.split("?", 1)[0] if "/cert-generator/api/" in p: return p.split("/api/", 1)[1] return None # ---------- GET ---------- def do_GET(self): # racine → CMDB if self.path in ("/", "/index.html"): self.send_response(302) self.send_header("Location", "/cmdb/cmdb.html") self.end_headers() return # base CMDB partagée if self.path == "/db": with C.DB_LOCK: 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?"): name = (parse_qs(urlparse(self.path).query).get("name") or [""])[0] try: data = C.snapshot_bytes(name) except (ValueError, OSError): return self._json({"error": "snapshot introuvable"}, 404) return self._send_download(data, os.path.basename(name)) # API certs (lecture) api = self._cert_api() if api == "ping": return self._json({"ok": True, "workdir": "LISTS_ALL_CERT"}) if api == "list": return self._json(self.cert_list()) return super().do_GET() # ---------- POST ---------- def do_POST(self): # base CMDB if self.path == "/db": data = self.rfile.read(int(self.headers.get("Content-Length", 0))) base = int(self.headers.get("X-Base-Version", "0") or 0) cur = C.db_version() if base != cur: return self._send_db(409, C.db_bytes(), cur) return self._send_db(200, b"", C.db_write_and_snapshot(data)) # snapshots CMDB (création / suppression / restauration) if self.path in ("/db/snapshot", "/db/snapshot/delete", "/db/restore"): body = self._body() if self.path == "/db/snapshot": meta = C.create_snapshot(label=body.get("label") or "manual") if not meta: return self._json({"error": "base vide — rien à sauvegarder"}, 400) return self._json(meta) if self.path == "/db/snapshot/delete": try: C.delete_snapshot(body.get("name") or "") except (ValueError, OSError): return self._json({"error": "nom de snapshot invalide"}, 400) return self._json({"ok": True}) try: C.restore_snapshot(body.get("name") or "") except (ValueError, FileNotFoundError): return self._json({"error": "snapshot introuvable"}, 404) with C.DB_LOCK: return self._send_db(200, C.db_bytes(), C.db_version()) # LDAP (délégué à cmdb_server) if self.path in ("/ldap/search", "/ldap/computer"): if not C.HAS_LDAP: return self._json( {"error": "module ldap3 manquant — pip install ldap3"}, 500 ) try: body = json.loads( self.rfile.read(int(self.headers.get("Content-Length", 0))) ) fn = C.ldap_computer if self.path == "/ldap/computer" else C.ldap_search if self.path == "/ldap/search": return self._json( fn( body.get("config") or {}, (body.get("query") or "").strip(), int(body.get("limit") or 25), ) ) return self._json( fn(body.get("config") or {}, (body.get("query") or "").strip()) ) 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: if api == "write": return self.cert_write_text() if api == "write_bin": return self.cert_write_bin() if api == "delete": return self.cert_delete_dir() if api == "delete_archives": return self.cert_delete_archives() except Exception as e: 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 def cert_write_text(self): d = self._body() dname = safe_name(d["dir"]) target = os.path.join(WORK, dname) os.makedirs(target, exist_ok=True) for fname, content in (d.get("files") or {}).items(): with open( os.path.join(target, safe_name(fname)), "w", encoding="utf-8", newline="", ) as f: f.write(content) invalidate_cert_cache() self._json({"ok": True, "path": "LISTS_ALL_CERT/%s/" % dname}) def cert_write_bin(self): d = self._body() parts = [safe_name(x) for x in d["path"].split("/") if x != ""] if not parts: raise ValueError("path vide") target = os.path.join(WORK, *parts) 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): d = self._body() dname = safe_name(d["dir"]) target = os.path.join(WORK, dname) if os.path.isdir(target): 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): low = dname.lower() if os.path.isdir(ARCH): for a in os.listdir(ARCH): if low in a.lower(): try: os.remove(os.path.join(ARCH, a)) except OSError: pass if __name__ == "__main__": ensure_work() os.chdir(HERE) if not C.HAS_LDAP: print("⚠ ldap3 absent : recherche AD de la CMDB désactivée (pip install ldap3)") print("Portail Infra : http://127.0.0.1:%d → CMDB" % PORT) print("CMDB base : %s" % C.DB_PATH) print("Certificats : %s (créé automatiquement)" % WORK) print("Ctrl+C pour arrêter.") ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()