Files
Alfredv2/alfred/app/services/admin_console.py
T

493 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
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]
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)