commit dbebafd34adc003ea14a2d97420516bd4db9983f Author: yoann Date: Sun Oct 4 19:56:51 2026 +0200 APmanageur Wi-Fi : conteneur Debian + Apache diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e71856d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM debian:13-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONUNBUFFERED=1 + +# Apache2 + NetworkManager embarqué (pour la carte Netgear) + outils Wi-Fi + Python +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + apache2 python3 \ + network-manager wpasupplicant dbus \ + iw iproute2 rfkill wireless-regdb \ + dnsmasq-base iptables nftables procps \ + && a2enmod proxy proxy_http headers \ + && rm -rf /var/lib/apt/lists/* + +# Vos fichiers +WORKDIR /app +COPY ap.py /app/ap.py +# Nom de votre fichier HTML dans le dossier de build (par défaut index.html) +ARG HTML_FILE=index.html +COPY ${HTML_FILE} /app/index.html +COPY ${HTML_FILE} /var/www/html/index.html +COPY apache-ap.conf /etc/apache2/sites-available/000-default.conf +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh && rm -f /var/www/html/index.html.orig \ + && sed -i 's/^Listen 80$/Listen 1001/; s/^\s*Listen 443$/ Listen 1443/' /etc/apache2/ports.conf + +ENV AP_DB_FILE=/data/ap-dashboard.sqlite3 \ + AP_CONNECTION_FILE=/data/connection \ + AP_UPTIME_FILE=/data/uptime + +VOLUME ["/data", "/etc/NetworkManager/system-connections", "/var/lib/NetworkManager"] +EXPOSE 1001 8080 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..81f4238 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# apmanageur + diff --git a/ap.py b/ap.py new file mode 100644 index 0000000..045910a --- /dev/null +++ b/ap.py @@ -0,0 +1,505 @@ +#!/usr/bin/env python3 +import base64, hashlib, hmac, json, os, re, secrets, socket, sqlite3, subprocess, threading, time, urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +INTERFACE = os.environ.get('AP_INTERFACE', 'wls16b1') +HOST, PORT = '0.0.0.0', 8080 +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DB_FILE = os.environ.get('AP_DB_FILE', os.path.join(BASE_DIR, 'ap-dashboard.sqlite3')) +CONNECTION_FILE = os.environ.get('AP_CONNECTION_FILE', os.path.join(BASE_DIR, 'ap-dashboard-connection')) +UPTIME_FILE = os.environ.get('AP_UPTIME_FILE', os.path.join(BASE_DIR, 'ap-dashboard-uptime')) + +# Auto-migration de l'ancienne DB si présente +OLD_DB_FILE = '/var/lib/ap-dashboard.sqlite3' +if not os.path.exists(DB_FILE) and os.path.exists(OLD_DB_FILE): + try: + import shutil + shutil.copy2(OLD_DB_FILE, DB_FILE) + except Exception: pass + +SESSION_TTL = 3600 +sessions = {} +last_macs = set() + +def uptime_start(): + try: return float(open(UPTIME_FILE, encoding='utf8').read().strip()) + except (OSError, ValueError): return None + +def mark_ap_started(): + try: + folder = os.path.dirname(UPTIME_FILE) + if folder: os.makedirs(folder, exist_ok=True) + open(UPTIME_FILE, 'w', encoding='utf8').write(str(time.time())) + except OSError: pass + +def ap_uptime(): + started = uptime_start() + return max(0, int(time.time() - started)) if started else 0 + +def device_name(ip, mac): + mac = (mac or '').upper() + # Les baux dnsmasq peuvent être dans plusieurs fichiers selon NetworkManager. + lease_files = ['/var/lib/NetworkManager/dnsmasq.leases', '/var/lib/misc/dnsmasq.leases'] + try: + import glob + lease_files += glob.glob('/var/lib/NetworkManager/dnsmasq-*.leases') + for lease_file in lease_files: + with open(lease_file, encoding='utf8') as f: + for line in f: + fields = line.strip().split() + if len(fields) >= 4 and (fields[1].upper() == mac or fields[2] == ip): + hostname = fields[3].strip() + if hostname and hostname != '*': return hostname + except (OSError, UnicodeError): pass + # NetworkManager/dnsmasq écrit parfois uniquement le DHCPACK dans le journal. + try: + journal = command('journalctl','-u','NetworkManager','--no-pager','-n','500') + pattern = r'DHCPACK\([^)]*\)\s+(\S+)\s+([0-9a-f:]{17})\s+(\S+)' + for line in reversed(journal.splitlines()): + match = re.search(pattern, line, re.I) + if match and (match.group(1) == ip or match.group(2).upper() == mac): + hostname = match.group(3).strip() + if hostname and hostname != '*': return hostname + except Exception: pass + if ip and ip not in ('Non attribuée', '—'): + try: + name = socket.gethostbyaddr(ip)[0] + if name and name != ip: return name.split('.')[0] + except (socket.herror, socket.gaierror, OSError): pass + host_line = command('ip','neigh','show',ip) + match = re.search(r'\(([^)]+)\)', host_line) + if match and match.group(1) != ip: return match.group(1) + try: + c = db(); row = c.execute('SELECT name FROM client_names WHERE mac=?', (mac,)).fetchone(); c.close() + if row and row[0]: return row[0] + except sqlite3.Error: pass + return 'Client ' + mac.replace(':', '')[-4:] if mac else 'Nom non communiqué' + +# __AUTHENTICATION_IMPLEMENTATION__ +def db(): + folder = os.path.dirname(DB_FILE) + if folder: os.makedirs(folder, exist_ok=True) + c = sqlite3.connect(DB_FILE) + c.execute('CREATE TABLE IF NOT EXISTS auth_users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL)') + columns = {row[1] for row in c.execute('PRAGMA table_info(auth_users)').fetchall()} + if 'totp_secret' not in columns: + c.execute('ALTER TABLE auth_users ADD COLUMN totp_secret TEXT DEFAULT NULL') + c.commit() + recovery_table(c) + c.execute('CREATE TABLE IF NOT EXISTS connection_logs(id INTEGER PRIMARY KEY,event_time TEXT,event TEXT,name TEXT,ip TEXT,mac TEXT)') + c.execute('CREATE TABLE IF NOT EXISTS blocked_clients(mac TEXT PRIMARY KEY,blocked_at TEXT)') + c.execute('CREATE TABLE IF NOT EXISTS client_names(mac TEXT PRIMARY KEY,name TEXT NOT NULL,last_ip TEXT,last_seen TEXT)') + c.execute('CREATE TABLE IF NOT EXISTS preferences(key TEXT PRIMARY KEY, value TEXT NOT NULL)') + c.commit(); return c + + +def password_hash(value, salt=None): + salt = salt or secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac('sha256', value.encode(), salt, 240000) + return salt.hex() + ':' + digest.hex() + +def password_ok(value, stored): + try: + salt, digest = stored.split(':', 1) + actual = hashlib.pbkdf2_hmac('sha256', value.encode(), bytes.fromhex(salt), 240000).hex() + return hmac.compare_digest(actual, digest) + except (ValueError, TypeError): return False + +def totp_code(secret, counter=None): + counter = int(time.time() // 30) if counter is None else counter + key = base64.b32decode(secret + '=' * (-len(secret) % 8), casefold=True) + raw = hmac.new(key, counter.to_bytes(8, 'big'), hashlib.sha1).digest() + offset = raw[-1] & 15 + number = int.from_bytes(raw[offset:offset + 4], 'big') & 0x7fffffff + return f'{number % 1000000:06d}' + +def totp_ok(secret, code): + code = str(code or '').strip() + if not re.fullmatch(r'\d{6}', code): return False + counter = int(time.time() // 30) + return any(hmac.compare_digest(totp_code(secret, counter + drift), code) for drift in (-1, 0, 1)) + +def auth_state(): + c = db(); row = c.execute('SELECT username,totp_secret FROM auth_users LIMIT 1').fetchone() + rows = dict(c.execute("SELECT key,value FROM preferences").fetchall()); c.close() + return { + 'configured': bool(row), + 'two_factor': bool(row and row[1]), + 'app_name': rows.get('app_name', 'Administration de l’AP Wi‑Fi'), + 'app_short_name': rows.get('app_short_name', 'AP Manageur') + } + +RECOVERY_COUNT = 8 + +def recovery_table(c): + c.execute('CREATE TABLE IF NOT EXISTS recovery_codes(code_hash TEXT PRIMARY KEY, used INTEGER NOT NULL DEFAULT 0)') + +def recovery_hash(code): + return hashlib.sha256(re.sub(r'[^A-Z0-9]', '', str(code or '').upper()).encode()).hexdigest() + +def recovery_generate(): + alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + codes = [] + for _ in range(RECOVERY_COUNT): + raw = ''.join(secrets.choice(alphabet) for _ in range(10)) + codes.append(raw[:5] + '-' + raw[5:]) + c = db(); recovery_table(c); c.execute('DELETE FROM recovery_codes') + c.executemany('INSERT INTO recovery_codes(code_hash) VALUES(?)', [(recovery_hash(x),) for x in codes]); c.commit(); c.close() + return codes + +def recovery_use(code): + cleaned = re.sub(r'[^A-Za-z0-9]', '', str(code or '')) + if len(cleaned) != 10: return False + c = db(); recovery_table(c) + cur = c.execute('UPDATE recovery_codes SET used=1 WHERE code_hash=? AND used=0', (recovery_hash(cleaned),)) + c.commit(); ok = cur.rowcount == 1; c.close(); return ok + +def recovery_remaining(): + c = db(); recovery_table(c); n = c.execute('SELECT COUNT(*) FROM recovery_codes WHERE used=0').fetchone()[0]; c.close(); return n + +def recovery_clear(): + c = db(); recovery_table(c); c.execute('DELETE FROM recovery_codes'); c.commit(); c.close() + +def second_factor_ok(secret, code): + return totp_ok(secret, code) or recovery_use(code) + + +def create_user(username, password, secret=None): + if not re.fullmatch(r'[A-Za-z0-9_.-]{3,32}', username or ''): raise ValueError('Nom utilisateur invalide') + if not 8 <= len(password) <= 128: raise ValueError('Le mot de passe doit contenir entre 8 et 128 caractères') + c = db(); c.execute('DELETE FROM auth_users'); c.execute('INSERT INTO auth_users VALUES(?,?,?)', (username, password_hash(password), secret)); c.commit(); c.close() + +def verify_user(username, password): + c = db(); row = c.execute('SELECT username,password_hash,totp_secret FROM auth_users WHERE username=?', (username.strip(),)).fetchone(); c.close() + return row if row and password_ok(password, row[1]) else None + +def new_session(username): + token = secrets.token_urlsafe(32); sessions[token] = {'username': username, 'expires': time.time() + SESSION_TTL}; return token + +def valid_session(h): + token = h.headers.get('X-Session-Token', ''); s = sessions.get(token) + if isinstance(s, dict) and s['expires'] > time.time(): return True + sessions.pop(token, None); return False + +def username(h): + s = sessions.get(h.headers.get('X-Session-Token', ''), {}); return s.get('username', '') if isinstance(s, dict) else '' + +def command(*args): + try: return subprocess.check_output(args, text=True, stderr=subprocess.DEVNULL).strip() + except (subprocess.CalledProcessError, FileNotFoundError): return '' + +def read_json(h): + return json.loads(h.rfile.read(int(h.headers.get('Content-Length', '0'))) or b'{}') + +def request_ip(h): return h.headers.get('X-Real-IP', '').strip() or h.client_address[0] + +def log(event, name='Nom non communiqué', ip='Non attribuée', mac='—'): + try: + c = db(); c.execute('INSERT INTO connection_logs(event_time,event,name,ip,mac) VALUES(?,?,?,?,?)', (time.strftime('%Y-%m-%d %H:%M:%S'), event, name, ip, mac)); c.commit(); c.close() + except sqlite3.Error: pass + +def logs(): + c = db(); rows = c.execute('SELECT event_time,event,name,ip,mac FROM connection_logs ORDER BY id DESC LIMIT 200').fetchall(); c.close() + return [{'time':r[0], 'event':r[1], 'name':r[2], 'ip':r[3], 'mac':r[4]} for r in rows] + +def connection_name(): + active = command('nmcli','-t','-g','GENERAL.CONNECTION','device','show',INTERFACE) + if active and active != '--': return active + try: return open(CONNECTION_FILE, encoding='utf8').read().strip() + except OSError: return os.environ.get('AP_CONNECTION', '') + +def save_connection(name): + try: open(CONNECTION_FILE, 'w', encoding='utf8').write(name) + except OSError: pass + +def security(value): + values = {'wpa1':('wpa-psk','wpa','optional'), 'wpa2':('wpa-psk','rsn','optional'), 'wpa3':('sae',None,'required')} + value = str(value or 'wpa2').lower() + if value not in values: raise ValueError('Sécurité Wi-Fi invalide') + key, proto, pmf = values[value]; return value, key, proto, pmf + +def status(): + name = connection_name(); ssid = command('nmcli','-t','-g','802-11-wireless.ssid','connection','show',name) if name else '' + password = command('nmcli','--show-secrets','-t','-g','802-11-wireless-security.psk','connection','show',name) if name else '' + key = command('nmcli','-t','-g','802-11-wireless-security.key-mgmt','connection','show',name).lower() if name else '' + proto = command('nmcli','-t','-g','802-11-wireless-security.proto','connection','show',name).lower() if name else '' + sec = 'wpa3' if key == 'sae' else 'wpa1' if proto == 'wpa' else 'wpa2' + state = command('nmcli','-t','-g','GENERAL.STATE','device','show',INTERFACE) + # CORRECTION : "30 (disconnected)" contient "connected" ; seul le code 100 signifie connecté. + active = state.startswith('100') + auto = command('nmcli','-t','-g','connection.autoconnect','connection','show',name).lower() == 'yes' if name else False + if active and uptime_start() is None: + mark_ap_started() + return {'ssid':ssid or 'SSID inconnu','password':password,'security':sec,'autoconnect':auto,'interface':INTERFACE,'connection':name,'active':active,'configured':bool(name),'uptime_seconds':ap_uptime() if active else 0} + +def blocked_set(): + c = db(); rows = c.execute('SELECT mac FROM blocked_clients').fetchall(); c.close(); return {r[0] for r in rows} + +def lease_ip(mac): + mac = (mac or '').upper() + try: + import glob + files = ['/var/lib/NetworkManager/dnsmasq.leases', '/var/lib/misc/dnsmasq.leases'] + files += glob.glob('/var/lib/NetworkManager/dnsmasq-*.leases') + for path in files: + with open(path, encoding='utf8') as f: + for line in f: + fields = line.strip().split() + if len(fields) >= 3 and fields[1].upper() == mac: + ip = fields[2].strip() + if re.fullmatch(r'\d{1,3}(?:\.\d{1,3}){3}', ip): return ip + except (OSError, UnicodeError): pass + return '' + +def clients(): + neigh = command('ip','neigh','show','dev',INTERFACE); ipmac = {} + for line in neigh.splitlines(): + m = re.search(r'^(\S+).*?lladdr ([0-9a-f:]{17})', line, re.I) + if m: ipmac[m.group(2).upper()] = m.group(1) + output = []; dump = command('iw','dev',INTERFACE,'station','dump'); blocked_macs = blocked_set() + for m in re.finditer(r'^Station ([0-9a-f:]{17})(.*?)(?=^Station |\Z)', dump, re.I|re.M|re.S): + mac = m.group(1).upper(); detail = m.group(2); sm = re.search(r'^\s*signal:\s*(-?\d+)', detail, re.I|re.M); ip = ipmac.get(mac, '') or lease_ip(mac); blocked = mac in blocked_macs; name = device_name(ip or 'Non attribuée', mac) + if not ip: + try: + c = db(); row = c.execute('SELECT last_ip FROM client_names WHERE mac=?', (mac,)).fetchone(); c.close(); ip = row[0] if row and row[0] else 'Non attribuée' + except sqlite3.Error: ip = 'Non attribuée' + if not name.startswith('Client ') and name != 'Nom non communiqué': + try: + c = db(); c.execute('INSERT INTO client_names(mac,name,last_ip,last_seen) VALUES(?,?,?,?) ON CONFLICT(mac) DO UPDATE SET name=excluded.name,last_ip=CASE WHEN excluded.last_ip != \'Non attribuée\' THEN excluded.last_ip ELSE client_names.last_ip END,last_seen=excluded.last_seen', (mac,name,ip,time.strftime('%Y-%m-%d %H:%M:%S'))); c.commit(); c.close() + except sqlite3.Error: pass + output.append({'name':name,'ip':ip,'mac':mac,'signal_dbm':int(sm.group(1)) if sm else None,'blocked':blocked,'connected':not blocked}) + return output + +def known_device(mac): + name, ip = '', 'Non attribuée' + try: + c = db(); row = c.execute('SELECT name,last_ip FROM client_names WHERE mac=?', (mac,)).fetchone(); c.close() + if row: name, ip = row[0] or '', row[1] or ip + except sqlite3.Error: pass + return name or device_name(ip, mac), ip + +def set_blocked(mac, blocked): + mac = mac.upper() + if not re.fullmatch(r'[0-9A-F]{2}(:[0-9A-F]{2}){5}', mac): raise ValueError('Adresse MAC invalide') + name, ip = known_device(mac) + c = db() + if blocked: c.execute('INSERT OR IGNORE INTO blocked_clients VALUES(?,?)',(mac,time.strftime('%Y-%m-%d %H:%M:%S'))); command('iw','dev',INTERFACE,'station','del',mac) + else: c.execute('DELETE FROM blocked_clients WHERE mac=?',(mac,)) + c.commit(); c.close(); log('blocage' if blocked else 'déblocage', name, ip, mac) + +def blocked_list(): + c = db(); rows = c.execute('SELECT b.mac,b.blocked_at,n.name FROM blocked_clients b LEFT JOIN client_names n ON n.mac=b.mac ORDER BY b.blocked_at DESC').fetchall(); c.close() + return [{'mac':r[0],'blocked_at':r[1],'name':r[2] or 'Client ' + r[0].replace(':', '')[-4:]} for r in rows] + +def update_logs(items): + global last_macs + current = {x['mac']:x for x in items} + for mac,x in current.items(): + if mac not in last_macs: log('connexion', x['name'], x['ip'], mac) + for mac in last_macs - set(current): + name, ip = 'Nom non communiqué', 'Non attribuée' + try: + c = db(); row = c.execute('SELECT name,last_ip FROM client_names WHERE mac=?', (mac,)).fetchone(); c.close() + if row: + name = row[0] or name + ip = row[1] or ip + except sqlite3.Error: pass + log('déconnexion', name, ip, mac) + last_macs = set(current) + +def configure_ap(data, initial=False, caller_ip=None): + ssid, password = str(data.get('ssid','')).strip(), str(data.get('password','')) + sec, key, proto, pmf = security(data.get('security','wpa2')) + if not ssid or len(ssid)>32 or not 8<=len(password)<=63: raise ValueError('SSID ou mot de passe Wi-Fi invalide') + name = ('AP-' + ssid) if initial else connection_name() + if not name: raise ValueError('Aucune connexion AP configurée') + if initial: + r=subprocess.run(['nmcli','connection','add','type','wifi','ifname',INTERFACE,'con-name',name,'ssid',ssid],text=True,capture_output=True) + if r.returncode and 'already exists' not in r.stderr.lower(): raise RuntimeError(r.stderr.strip()) + args=['nmcli','connection','modify',name,'802-11-wireless.mode','ap','802-11-wireless.ssid',ssid,'802-11-wireless-security.key-mgmt',key,'802-11-wireless-security.psk',password,'802-11-wireless-security.pmf',pmf,'ipv4.method','shared','ipv6.method','ignore','connection.autoconnect','yes' if data.get('autoconnect',True) else 'no'] + if proto: args += ['802-11-wireless-security.proto',proto] + r=subprocess.run(args,text=True,capture_output=True) + if r.returncode: raise RuntimeError(r.stderr.strip() or 'NetworkManager a refusé la configuration') + save_connection(name); r=subprocess.run(['nmcli','connection','up',name],text=True,capture_output=True) + if r.returncode: raise RuntimeError(r.stderr.strip() or 'Impossible de démarrer le point d’accès') + mark_ap_started(); log('création' if initial else 'enregistrement_modifie', 'Réseau Wi-Fi '+ssid, ip=caller_ip or 'Non attribuée', mac='AP'); return name + +class Handler(BaseHTTPRequestHandler): + def send_json(self, data, status=200): + body=json.dumps(data,ensure_ascii=False).encode(); self.send_response(status); self.send_header('Content-Type','application/json; charset=utf-8'); self.send_header('Access-Control-Allow-Origin','*'); self.send_header('Access-Control-Allow-Headers','Content-Type, X-Session-Token'); self.send_header('Access-Control-Allow-Methods','GET, POST, OPTIONS'); self.send_header('Content-Length',str(len(body))); self.end_headers(); self.wfile.write(body) + def do_OPTIONS(self): self.send_response(204); self.send_header('Access-Control-Allow-Origin','*'); self.send_header('Access-Control-Allow-Headers','Content-Type, X-Session-Token'); self.send_header('Access-Control-Allow-Methods','GET, POST, OPTIONS'); self.end_headers() + def do_GET(self): + if self.path in ('/','/index.html','/ap_dashboard.html'): + try: body=open(os.path.join(os.path.dirname(__file__),'index.html'),'rb').read(); self.send_response(200); self.send_header('Content-Type','text/html; charset=utf-8'); self.send_header('Content-Length',str(len(body))); self.end_headers(); self.wfile.write(body) + except OSError: self.send_error(404,'index.html introuvable') + return + if self.path == '/api/auth/status': self.send_json(auth_state()); return + if not valid_session(self): self.send_json({'error':'Authentification requise'},401); return + if self.path == '/api/status': self.send_json(status()) + elif self.path == '/api/clients': self.send_json(clients()) + elif self.path == '/api/clients/blocked': self.send_json(blocked_list()) + elif self.path == '/api/logs': self.send_json(logs()) + elif self.path == '/api/preferences': + c=db(); rows=dict(c.execute("SELECT key,value FROM preferences").fetchall()); c.close() + self.send_json({ + 'theme': rows.get('theme', 'dark'), + 'app_name': rows.get('app_name', 'Administration de l’AP Wi‑Fi'), + 'app_short_name': rows.get('app_short_name', 'AP Manageur') + }) + else: self.send_json({'error':'Route inconnue'},404) + def do_POST(self): + try: data=read_json(self) + except Exception: self.send_json({'error':'JSON invalide'},400); return + if not isinstance(data, dict): self.send_json({'error':'JSON invalide'},400); return + try: + if self.path == '/api/auth/setup': + if auth_state()['configured']: raise ValueError('Un compte existe déjà') + u=str(data.get('username','')) + create_user(u,str(data.get('password','')),None) + log('creation_compte_admin', u, request_ip(self), 'AUTH') + self.send_json({'ok':True,'message':'Compte créé','two_factor_enabled':False}); return + if self.path == '/api/login': + u=str(data.get('username','')) + row=verify_user(u,str(data.get('password',''))) + if not row: + log('echec_connexion', u or 'Inconnu', request_ip(self), 'AUTH') + raise ValueError('Utilisateur ou mot de passe incorrect') + used_recovery = False + if row[2]: + otp=data.get('otp') + if not totp_ok(row[2],otp): + if not recovery_use(otp): + log('echec_connexion', row[0], request_ip(self), 'AUTH') + raise ValueError('Code de double identification incorrect') + used_recovery = True + log('connexion_code_recuperation', row[0], request_ip(self), 'AUTH') + else: + log('connexion_admin', row[0], request_ip(self), 'AUTH') + else: + log('connexion_admin', row[0], request_ip(self), 'AUTH') + self.send_json({'ok':True,'token':new_session(row[0]),'used_recovery':used_recovery}); return + if self.path == '/api/logout': + log('deconnexion_admin', username(self) or 'Administrateur', request_ip(self), 'AUTH') + sessions.pop(self.headers.get('X-Session-Token',''),None); self.send_json({'ok':True}); return + if not valid_session(self): self.send_json({'error':'Authentification requise'},401); return + if self.path == '/api/clients/block': set_blocked(str(data.get('mac','')),bool(data.get('blocked',True))); self.send_json({'ok':True}); return + if self.path == '/api/ap/control': + action=str(data.get('action','')); name=connection_name() + if not name: raise ValueError('Aucune connexion AP configurée') + cmd=['nmcli','connection','up' if action=='start' else 'down',name] if action in ('start','stop') else ['nmcli','connection','down',name] + r=subprocess.run(cmd,capture_output=True,text=True) + if action=='restart': r=subprocess.run(['nmcli','connection','up',name],capture_output=True,text=True) + if r.returncode: raise RuntimeError(r.stderr.strip()) + if action in ('start','restart'): mark_ap_started() + elif action == 'stop': + try: os.remove(UPTIME_FILE) + except OSError: pass + log({'start':'démarrage','stop':'arrêt','restart':'redémarrage'}.get(action,'action'), 'Point d’accès Wi-Fi', ip=request_ip(self), mac='AP'); self.send_json({'ok':True}); return + if self.path == '/api/setup': self.send_json({'ok':True,'message':'Configuration enregistrée','connection':configure_ap(data,True,caller_ip=request_ip(self))}); return + if self.path == '/api/settings': self.send_json({'ok':True,'message':'Réglages enregistrés','connection':configure_ap(data,caller_ip=request_ip(self))}); return + if self.path == '/api/logs/clear': + c=db(); c.execute('DELETE FROM connection_logs'); c.commit(); c.close() + log('suppression_journaux', username(self) or 'Administrateur', request_ip(self), 'SYSTEM') + self.send_json({'ok':True,'message':'Journaux supprimés'}); return + if self.path == '/api/data/clear': + name = connection_name() + names_to_delete = set() + if name: names_to_delete.add(name) + out = subprocess.run(['nmcli', '-t', '-g', 'NAME', 'connection', 'show'], capture_output=True, text=True).stdout + for line in out.splitlines(): + line = line.strip() + if line.startswith('AP-') or line == name: + names_to_delete.add(line) + for n in names_to_delete: + if n: + subprocess.run(['nmcli', 'connection', 'down', n], capture_output=True, text=True) + subprocess.run(['nmcli', 'connection', 'delete', n], capture_output=True, text=True) + try: os.remove(CONNECTION_FILE) + except OSError: pass + try: os.remove(UPTIME_FILE) + except OSError: pass + c=db() + c.execute('DELETE FROM connection_logs') + c.execute('DELETE FROM blocked_clients') + c.execute('DELETE FROM client_names') + c.execute('DELETE FROM auth_users') + c.execute('DROP TABLE IF EXISTS recovery_codes') + c.execute('DELETE FROM preferences') + c.commit(); c.close() + sessions.clear() + log('reinitialisation_donnees', 'Système', request_ip(self), 'SYSTEM') + self.send_json({'ok':True,'message':'Toutes les données et le point d’accès ont été réinitialisés et supprimés'}); return + if self.path == '/api/auth/2fa/status': + self.send_json({'enabled':auth_state()['two_factor'],'recovery_remaining':recovery_remaining()}); return + if self.path == '/api/auth/2fa/enable': + row=verify_user(username(self),str(data.get('password',''))) + if not row: raise ValueError('Mot de passe incorrect') + secret=base64.b32encode(secrets.token_bytes(10)).decode('ascii').rstrip('=') + sessions[self.headers.get('X-Session-Token','')]['pending_2fa']=secret + log('generation_secret_2fa', username(self), request_ip(self), 'AUTH') + self.send_json({'ok':True,'message':'Secret généré. Ajoutez-le dans votre application TOTP puis confirmez avec le code affiché.','secret':secret}); return + if self.path == '/api/auth/2fa/confirm': + session=sessions.get(self.headers.get('X-Session-Token',''),{}) + secret=session.get('pending_2fa') + if not secret or not totp_ok(secret,data.get('code')): raise ValueError('Code 2FA invalide') + row=verify_user(username(self),str(data.get('password',''))) + if not row: raise ValueError('Mot de passe incorrect') + create_user(row[0],str(data.get('password','')),secret); session.pop('pending_2fa',None) + log('activation_2fa', username(self), request_ip(self), 'AUTH') + self.send_json({'ok':True,'message':'Double identification activée. Conservez vos codes de récupération.','recovery_codes':recovery_generate()}); return + if self.path == '/api/auth/2fa/disable': + row=verify_user(username(self),str(data.get('password',''))) + if not row: raise ValueError('Mot de passe incorrect') + if row[2] and not second_factor_ok(row[2],data.get('code')): raise ValueError('Code 2FA invalide') + create_user(row[0],str(data.get('password','')),None); recovery_clear() + log('desactivation_2fa', username(self), request_ip(self), 'AUTH') + self.send_json({'ok':True,'message':'Double identification désactivée'}); return + if self.path == '/api/auth/2fa/recovery': + row=verify_user(username(self),str(data.get('password',''))) + if not row: raise ValueError('Mot de passe incorrect') + if not row[2]: raise ValueError('La 2FA n’est pas activée') + if not second_factor_ok(row[2],data.get('code')): raise ValueError('Code 2FA invalide') + log('regeneration_codes_secours', username(self), request_ip(self), 'AUTH') + self.send_json({'ok':True,'message':'Nouveaux codes générés','recovery_codes':recovery_generate()}); return + if self.path == '/api/auth/password': + current=verify_user(username(self),str(data.get('current_password',''))) + if not current: raise ValueError('Mot de passe actuel incorrect') + new_u = str(data.get('username',username(self))) + create_user(new_u,str(data.get('password','')),current[2]) + log('modification_identifiants', new_u, request_ip(self), 'AUTH') + self.send_json({'ok':True,'message':'Identifiants modifiés'}); return + if self.path == '/api/preferences': + c=db() + if 'theme' in data: + theme=str(data.get('theme','')) + if theme not in ('dark','light','auto'): raise ValueError('Thème invalide') + c.execute("INSERT OR REPLACE INTO preferences(key,value) VALUES('theme',?)",(theme,)) + if 'app_name' in data: + app_name=str(data.get('app_name','')).strip() + if not app_name or len(app_name)>64: raise ValueError('Nom de l’application invalide') + c.execute("INSERT OR REPLACE INTO preferences(key,value) VALUES('app_name',?)",(app_name,)) + if 'app_short_name' in data: + app_short_name=str(data.get('app_short_name','')).strip() + if not app_short_name or len(app_short_name)>32: raise ValueError('Nom court invalide') + c.execute("INSERT OR REPLACE INTO preferences(key,value) VALUES('app_short_name',?)",(app_short_name,)) + c.commit(); c.close() + log('modification_preferences', username(self) or 'Administrateur', request_ip(self), 'CONFIG') + self.send_json({'ok':True,'message':'Préférences enregistrées'}); return + self.send_json({'error':'Route inconnue'},404) + except (ValueError, RuntimeError, sqlite3.Error) as e: self.send_json({'error':str(e)},400) + except Exception: self.send_json({'error':'Erreur interne'},500) + def log_message(self,*args): pass + +def monitor(): + while True: + try: + items = clients() + # CORRECTION : éjecter en continu les appareils bloqués qui se reconnectent. + for x in items: + if x['blocked']: command('iw','dev',INTERFACE,'station','del',x['mac']) + update_logs([x for x in items if not x['blocked']]) + except Exception: pass + time.sleep(2) + +if __name__ == '__main__': + db(); threading.Thread(target=monitor,daemon=True).start(); print(f'API AP sur http://0.0.0.0:{PORT}'); ThreadingHTTPServer((HOST,PORT),Handler).serve_forever() diff --git a/apache-ap.conf b/apache-ap.conf new file mode 100644 index 0000000..2a40f24 --- /dev/null +++ b/apache-ap.conf @@ -0,0 +1,20 @@ +ServerName localhost + + + DocumentRoot /var/www/html + DirectoryIndex index.html + + + Options -Indexes + Require all granted + + + # L'API Python lit l'IP réelle du client dans X-Real-IP + RequestHeader set X-Real-IP "expr=%{REMOTE_ADDR}" + ProxyPreserveHost On + ProxyPass /api http://127.0.0.1:8080/api + ProxyPassReverse /api http://127.0.0.1:8080/api + + ErrorLog /dev/stderr + CustomLog /dev/stdout combined + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0eb17ca --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +# 1. Trouver l'interface de la carte Netgear : iw dev +# 2. Lancer : AP_INTERFACE=wlan0 docker compose up -d --build +# 3. Ouvrir : http://IP-DE-LA-MACHINE:1001/ +# +# Si votre fichier HTML ne s'appelle pas index.html, ajoutez sous "build:" +# args: +# HTML_FILE: ap_dashboard.html + +services: + ap-wifi: + build: . + container_name: ap-wifi + restart: unless-stopped + network_mode: host # accès direct à la carte Wi-Fi de l'hôte + privileged: true # NetworkManager interne : rfkill, NAT, sysctl + environment: + AP_INTERFACE: ${AP_INTERFACE:-} + volumes: + - ap-data:/data + - nm-connections:/etc/NetworkManager/system-connections + - nm-state:/var/lib/NetworkManager + - /run/udev:/run/udev:ro # NetworkManager reconnaît ainsi la carte USB + +volumes: + ap-data: + nm-connections: + nm-state: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..b72ca4f --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { echo "[ap-wifi] $*"; } + +# 1. Interface Wi-Fi (Netgear) -------------------------------------------------- +IFACE="${AP_INTERFACE:-}" +mapfile -t FOUND < <(iw dev 2>/dev/null | awk '$1=="Interface"{print $2}') +if [[ -z "$IFACE" ]]; then + if [[ ${#FOUND[@]} -eq 1 ]]; then + IFACE="${FOUND[0]}" + else + log "ERREUR : précisez AP_INTERFACE. Interfaces Wi-Fi détectées : ${FOUND[*]:-aucune}" + exit 1 + fi +fi +iw dev "$IFACE" info >/dev/null 2>&1 || { log "ERREUR : interface '$IFACE' introuvable (détectées : ${FOUND[*]:-aucune})"; exit 1; } +export AP_INTERFACE="$IFACE" +log "Interface utilisée : $IFACE" + +# 2. NetworkManager : ne gère que l'interface choisie --------------------------- +mkdir -p /etc/NetworkManager/conf.d /run/dbus /run/NetworkManager /var/run/apache2 /var/lock/apache2 /data +cat > /etc/NetworkManager/conf.d/10-ap.conf </dev/null 2>&1 && break + sleep 1 +done +nmcli -t general status >/dev/null 2>&1 || { log "ERREUR : NetworkManager ne répond pas"; exit 1; } + +rfkill unblock wifi 2>/dev/null || true +nmcli radio wifi on 2>/dev/null || true +nmcli device set "$IFACE" managed yes 2>/dev/null || true +log "NetworkManager prêt" + +# 3. API Python (port 8080) + Apache (port 80) ------------------------------------ +python3 /app/ap_dashboard.py & +API_PID=$! +apache2ctl -D FOREGROUND & +WEB_PID=$! + +trap 'log "Arrêt"; kill "$WEB_PID" "$API_PID" "$NM_PID" 2>/dev/null || true; exit 0' TERM INT + +log "Interface web : http://:1001/ (API directe : port 8080)" +wait -n +log "Un service s'est arrêté, fermeture du conteneur" +kill "$WEB_PID" "$API_PID" "$NM_PID" 2>/dev/null || true +exit 1 diff --git a/favicon.png b/favicon.png new file mode 100644 index 0000000..a3e64c1 Binary files /dev/null and b/favicon.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..aded067 --- /dev/null +++ b/index.html @@ -0,0 +1,254 @@ + + + + + + + + +

Administration de l’AP Wi‑Fi

Chargement de l’API…

Connexion

Connectez-vous pour accéder à l’administration de l’AP Wi‑Fi.

+

Administration de l’AP Wi‑Fi

AP Manageur V1.0
+ +

Configuration initiale

Aucun réseau Wi‑Fi n’est configuré. Créez votre point d’accès avec un SSID et un mot de passe.

Caractéristiques requises
SSID : 1 à 32 caractères.
Mot de passe WPA : 8 à 63 caractères, recommandé avec des lettres, des chiffres et des symboles.
Le réseau sera démarré automatiquement au démarrage de la machine.
+
+
État de l’AP
Chargement
Clients connectés
—
SSID
—
Uptime du SSID
—
+

Clients connectés

—
APPAREILIPMACSIGNALÉTATACTION
+
+

Réglages AP

Les changements redémarrent la connexion Wi‑Fi active.

Personnalisation

Enregistré dans la base de données : le nom et le thème se retrouvent sur tous vos appareils.

Réglages

Double identification (2FA) & Codes de secours

Chargement…

Zone dangereuse

Supprime l’historique des logs, les appareils bloqués et la configuration réseau enregistrée.

+

Journaux de connexion

DATEÉVÉNEMENTAPPAREILIPMAC
+

Appareils bloqués

Ces appareils sont enregistrés dans la liste de blocage et peuvent être autorisés à nouveau.

APPAREILADRESSE MACBLOQUÉ LEACTION
+
+