493 lines
22 KiB
Python
493 lines
22 KiB
Python
"""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)
|