init: NetMap — scanner réseau local avec interface web
Scan nmap de 192.168.1.10-150, affichage IP/MAC/fabricant/hostname, nommage personnalisé par appareil (JSON), serveur HTTP Python stdlib. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,262 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""NetMap - Scan et nommage des appareils réseau 192.168.1.10-150"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
DATA_FILE = os.path.join(os.path.dirname(__file__), "devices.json")
|
||||||
|
IP_START = "192.168.1.10"
|
||||||
|
IP_END = "192.168.1.150"
|
||||||
|
NMAP_RANGE = f"192.168.1.10-150"
|
||||||
|
PORT = 8787
|
||||||
|
|
||||||
|
|
||||||
|
def load_devices():
|
||||||
|
if os.path.exists(DATA_FILE):
|
||||||
|
with open(DATA_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_devices(data):
|
||||||
|
with open(DATA_FILE, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def run_scan():
|
||||||
|
"""Lance nmap et retourne la liste des hôtes détectés."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["nmap", "-sn", "-T4", "--host-timeout", "2s", NMAP_RANGE],
|
||||||
|
capture_output=True, text=True, timeout=120
|
||||||
|
)
|
||||||
|
return parse_nmap_output(result.stdout)
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nmap_output(output):
|
||||||
|
hosts = []
|
||||||
|
current = {}
|
||||||
|
for line in output.splitlines():
|
||||||
|
m_host = re.search(r"Nmap scan report for (.+)", line)
|
||||||
|
if m_host:
|
||||||
|
if current.get("ip"):
|
||||||
|
hosts.append(current)
|
||||||
|
raw = m_host.group(1)
|
||||||
|
# "hostname (ip)" ou juste "ip"
|
||||||
|
ip_match = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)", raw)
|
||||||
|
if ip_match:
|
||||||
|
current = {"ip": ip_match.group(1), "hostname": raw.split("(")[0].strip()}
|
||||||
|
else:
|
||||||
|
current = {"ip": raw.strip(), "hostname": ""}
|
||||||
|
current["mac"] = ""
|
||||||
|
current["vendor"] = ""
|
||||||
|
continue
|
||||||
|
m_mac = re.search(r"MAC Address: ([0-9A-F:]+)\s*(?:\((.+)\))?", line, re.I)
|
||||||
|
if m_mac:
|
||||||
|
current["mac"] = m_mac.group(1)
|
||||||
|
current["vendor"] = m_mac.group(2) or ""
|
||||||
|
if current.get("ip"):
|
||||||
|
hosts.append(current)
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
HTML = r"""<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>NetMap — Réseau local</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e2e8f0; min-height: 100vh; }
|
||||||
|
header { background: #1a1d2e; border-bottom: 1px solid #2d3250; padding: 18px 32px; display: flex; align-items: center; gap: 16px; }
|
||||||
|
header h1 { font-size: 1.4rem; font-weight: 700; color: #7c8cf8; }
|
||||||
|
header span { font-size: 0.85rem; color: #64748b; }
|
||||||
|
.container { max-width: 960px; margin: 0 auto; padding: 28px 20px; }
|
||||||
|
.toolbar { display: flex; gap: 12px; margin-bottom: 24px; align-items: center; flex-wrap: wrap; }
|
||||||
|
button { background: #7c8cf8; color: #fff; border: none; border-radius: 8px; padding: 9px 20px; font-size: 0.9rem; cursor: pointer; font-weight: 600; transition: background .15s; }
|
||||||
|
button:hover { background: #6370f0; }
|
||||||
|
button:disabled { background: #3d4466; color: #8a94b4; cursor: not-allowed; }
|
||||||
|
.status { font-size: 0.85rem; color: #64748b; }
|
||||||
|
.status.scanning { color: #f59e0b; }
|
||||||
|
.status.done { color: #22c55e; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
thead th { background: #1a1d2e; padding: 12px 14px; text-align: left; font-size: 0.78rem; text-transform: uppercase; letter-spacing: .06em; color: #64748b; border-bottom: 1px solid #2d3250; }
|
||||||
|
tbody tr { border-bottom: 1px solid #1e2235; transition: background .1s; }
|
||||||
|
tbody tr:hover { background: #1a1d2e; }
|
||||||
|
td { padding: 11px 14px; font-size: 0.9rem; }
|
||||||
|
td.ip { font-family: monospace; color: #7c8cf8; font-weight: 600; }
|
||||||
|
td.mac { font-family: monospace; font-size: 0.82rem; color: #94a3b8; }
|
||||||
|
td.vendor { font-size: 0.82rem; color: #64748b; }
|
||||||
|
td.hostname { font-size: 0.82rem; color: #94a3b8; }
|
||||||
|
.name-cell { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.name-input { background: #252840; border: 1px solid #3d4466; color: #e2e8f0; border-radius: 6px; padding: 5px 10px; font-size: 0.88rem; flex: 1; min-width: 120px; transition: border-color .15s; }
|
||||||
|
.name-input:focus { outline: none; border-color: #7c8cf8; }
|
||||||
|
.save-btn { background: #1e2235; color: #7c8cf8; border: 1px solid #3d4466; border-radius: 6px; padding: 5px 12px; font-size: 0.82rem; cursor: pointer; white-space: nowrap; transition: all .15s; }
|
||||||
|
.save-btn:hover { background: #2d3250; border-color: #7c8cf8; }
|
||||||
|
.saved-flash { color: #22c55e; font-size: 0.78rem; }
|
||||||
|
.empty { text-align: center; padding: 60px; color: #64748b; }
|
||||||
|
.badge { display: inline-block; background: #22c55e22; color: #22c55e; border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; font-weight: 700; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>🌐 NetMap</h1>
|
||||||
|
<span>192.168.1.10 — 192.168.1.150</span>
|
||||||
|
</header>
|
||||||
|
<div class="container">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="scanBtn" onclick="scan()">🔍 Scanner le réseau</button>
|
||||||
|
<span class="status" id="status">Cliquez sur Scanner pour démarrer</span>
|
||||||
|
</div>
|
||||||
|
<div id="result"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let allDevices = {};
|
||||||
|
|
||||||
|
async function scan() {
|
||||||
|
const btn = document.getElementById('scanBtn');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
btn.disabled = true;
|
||||||
|
status.className = 'status scanning';
|
||||||
|
status.textContent = '⏳ Scan en cours (peut prendre 30–60 s)…';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/scan');
|
||||||
|
const data = await r.json();
|
||||||
|
status.className = 'status done';
|
||||||
|
if (data.error) {
|
||||||
|
status.textContent = '❌ Erreur : ' + data.error;
|
||||||
|
} else {
|
||||||
|
status.textContent = `✅ ${data.hosts.length} appareil(s) détecté(s)`;
|
||||||
|
allDevices = data.names || {};
|
||||||
|
renderTable(data.hosts, allDevices);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
status.className = 'status';
|
||||||
|
status.textContent = '❌ Erreur réseau : ' + e;
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(hosts, names) {
|
||||||
|
const el = document.getElementById('result');
|
||||||
|
if (!hosts.length) { el.innerHTML = '<p class="empty">Aucun appareil détecté.</p>'; return; }
|
||||||
|
let rows = hosts.map(h => {
|
||||||
|
const name = names[h.ip] || '';
|
||||||
|
return `<tr>
|
||||||
|
<td class="ip">${h.ip}</td>
|
||||||
|
<td class="mac">${h.mac || '—'}</td>
|
||||||
|
<td class="vendor">${h.vendor || '—'}</td>
|
||||||
|
<td class="hostname">${h.hostname || '—'}</td>
|
||||||
|
<td>
|
||||||
|
<div class="name-cell">
|
||||||
|
<input class="name-input" id="n_${h.ip.replace(/\./g,'_')}" value="${escHtml(name)}" placeholder="Nom personnalisé…">
|
||||||
|
<button class="save-btn" onclick="saveName('${h.ip}')">Sauver</button>
|
||||||
|
<span class="saved-flash" id="f_${h.ip.replace(/\./g,'_')}"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
el.innerHTML = `<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>IP</th><th>MAC</th><th>Fabricant</th><th>Hostname</th><th>Nom perso</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
async function saveName(ip) {
|
||||||
|
const key = ip.replace(/\./g,'_');
|
||||||
|
const val = document.getElementById('n_'+key).value.trim();
|
||||||
|
const flash = document.getElementById('f_'+key);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/name', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ip, name: val})
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
flash.textContent = d.ok ? '✓' : '✗';
|
||||||
|
setTimeout(() => flash.textContent = '', 2000);
|
||||||
|
} catch(e) { flash.textContent = '✗'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger les noms sauvegardés au démarrage
|
||||||
|
fetch('/names').then(r => r.json()).then(d => { allDevices = d; });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
pass # Silence les logs HTTP
|
||||||
|
|
||||||
|
def send_json(self, data, code=200):
|
||||||
|
body = json.dumps(data).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", len(body))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path == "/" or path == "/index.html":
|
||||||
|
body = HTML.encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", len(body))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
elif path == "/scan":
|
||||||
|
hosts = run_scan()
|
||||||
|
if isinstance(hosts, dict) and "error" in hosts:
|
||||||
|
self.send_json({"error": hosts["error"], "hosts": [], "names": {}})
|
||||||
|
else:
|
||||||
|
names = load_devices()
|
||||||
|
self.send_json({"hosts": hosts, "names": names})
|
||||||
|
elif path == "/names":
|
||||||
|
self.send_json(load_devices())
|
||||||
|
else:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path == "/name":
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = json.loads(self.rfile.read(length))
|
||||||
|
ip = body.get("ip", "").strip()
|
||||||
|
name = body.get("name", "").strip()
|
||||||
|
if re.match(r"^\d+\.\d+\.\d+\.\d+$", ip):
|
||||||
|
data = load_devices()
|
||||||
|
if name:
|
||||||
|
data[ip] = name
|
||||||
|
elif ip in data:
|
||||||
|
del data[ip]
|
||||||
|
save_devices(data)
|
||||||
|
self.send_json({"ok": True})
|
||||||
|
else:
|
||||||
|
self.send_json({"ok": False, "error": "IP invalide"}, 400)
|
||||||
|
else:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
server = HTTPServer(("0.0.0.0", PORT), Handler)
|
||||||
|
print(f"NetMap démarré → http://localhost:{PORT}")
|
||||||
|
print("Ctrl+C pour arrêter")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nArrêt.")
|
||||||
Reference in New Issue
Block a user