From dbebafd34adc003ea14a2d97420516bd4db9983f Mon Sep 17 00:00:00 2001 From: yoann Date: Sun, 4 Oct 2026 19:56:51 +0200 Subject: [PATCH] APmanageur Wi-Fi : conteneur Debian + Apache --- Dockerfile | 35 ++++ README.md | 2 + ap.py | 505 +++++++++++++++++++++++++++++++++++++++++++++ apache-ap.conf | 20 ++ docker-compose.yml | 27 +++ entrypoint.sh | 63 ++++++ favicon.png | Bin 0 -> 29054 bytes index.html | 254 +++++++++++++++++++++++ 8 files changed, 906 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 ap.py create mode 100644 apache-ap.conf create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 favicon.png create mode 100644 index.html 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 0000000000000000000000000000000000000000..a3e64c1858596568bac289694acd77ae08ce8dea GIT binary patch literal 29054 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 literal 0 HcmV?d00001 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
+
+