"""Authenticated local configuration and health UI for Alfred. Registration is additive and doesn't replace the existing audio, camera, HA, weather or Hermes endpoints. Passwords are provisioned locally via CLI. """ from __future__ import annotations import functools import hmac import json import os import re import secrets import shutil import socket import subprocess import threading import time from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.parse import urlsplit import requests from dotenv import dotenv_values from flask import Blueprint, current_app, jsonify, render_template, request, session from werkzeug.security import check_password_hash ROOT = Path(__file__).resolve().parents[1] AUTH_FILE = ROOT / 'config' / 'admin_auth.json' GENERAL_ENV = ROOT / 'config' / 'control_center.env' WEATHER_ENV = ROOT / 'config' / 'weather_outfits.env' CONFIG_LOCK = threading.RLock() LOGIN_LOCK = threading.Lock() LOGIN_ATTEMPTS = {} # Only these keys are writable; no token, command, system file or shell input. FIELDS = { 'ALFRED_NAME': ('general', 'text'), 'WAKE_WORD': ('general', 'wake'), 'PIPER_VOICE': ('general', 'voice'), 'ALFRED_OLLAMA_URL': ('general', 'url'), 'ALFRED_COMFYUI_URL': ('weather', 'url'), 'HA_WEATHER_ENTITY': ('general', 'entity'), 'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': ('weather', 'attempts'), 'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': ('weather', 'score'), 'ALFRED_WEATHER_DISPLAY_SIZE': ('weather', 'size'), 'ALFRED_WEATHER_REVIEW': ('weather', 'boolean'), 'ALFRED_WEATHER_REFERENCE_MODE': ('weather', 'reference_mode'), 'ALFRED_WEATHER_REFERENCE_DENOISE': ('weather', 'denoise'), 'ALFRED_WEATHER_PRESERVE_FACE': ('weather', 'boolean'), 'ALFRED_WEATHER_ACCESSORY_LIMIT': ('weather', 'accessory'), } DEFAULTS = { 'ALFRED_NAME': 'Alfred', 'WAKE_WORD': 'alfred', 'PIPER_VOICE': 'fr_FR-tom-medium', 'ALFRED_OLLAMA_URL': '', 'ALFRED_COMFYUI_URL': '', 'HA_WEATHER_ENTITY': '', 'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': '2', 'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': '8.6', 'ALFRED_WEATHER_DISPLAY_SIZE': '350', 'ALFRED_WEATHER_REVIEW': 'true', 'ALFRED_WEATHER_REFERENCE_MODE': 'auto', 'ALFRED_WEATHER_REFERENCE_DENOISE': '0.56', 'ALFRED_WEATHER_PRESERVE_FACE': 'true', 'ALFRED_WEATHER_ACCESSORY_LIMIT': '1', } # ALFRED_ADMIN_SURVEILLANCE_V2 : champs du moteur STT existant. FIELDS.update({ 'STT_ENGINE': ('general', 'stt_engine'), 'WYOMING_WHISPER_HOST': ('general', 'stt_host'), 'WYOMING_WHISPER_PORT': ('general', 'stt_port'), 'WYOMING_WHISPER_LANGUAGE': ('general', 'stt_language'), 'WYOMING_WHISPER_TIMEOUT': ('general', 'stt_timeout'), 'WYOMING_WHISPER_CONNECT_TIMEOUT': ('general', 'stt_connect_timeout'), 'STT_MAX_AUDIO_SECONDS': ('general', 'stt_max_audio'), }) DEFAULTS.update({ 'STT_ENGINE': 'vosk', 'WYOMING_WHISPER_HOST': '192.168.1.13', 'WYOMING_WHISPER_PORT': '10300', 'WYOMING_WHISPER_LANGUAGE': 'fr', 'WYOMING_WHISPER_TIMEOUT': '20', 'WYOMING_WHISPER_CONNECT_TIMEOUT': '3', 'STT_MAX_AUDIO_SECONDS': '30', }) from services.admin_extensions_v2 import extend_health as _admin_v2_extend_health # ALFRED_DUPLEX_BARGE_IN_V1: values saved with existing /config API. FIELDS.update({ 'DUPLEX_MODE': ('general', 'duplex_mode'), 'DUPLEX_MIC_THRESHOLD': ('general', 'duplex_threshold'), }) DEFAULTS.update({'DUPLEX_MODE': 'wake', 'DUPLEX_MIC_THRESHOLD': '0.025'}) # ALFRED_DUPLEX_CONVERSATION_V2: configurable from the existing authenticated /config page. FIELDS.update({ 'DUPLEX_STOP_COMMANDS': ('general', 'boolean'), 'DUPLEX_HANDOFF': ('general', 'boolean'), 'DUPLEX_CONVERSATION': ('general', 'boolean'), 'DUPLEX_CONVERSATION_SECONDS': ('general', 'duplex_seconds'), }) DEFAULTS.update({ 'DUPLEX_STOP_COMMANDS': 'true', 'DUPLEX_HANDOFF': 'true', 'DUPLEX_CONVERSATION': 'true', 'DUPLEX_CONVERSATION_SECONDS': '6', }) def _auth_config(): try: return json.loads(AUTH_FILE.read_text(encoding='utf-8')) except (OSError, ValueError): return {} def _authenticated(): config = _auth_config() password_hash = config.get('password_hash', '') if not password_hash: return False # Changing the admin password instantly invalidates old signed sessions. expected_version = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24] return bool(session.get('alfred_admin') and hmac.compare_digest(str(session.get('auth_version', '')), expected_version)) def _valid_csrf(): token = request.headers.get('X-Alfred-CSRF', '') expected = session.get('alfred_csrf', '') return bool(token and expected and hmac.compare_digest(token, expected)) def _trusted_request(): origin = request.headers.get('Origin', '') if origin: parsed = urlsplit(origin) if parsed.netloc.lower() != request.host.lower() or parsed.scheme not in ('http', 'https'): return False if request.headers.get('Sec-Fetch-Site', '').lower() == 'cross-site': return False return True def _require_admin(handler): @functools.wraps(handler) def wrapped(*args, **kwargs): if not _authenticated(): return jsonify(error='Authentification administrateur nécessaire.'), 401 if request.method != 'GET' and (not _valid_csrf() or not _trusted_request()): return jsonify(error='Protection anti-CSRF : requête refusée.'), 403 return handler(*args, **kwargs) return wrapped def _validate(key, value): if key not in FIELDS: raise ValueError(f'Paramètre non autorisé : {key}.') kind = FIELDS[key][1] # ALFRED_DUPLEX_BARGE_IN_V1 # ALFRED_DUPLEX_CONVERSATION_V2 if kind == 'duplex_seconds': try: seconds = int(value) except (ValueError, TypeError, OverflowError): raise ValueError('Duree de conversation invalide.') if isinstance(value, bool) or not 2 <= seconds <= 15: raise ValueError('Duree de conversation : de 2 a 15 secondes.') return str(seconds) if kind == 'duplex_mode': if value not in ('off', 'wake', 'natural'): raise ValueError('Mode duplex invalide.') return value if kind == 'duplex_threshold': if isinstance(value, bool): raise ValueError('Seuil microphone invalide.') try: threshold = float(value) except (ValueError, TypeError, OverflowError): raise ValueError('Seuil microphone invalide.') if not 0.008 <= threshold <= 0.30 or not __import__('math').isfinite(threshold): raise ValueError('Seuil microphone compris entre 0.008 et 0.30.') return f'{threshold:.3f}' if kind == 'boolean': if value not in (True, False, 'true', 'false'): raise ValueError(f'{key} doit être vrai ou faux.') return 'true' if value in (True, 'true') else 'false' if isinstance(value, bool): raise ValueError(f'Valeur invalide pour {key}.') value = str(value).strip() if any(c in value for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')): raise ValueError(f'Caractères interdits pour {key}.') # ALFRED_ADMIN_SURVEILLANCE_V2: stricte validation des paramètres Whisper. if kind == 'stt_engine': if value not in ('vosk', 'wyoming'): raise ValueError('Moteur STT attendu : vosk ou wyoming.') elif kind == 'stt_host': if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.\-]{0,252}', value): raise ValueError('Hôte Whisper invalide : indiquer une IP ou un nom DNS, sans protocole.') elif kind == 'stt_port': if not value.isdecimal() or not 1 <= int(value) <= 65535: raise ValueError('Port Whisper invalide (1 à 65535).') value = str(int(value)) elif kind == 'stt_language': if not re.fullmatch(r'[a-z]{2}', value.lower()): raise ValueError('Langue Whisper invalide (exemple : fr).') value = value.lower() elif kind in ('stt_timeout', 'stt_connect_timeout'): try: number = float(value) except ValueError: raise ValueError('Délai Whisper non numérique.') bounds = (5, 90) if kind == 'stt_timeout' else (1, 15) if not bounds[0] <= number <= bounds[1]: raise ValueError(f'Délai Whisper entre {bounds[0]} et {bounds[1]} secondes.') value = str(number).rstrip('0').rstrip('.') if '.' in str(number) else str(number) elif kind == 'stt_max_audio': if not value.isdecimal() or not 5 <= int(value) <= 60: raise ValueError('Enregistrement entre 5 et 60 secondes.') value = str(int(value)) elif kind == 'text': if not 2 <= len(value) <= 30: raise ValueError('Le nom doit contenir entre 2 et 30 caractères.') elif kind == 'wake': if not re.fullmatch(r'[A-Za-zÀ-ÖØ-öø-ÿ]{2,22}', value): raise ValueError('Le mot-clé Vosk doit être un mot simple de 2 à 22 lettres.') value = value.lower() elif kind == 'voice': if not re.fullmatch(r'[A-Za-z0-9_.-]{2,96}', value): raise ValueError('Identifiant de voix Piper invalide.') elif kind == 'url': if not value: return '' parsed = urlsplit(value) if (parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password or parsed.path not in ('', '/') or parsed.query or parsed.fragment): raise ValueError(f'{key} attend une URL http(s) de serveur sans chemin ni identifiants.') if parsed.port and not 1 <= parsed.port <= 65535: raise ValueError('Port réseau invalide.') value = value.rstrip('/') elif kind == 'entity': if value and not re.fullmatch(r'weather\.[a-z0-9_]{2,80}', value): raise ValueError('Attendu : weather.identifiant ou une valeur vide.') elif kind == 'attempts': if value not in ('1', '2', '3'): raise ValueError('Nombre d’essais autorisé : 1, 2 ou 3 par personnage.') elif kind == 'score': value = str(float(value)) if not 1 <= float(value) <= 10: raise ValueError('Score entre 1 et 10.') elif kind == 'size': if value not in ('250', '300', '350', '400', '450', '500'): raise ValueError('Taille autorisée : 250, 300, 350, 400, 450 ou 500 px.') elif kind == 'denoise': value = str(float(value)) if not 0.25 <= float(value) <= 0.85: raise ValueError('Denoise entre 0.25 et 0.85.') elif kind == 'reference_mode': if value not in ('auto', 'required', 'off'): raise ValueError('Mode référence : auto, required ou off.') elif kind == 'accessory': if value not in ('0', '1', '2'): raise ValueError('Accessoires visibles autorisés : 0, 1 ou 2.') return value def _read_config(): local = dict(dotenv_values(GENERAL_ENV)) if GENERAL_ENV.exists() else {} weather = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {} effective = {} for key, (group, _) in FIELDS.items(): env_file = local if group == 'general' else weather # File values take precedence on the next restart. Show saved values. effective[key] = str(env_file.get(key) if env_file.get(key) is not None else os.getenv(key, DEFAULTS[key])) return effective def _save_env(path: Path, changes: dict): path.parent.mkdir(parents=True, exist_ok=True) existing = path.read_text(encoding='utf-8') if path.exists() else '' lines = existing.splitlines() updated = set() result = [] for line in lines: match = re.match(r'^\s*([A-Z][A-Z0-9_]*)\s*=', line) if match and match.group(1) in changes: name = match.group(1) if name not in updated: result.append(f'{name}={changes[name]}') updated.add(name) else: result.append(line) for key, value in changes.items(): if key not in updated: result.append(f'{key}={value}') tmp = path.with_name(path.name + f'.{secrets.token_hex(4)}.tmp') try: with tmp.open('w', encoding='utf-8') as fp: fp.write('\n'.join(result).rstrip() + '\n') fp.flush() os.fsync(fp.fileno()) os.chmod(tmp, 0o600) tmp.replace(path) finally: tmp.unlink(missing_ok=True) def _probe_http(url, *, token=None, timeout=2): start = time.monotonic() try: response = requests.get(url, headers={'Authorization': f'Bearer {token}'} if token else {}, timeout=timeout, allow_redirects=False) if 200 <= response.status_code < 300: state = 'ok' detail = f'HTTP {response.status_code}' elif response.status_code in (401, 403): state, detail = 'warning', f'Connexion établie, accès refusé ({response.status_code})' else: state, detail = 'warning', f'HTTP {response.status_code}' except requests.RequestException: state, detail = 'down', 'Aucune réponse HTTP' return {'state': state, 'detail': detail, 'latency_ms': round((time.monotonic()-start)*1000)} def _probe_tcp(host, port, timeout=1.5): try: with socket.create_connection((host, int(port)), timeout=timeout): return {'state': 'ok', 'detail': f'Port {port} accessible'} except (OSError, ValueError): return {'state': 'down', 'detail': f'Port {port} inaccessible'} def _weather_comfy_url(): # Respect the V5 precedence if possible, without reaching into credentials. settings = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {} return (_read_config()['ALFRED_COMFYUI_URL'] or settings.get('ALFRED_COMFYUI_URL') or os.getenv('ALFRED_COMFYUI_URL') or os.getenv('COMFYUI_URL') or dict(dotenv_values('/opt/hermes/MCP_comfyui/.env')).get('COMFYUI_URL') or '') def _health(wake, audio_settings): now = datetime.now(timezone.utc).isoformat() audio = audio_settings.get_settings() try: wake_state = wake.status() except Exception: wake_state = {'running': False, 'paused': True, 'error': 'Impossible de lire Vosk'} cam_index = int(os.getenv('CAMERA_INDEX', '0')) camera_path = Path(f'/dev/video{cam_index}') camera_ok = camera_path.exists() and os.access(camera_path, os.R_OK) # Checking /dev/videoN doesn't open the device: no conflict with facial recognition. camera = {'state':'ok' if camera_ok else 'warning', 'detail':f'{camera_path} détectée' if camera_ok else f'{camera_path} indisponible ou permissions insuffisantes'} mute_micro = audio.get('mic_muted', False) microphone = {'state':'muted' if mute_micro else ('ok' if wake_state.get('running') else 'warning'), 'detail':'Micro coupé volontairement' if mute_micro else ('Détection Vosk active' if wake_state.get('running') and not wake_state.get('paused') else 'Vosk non disponible ou en pause')} loudspeaker = {'state':'muted' if audio.get('speaker_muted') else 'ok', 'detail':'Son coupé' if audio.get('speaker_muted') else f"Volume {audio.get('volume', '?')} %"} config = _read_config() comfy = _weather_comfy_url() ollama = config.get('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or os.getenv('OLLAMA_URL') ha_base = os.getenv('HA_URL', '').rstrip('/') tts_host = os.getenv('PIPER_HOST', '').strip() tts_port = os.getenv('PIPER_PORT', '10200') services = {'alfred': {'state':'ok', 'detail':'Serveur Flask actif'}, 'hermes_cli': {'state':'ok' if shutil.which('hermes') else 'warning', 'detail':'CLI détectée' if shutil.which('hermes') else 'CLI non détectée dans le PATH du service'}} services['hermes_dashboard'] = _probe_tcp('127.0.0.1', 9119) services['hermes_dashboard']['label'] = 'Tableau de bord (optionnel)' services['comfyui'] = _probe_http(comfy.rstrip('/') + '/system_stats') if comfy else {'state':'unknown','detail':'URL non configurée'} services['ollama'] = _probe_http(ollama.rstrip('/') + '/api/tags') if ollama else {'state':'unknown','detail':'URL non configurée'} services['home_assistant'] = _probe_http(ha_base + '/api/', token=os.getenv('HA_TOKEN')) if ha_base else {'state':'unknown','detail':'URL non configurée'} services['wyoming_piper'] = _probe_tcp(tts_host, tts_port) if tts_host else {'state':'unknown','detail':'Wyoming Piper non configuré (Piper local possible)'} mcp_path = Path('/opt/hermes') mcps = {} for slug, label in [('MCP_home_assistant','Home Assistant'), ('MCP_comfyui','ComfyUI'), ('MCP_face_recognition','Reconnaissance faciale'),('MCP_wazuh','Wazuh')]: found = (mcp_path / slug).exists() mcps[slug] = {'state':'ok' if found else 'unknown', 'detail':'Dossier présent (connexion non vérifiée)' if found else 'Dossier absent', 'label':label} disk = shutil.disk_usage(ROOT) mem = {} try: info = {} for line in Path('/proc/meminfo').read_text().splitlines(): k, _, v = line.partition(':') if k in ('MemTotal','MemAvailable'): info[k] = int(v.strip().split()[0]) * 1024 if info.get('MemTotal'): mem = {'total': info['MemTotal'], 'used': info['MemTotal'] - info.get('MemAvailable', 0)} except (OSError, ValueError): pass result = {'time': now, 'audio':audio, 'wake':wake_state, 'camera':camera, 'microphone':microphone, 'speaker':loudspeaker, 'services':services, 'mcps':mcps, 'system':{'disk_total':disk.total,'disk_used':disk.used, 'memory':mem, 'load_1m':round(os.getloadavg()[0], 2) if hasattr(os,'getloadavg') else None}} return _admin_v2_extend_health(result, _read_config) def register_admin_console(app, wake, audio_settings): """Register pages/API on the existing Flask app. Call exactly once.""" secret_file = ROOT / 'config' / 'admin_secret.key' if not secret_file.exists(): raise RuntimeError('Secret administrateur absent ; exécuter install_patch.py.') app.secret_key = secret_file.read_bytes() app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Strict', SESSION_COOKIE_SECURE=os.getenv('ALFRED_ADMIN_HTTPS', 'false').lower() == 'true', PERMANENT_SESSION_LIFETIME=timedelta(hours=8)) bp = Blueprint('alfred_admin', __name__) @bp.get('/config') @bp.get('/config.html') def config_page(): return render_template('config.html') @bp.get('/surveillance') @bp.get('/surveillance.html') def surveillance_page(): return render_template('surveillance.html') @bp.get('/api/control/auth') def auth_status(): authed = _authenticated() return jsonify(authenticated=authed, configured=bool(_auth_config().get('password_hash')), csrf=session.get('alfred_csrf') if authed else None) @bp.post('/api/control/login') def login(): if not _trusted_request() or not request.is_json: return jsonify(error='Requête refusée.'), 403 data = request.get_json(silent=True) or {} password = data.get('password', '') if not isinstance(password, str) or len(password) > 500: return jsonify(error='Mot de passe incorrect.'), 400 now = time.monotonic() client = request.remote_addr or 'unknown' with LOGIN_LOCK: events = [x for x in LOGIN_ATTEMPTS.get(client, []) if x > now - 900] LOGIN_ATTEMPTS[client] = events if len(events) >= 5: return jsonify(error='Trop de tentatives : réessaie plus tard.'), 429 config = _auth_config() password_hash = config.get('password_hash') if not password_hash: return jsonify(error='Définis d’abord le mot de passe avec set_admin_password.py.'), 503 if not check_password_hash(password_hash, password): with LOGIN_LOCK: LOGIN_ATTEMPTS[client].append(now) return jsonify(error='Mot de passe incorrect.'), 401 with LOGIN_LOCK: LOGIN_ATTEMPTS.pop(client, None) session.clear() session.permanent = True session['alfred_admin'] = True session['auth_version'] = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24] session['alfred_csrf'] = secrets.token_urlsafe(32) return jsonify(authenticated=True, csrf=session['alfred_csrf']) @bp.post('/api/control/logout') @_require_admin def logout(): session.clear() return jsonify(ok=True) @bp.get('/api/control/config') @_require_admin def config_get(): return jsonify(values=_read_config(), note='La plupart des réglages nécessitent un redémarrage.') @bp.post('/api/control/config') @_require_admin def config_update(): data = request.get_json(silent=True) if not isinstance(data, dict) or len(data) > len(FIELDS): return jsonify(error='JSON invalide.'), 400 try: normalized = {key: _validate(key, value) for key, value in data.items()} except (ValueError, TypeError, OverflowError) as exc: return jsonify(error=str(exc)), 400 general = {k:v for k,v in normalized.items() if FIELDS[k][0]=='general'} weather = {k:v for k,v in normalized.items() if FIELDS[k][0]=='weather'} try: with CONFIG_LOCK: if general: _save_env(GENERAL_ENV, general) if weather: _save_env(WEATHER_ENV, weather) except OSError as exc: return jsonify(error=f'Impossible d’enregistrer : {exc}'), 500 # ALFRED_IDENTITY_GLASS_V1 : synchroniser le nom après sauvegarde. _identity_sync_warning = None if 'ALFRED_NAME' in normalized: from services.alfred_identity_page import sync_identity_name as _sync_identity_name os.environ['ALFRED_NAME'] = normalized['ALFRED_NAME'] try: _sync_identity_name(normalized['ALFRED_NAME']) except (OSError, ValueError) as _identity_error: _identity_sync_warning = str(_identity_error) current_app.logger.warning('Identité non synchronisée: %s', _identity_error) return jsonify(ok=True, restart_required=True, identity_sync_warning=_identity_sync_warning, values=_read_config()) @bp.get('/api/control/health') @_require_admin def health(): return jsonify(_health(wake, audio_settings)) @bp.post('/api/control/restart') @_require_admin def restart(): if os.getenv('ALFRED_ALLOW_WEB_RESTART', 'false').lower() not in ('true', '1', 'yes'): return jsonify(error='Redémarrage Web désactivé. Activer ALFRED_ALLOW_WEB_RESTART=true (voir README).'), 403 systemctl = shutil.which('systemctl') runner = shutil.which('systemd-run') if not systemctl or not runner: return jsonify(error='systemd-run ou systemctl absent. Redémarrer depuis le terminal.'), 503 # Schedule restart in a DIFFERENT systemd unit/cgroup so stopping Alfred # cannot kill the restart command mid-flight. command = [runner, '--unit=alfred-web-restart', '--on-active=2s', '--collect', systemctl, 'restart', 'alfred.service'] if os.geteuid() != 0: command = ['sudo', '-n', *command] def do_restart(): time.sleep(0.5) try: result = subprocess.run(command, capture_output=True, text=True, timeout=12, check=False) if result.returncode: app.logger.error('Planification du redémarrage refusée (%s): %s', result.returncode, result.stderr[:350]) except (OSError, subprocess.TimeoutExpired) as exc: app.logger.error('Planification du redémarrage échouée: %s', exc) threading.Thread(target=do_restart, daemon=True).start() return jsonify(accepted=True, message='Demande de redémarrage transmise ; contrôler le retour du service.') , 202 app.register_blueprint(bp)