mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(errors): serve /api/v3/errors/* from the display service's aggregator The error aggregator is a per-process singleton and only the display service runs plugins, so only its aggregator records anything. The routes read the web process's own, empty one and always reported no errors. The display service now publishes a bounded snapshot of its aggregator to the shared cache (plugin_error_snapshot) from a daemon thread: at most once every 10 s and only when something changed, never raising into the caller. The routes read it and keep their response shapes, adding snapshot_available, generated_at and clear_pending; exception text has credentials redacted. POST /errors/clear writes a clear request (plugin_error_clear_request) that the display applies on its next 5 s tick via the new clear_before(), which keeps errors recorded after the cutoff and rebuilds the counts. Until the snapshot acknowledges the request, reads hide everything before the cutoff, so a snapshot written just before the click cannot bring errors back. Adds "all": true; cleared_count is null when only the display can know it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): show plugin errors in the Logs tab A compact panel under the log viewer: per-plugin error counts, repeating errors (type, count, affected plugins, a sample message, last seen) and a Clear button, with empty states for "no errors" and "display service hasn't reported yet". Polls every 15 s while the tab is active; all text goes through escapeHtml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: describe where plugin error reports come from and how clear works Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(errors): redact the published snapshot before clipping it Keeping only a traceback's tail (or clipping a message) could cut an `api_key=` marker off while keeping the secret after it, and the web side's redaction would then have nothing to match. The display now redacts every free-text field of the snapshot first. The patterns move to a Flask-free src/redaction.py so the display service can use them; redact_text in the web error handler uses the same function, unchanged in behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
600 lines
25 KiB
Python
600 lines
25 KiB
Python
"""Routes with no larger group of their own: errors, integrations,
|
|
cache, sync, skins, logs, health and hardware.
|
|
|
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
|
endpoint names are unchanged by living here.
|
|
"""
|
|
from web_interface.blueprints.api_v3 import (
|
|
_coerce_to_bool,
|
|
ErrorCode, Path, _JOURNALCTL, _MQTT_BRIDGE_CONFIG, _MQTT_BRIDGE_DEFAULTS,
|
|
_MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value,
|
|
_get_display_service_status, _mqtt_bridge_service_state,
|
|
_read_mqtt_bridge_config, api_v3, contextlib, describe_exception,
|
|
error_response, json, jsonify, logger, os, redact_text, request,
|
|
subprocess, success_response, tempfile,
|
|
)
|
|
from src.common.path_safety import safe_path_component
|
|
from src import error_aggregator as _errors
|
|
import web_interface.blueprints.api_v3 as _pkg
|
|
# Read through the module rather than bound by value: tests patch these
|
|
# as module attributes, and a value binding would not see the patch.
|
|
# Several are also called from helpers that live in __init__, so the
|
|
# package is the only patch point that covers every caller.
|
|
|
|
|
|
@api_v3.route('/health', methods=['GET'])
|
|
def get_health():
|
|
"""Get system health status"""
|
|
try:
|
|
health_status = {
|
|
'status': 'healthy',
|
|
'timestamp': _pkg.time.time(),
|
|
'services': {},
|
|
'checks': {}
|
|
}
|
|
|
|
# Check web interface service
|
|
# Stamp the start _pkg.time before measuring against it -- reading it with a
|
|
# fallback of _pkg.time.time() and only assigning afterwards made the very
|
|
# first call subtract two separate clock reads, reporting a small
|
|
# negative uptime.
|
|
if not hasattr(get_health, '_start_time'):
|
|
get_health._start_time = _pkg.time.time()
|
|
health_status['services']['web_interface'] = {
|
|
'status': 'running',
|
|
'uptime_seconds': _pkg.time.time() - get_health._start_time
|
|
}
|
|
|
|
# Check display service
|
|
display_service_status = _get_display_service_status()
|
|
health_status['services']['display_service'] = {
|
|
'status': 'active' if display_service_status.get('active') else 'inactive',
|
|
'details': display_service_status
|
|
}
|
|
|
|
# Check config file accessibility
|
|
try:
|
|
if api_v3.config_manager:
|
|
test_config = api_v3.config_manager.load_config()
|
|
health_status['checks']['config_file'] = {
|
|
'status': 'accessible',
|
|
'readable': True
|
|
}
|
|
else:
|
|
health_status['checks']['config_file'] = {
|
|
'status': 'unknown',
|
|
'readable': False
|
|
}
|
|
except Exception as e:
|
|
health_status['checks']['config_file'] = {
|
|
'status': 'error',
|
|
'readable': False,
|
|
'error': 'see logs for details'
|
|
}
|
|
|
|
# Check plugin system
|
|
try:
|
|
if api_v3.plugin_manager:
|
|
# Try to discover plugins (lightweight check)
|
|
plugin_count = len(api_v3.plugin_manager.get_available_plugins()) if hasattr(api_v3.plugin_manager, 'get_available_plugins') else 0
|
|
health_status['checks']['plugin_system'] = {
|
|
'status': 'operational',
|
|
'plugin_count': plugin_count
|
|
}
|
|
else:
|
|
health_status['checks']['plugin_system'] = {
|
|
'status': 'not_initialized'
|
|
}
|
|
except Exception as e:
|
|
health_status['checks']['plugin_system'] = {
|
|
'status': 'error',
|
|
'error': 'see logs for details'
|
|
}
|
|
|
|
# Check hardware connectivity (if display manager available)
|
|
try:
|
|
snapshot_path = "/tmp/led_matrix_preview.png"
|
|
if os.path.exists(snapshot_path):
|
|
# Check if snapshot is recent (updated in last 60 seconds)
|
|
mtime = os.path.getmtime(snapshot_path)
|
|
age_seconds = _pkg.time.time() - mtime
|
|
health_status['checks']['hardware'] = {
|
|
'status': 'connected' if age_seconds < 60 else 'stale',
|
|
'snapshot_age_seconds': round(age_seconds, 1)
|
|
}
|
|
else:
|
|
health_status['checks']['hardware'] = {
|
|
'status': 'no_snapshot',
|
|
'note': 'Display service may not be running'
|
|
}
|
|
except Exception as e:
|
|
health_status['checks']['hardware'] = {
|
|
'status': 'unknown',
|
|
'error': 'see logs for details'
|
|
}
|
|
|
|
# Determine overall health
|
|
all_healthy = all(
|
|
check.get('status') in ['accessible', 'operational', 'connected', 'running', 'active']
|
|
for check in health_status['checks'].values()
|
|
)
|
|
|
|
if not all_healthy:
|
|
health_status['status'] = 'degraded'
|
|
|
|
return jsonify({'status': 'success', 'data': health_status})
|
|
except Exception as e:
|
|
logger.error("%s failed", request.path, exc_info=True)
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'An error occurred; see logs for details',
|
|
'details': describe_exception(e),
|
|
'data': {'status': 'unhealthy'}
|
|
}), 500
|
|
@api_v3.route('/hardware/status', methods=['GET'])
|
|
def get_hardware_status():
|
|
"""Return LED matrix hardware initialization status written by display_manager at startup."""
|
|
status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
|
|
try:
|
|
with open(status_path) as f:
|
|
hw_data = json.load(f)
|
|
return jsonify({"status": "success", "data": hw_data})
|
|
except FileNotFoundError:
|
|
return jsonify({"status": "success", "data": {"ok": None, "error": "Display service not yet started"}})
|
|
except PermissionError:
|
|
logger.warning("Permission denied reading hardware status file; display service may be running as a different user")
|
|
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status temporarily unavailable"}})
|
|
except json.JSONDecodeError:
|
|
logger.error("Failed to parse hardware status file", exc_info=True)
|
|
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status file corrupted"}})
|
|
except Exception:
|
|
logger.error("Unexpected error reading hardware status", exc_info=True)
|
|
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
|
|
@api_v3.route('/skins', methods=['GET'])
|
|
def list_skins():
|
|
"""List installed visual skins (docs/SKIN_SYSTEM.md).
|
|
|
|
Optional ?plugin_id=... filters to skins matching that plugin.
|
|
|
|
The response carries ``supported: false`` and a ``message``: the current
|
|
scoreboard plugins don't render skins, so a client must not present
|
|
these as selectable.
|
|
"""
|
|
try:
|
|
from src.skin_system import (
|
|
SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE, skin_runtime,
|
|
)
|
|
|
|
plugin_id = request.args.get('plugin_id')
|
|
if plugin_id:
|
|
skins = skin_runtime.skins_for_plugin(plugin_id)
|
|
else:
|
|
# The discovery cache self-invalidates on directory/manifest
|
|
# mtime changes, so no force_refresh — keeps Pi disk I/O down.
|
|
skins = skin_runtime.discover_skins()
|
|
|
|
payload = []
|
|
for skin_id, manifest in sorted(skins.items()):
|
|
skin_dir = Path(manifest['_skin_dir'])
|
|
preview = manifest.get('preview')
|
|
payload.append({
|
|
'id': skin_id,
|
|
'name': manifest.get('name', skin_id),
|
|
'version': manifest.get('version'),
|
|
'author': manifest.get('author'),
|
|
'description': manifest.get('description', ''),
|
|
'skin_api_version': manifest.get('skin_api_version'),
|
|
'targets': manifest.get('targets', {}),
|
|
'modes': manifest.get('modes', []),
|
|
'has_preview': bool(preview and (skin_dir / preview).is_file()),
|
|
})
|
|
data = {'skins': payload, 'supported': SKINS_RENDER_SUPPORTED}
|
|
if not SKINS_RENDER_SUPPORTED:
|
|
data['message'] = SKINS_UNSUPPORTED_MESSAGE
|
|
return jsonify({'status': 'success', 'data': data})
|
|
except Exception as e:
|
|
logger.error('Error in list_skins', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/logs', methods=['GET'])
|
|
def get_logs():
|
|
"""Get system logs from journalctl"""
|
|
try:
|
|
if not _JOURNALCTL:
|
|
return jsonify({'status': 'error', 'message': 'journalctl not found on this system'}), 503
|
|
# Get recent logs from journalctl
|
|
_cmd = ([_SUDO, _JOURNALCTL] if _SUDO else [_JOURNALCTL]) + [
|
|
'-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
|
|
'-n', '100', '--no-pager', '--output=short-iso']
|
|
result = subprocess.run(
|
|
_cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
logs_text = result.stdout.strip()
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'logs': logs_text if logs_text else 'No logs available from ledmatrix or ledmatrix-web service'
|
|
}
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'Failed to get logs: {result.stderr}'
|
|
}), 500
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Timeout while fetching logs'
|
|
}), 500
|
|
except Exception as e:
|
|
logger.error("%s failed", request.path, exc_info=True)
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'An error occurred; see logs for details',
|
|
'details': describe_exception(e)
|
|
}), 500
|
|
# Multi-Display Sync Endpoints
|
|
@api_v3.route('/sync/status', methods=['GET'])
|
|
def get_sync_status():
|
|
"""Return live multi-display sync status written by the display process."""
|
|
import os as _os
|
|
status_file = "/tmp/led_matrix_sync_status.json"
|
|
# Also surface config so the UI can show the configured role even before
|
|
# the display process has written a status file.
|
|
cfg_role = "standalone"
|
|
cfg_port = 5765
|
|
if api_v3.config_manager:
|
|
try:
|
|
cfg = api_v3.config_manager.load_config().get("sync", {})
|
|
cfg_role = cfg.get("role", "standalone")
|
|
cfg_port = int(cfg.get("port", 5765))
|
|
except Exception:
|
|
pass
|
|
|
|
if _os.path.exists(status_file):
|
|
try:
|
|
with open(status_file) as f:
|
|
live = json.load(f)
|
|
return jsonify({"status": "success", "data": live})
|
|
except Exception:
|
|
pass
|
|
|
|
# Status file not yet written — return config-only placeholder
|
|
return jsonify({
|
|
"status": "success",
|
|
"data": {
|
|
"role": cfg_role,
|
|
"port": cfg_port,
|
|
"state": "starting",
|
|
}
|
|
})
|
|
@api_v3.route('/cache/list', methods=['GET'])
|
|
def list_cache_files():
|
|
"""List all cache files with metadata"""
|
|
try:
|
|
if not api_v3.cache_manager:
|
|
# Initialize cache manager if not already initialized
|
|
from src.cache_manager import CacheManager
|
|
api_v3.cache_manager = CacheManager()
|
|
|
|
cache_files = api_v3.cache_manager.list_cache_files()
|
|
cache_dir = api_v3.cache_manager.get_cache_dir()
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'cache_files': cache_files,
|
|
'cache_dir': cache_dir,
|
|
'total_files': len(cache_files)
|
|
}
|
|
})
|
|
except Exception as e:
|
|
logger.error('Error in list_cache_files', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
@api_v3.route('/cache/delete', methods=['POST'])
|
|
def delete_cache_file():
|
|
"""Delete a specific cache file by key"""
|
|
try:
|
|
if not api_v3.cache_manager:
|
|
# Initialize cache manager if not already initialized
|
|
from src.cache_manager import CacheManager
|
|
api_v3.cache_manager = CacheManager()
|
|
|
|
data = request.get_json(silent=True)
|
|
if not data or 'key' not in data:
|
|
return jsonify({'status': 'error', 'message': 'cache key is required'}), 400
|
|
|
|
cache_key = data['key']
|
|
|
|
# The key names the file about to be removed. DiskCache refuses an
|
|
# unusable key on its own, but silently: say so here instead of
|
|
# reporting a deletion that never happened.
|
|
if safe_path_component(cache_key) is None:
|
|
return jsonify({'status': 'error', 'message': 'Invalid cache key'}), 400
|
|
|
|
# Delete the cache file
|
|
api_v3.cache_manager.clear_cache(cache_key)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': f'Cache file for key "{cache_key}" deleted successfully'
|
|
})
|
|
except Exception as e:
|
|
logger.error('Error in delete_cache_file', exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
def _errors_cache():
|
|
"""The shared cache the display service publishes its errors to."""
|
|
if not api_v3.cache_manager:
|
|
from src.cache_manager import CacheManager
|
|
api_v3.cache_manager = CacheManager()
|
|
return api_v3.cache_manager
|
|
|
|
|
|
def _redact_error_text(text, keep_lines=False):
|
|
"""Credentials out of plugin exception text, which can quote a URL with
|
|
an API key in it. Stack traces keep their line breaks and indentation."""
|
|
if not isinstance(text, str):
|
|
return text
|
|
if not keep_lines:
|
|
return redact_text(text, max_length=len(text) + 1)
|
|
return '\n'.join(
|
|
line[:len(line) - len(line.lstrip())] + redact_text(line, max_length=len(line) + 1)
|
|
for line in text.splitlines()
|
|
)
|
|
|
|
|
|
def _redact_error_record(record):
|
|
if not isinstance(record, dict):
|
|
return record
|
|
record = dict(record)
|
|
record['message'] = _redact_error_text(record.get('message'))
|
|
record['stack_trace'] = _redact_error_text(record.get('stack_trace'), keep_lines=True)
|
|
if isinstance(record.get('context'), dict):
|
|
record['context'] = {k: _redact_error_text(v) for k, v in record['context'].items()}
|
|
return record
|
|
|
|
|
|
def _read_errors():
|
|
snapshot, clear_request = _errors.read_error_report(_errors_cache())
|
|
return snapshot, clear_request
|
|
|
|
|
|
@api_v3.route('/errors/summary', methods=['GET'])
|
|
def get_error_summary():
|
|
"""
|
|
Get summary of all errors for monitoring and debugging.
|
|
|
|
Returns error counts, detected patterns, and recent errors, as last
|
|
reported by the display service (which runs the plugins, so it is the
|
|
only process that records their errors). ``snapshot_available`` is false
|
|
until it has reported; ``generated_at`` says when it did.
|
|
"""
|
|
try:
|
|
summary = _errors.error_summary_from_report(*_read_errors())
|
|
summary['recent_errors'] = [_redact_error_record(r) for r in summary['recent_errors']]
|
|
for pattern in summary['active_patterns'].values():
|
|
if isinstance(pattern, dict) and isinstance(pattern.get('sample_messages'), list):
|
|
pattern['sample_messages'] = [_redact_error_text(m) for m in pattern['sample_messages']]
|
|
message = ("Error summary retrieved" if summary['snapshot_available']
|
|
else "The display service has not reported any errors yet")
|
|
return success_response(data=summary, message=message)
|
|
except Exception as e:
|
|
logger.error(f"Error getting error summary: {e}", exc_info=True)
|
|
return error_response(
|
|
error_code=ErrorCode.SYSTEM_ERROR,
|
|
message="Failed to retrieve error summary",
|
|
status_code=500
|
|
)
|
|
@api_v3.route('/errors/plugin/<plugin_id>', methods=['GET'])
|
|
def get_plugin_errors(plugin_id):
|
|
"""
|
|
Get error health status for a specific plugin.
|
|
|
|
Args:
|
|
plugin_id: Plugin identifier
|
|
|
|
Returns health status and error statistics for the plugin, from the
|
|
display service's last report (see get_error_summary). A plugin with no
|
|
recorded errors is "healthy".
|
|
"""
|
|
try:
|
|
health = _errors.plugin_health_from_report(*_read_errors(), plugin_id)
|
|
health['last_error'] = _redact_error_record(health['last_error'])
|
|
return success_response(data=health, message="Plugin health retrieved")
|
|
except Exception as e:
|
|
logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True)
|
|
return error_response(
|
|
error_code=ErrorCode.SYSTEM_ERROR,
|
|
message=f"Failed to retrieve health for plugin {plugin_id}",
|
|
status_code=500
|
|
)
|
|
@api_v3.route('/errors/clear', methods=['POST'])
|
|
def clear_old_errors():
|
|
"""
|
|
Clear error records older than specified age.
|
|
|
|
Request body (optional):
|
|
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
|
|
all: true clears every error recorded so far (max_age_hours ignored)
|
|
|
|
The errors live in the display service, so this records a clear request
|
|
that it applies within a few seconds. Reads hide the cleared errors from
|
|
the moment the request is recorded.
|
|
"""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
clear_all = _coerce_to_bool(data.get('all'))
|
|
raw_max_age = data.get('max_age_hours', 24)
|
|
|
|
# Validate and coerce max_age_hours
|
|
max_age_hours = None
|
|
if not clear_all:
|
|
try:
|
|
max_age_hours = int(raw_max_age)
|
|
if max_age_hours < 1:
|
|
return error_response(
|
|
error_code=ErrorCode.INVALID_INPUT,
|
|
message="max_age_hours must be at least 1",
|
|
context={'provided_value': raw_max_age},
|
|
status_code=400
|
|
)
|
|
if max_age_hours > 8760: # 1 year max
|
|
return error_response(
|
|
error_code=ErrorCode.INVALID_INPUT,
|
|
message="max_age_hours cannot exceed 8760 (1 year)",
|
|
context={'provided_value': raw_max_age},
|
|
status_code=400
|
|
)
|
|
except (ValueError, TypeError, OverflowError):
|
|
return error_response(
|
|
error_code=ErrorCode.INVALID_INPUT,
|
|
message="max_age_hours must be a valid integer",
|
|
context={'provided_value': str(raw_max_age)},
|
|
status_code=400
|
|
)
|
|
|
|
now = _pkg.time.time()
|
|
cutoff = now if clear_all else now - max_age_hours * 3600
|
|
try:
|
|
result = _errors.request_error_clear(_errors_cache(), cutoff)
|
|
except OSError as e:
|
|
logger.error("Could not record an error clear request: %s", e)
|
|
return error_response(
|
|
error_code=ErrorCode.SYSTEM_ERROR,
|
|
message="Could not record the clear request in the shared cache",
|
|
status_code=500
|
|
)
|
|
|
|
scope = "all errors" if clear_all else f"errors older than {max_age_hours} hours"
|
|
return success_response(
|
|
data=result,
|
|
message=(f"Clear of {scope} requested; the display service applies it "
|
|
f"within about {int(_errors.SNAPSHOT_TICK_INTERVAL)} seconds")
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error clearing old errors: {e}", exc_info=True)
|
|
return error_response(
|
|
error_code=ErrorCode.SYSTEM_ERROR,
|
|
message="Failed to clear old errors",
|
|
status_code=500
|
|
)
|
|
|
|
|
|
@api_v3.route('/integrations/mqtt-bridge', methods=['GET'])
|
|
def get_mqtt_bridge():
|
|
"""Bridge service state and its settings, minus the password."""
|
|
try:
|
|
config = _read_mqtt_bridge_config()
|
|
password = config.get('mqtt_password')
|
|
safe = {key: config.get(key, default)
|
|
for key, default in _MQTT_BRIDGE_DEFAULTS.items()}
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'service': _mqtt_bridge_service_state(),
|
|
'config_exists': _MQTT_BRIDGE_CONFIG.is_file(),
|
|
'config_path': str(_MQTT_BRIDGE_CONFIG),
|
|
'config': safe,
|
|
# Enough to render "a password is set" without disclosing it.
|
|
'password_set': bool(password),
|
|
'env_override_prefix': 'LEDMATRIX_MQTT_',
|
|
}
|
|
})
|
|
except Exception as e:
|
|
logger.exception('Error reading MQTT bridge settings')
|
|
return jsonify({'status': 'error', 'message': 'Could not read bridge settings',
|
|
'details': describe_exception(e)}), 500
|
|
|
|
@api_v3.route('/integrations/mqtt-bridge/config', methods=['PUT'])
|
|
def update_mqtt_bridge_config():
|
|
"""Write bridge_config.json.
|
|
|
|
The password is write-only: omit it to leave whatever is stored alone, send
|
|
a value to replace it, or send clear_password to remove it. It is never
|
|
returned by the GET above, so a form that round-tripped it would otherwise
|
|
have to blank it on every save.
|
|
"""
|
|
try:
|
|
# No `or {}` here: get_json(silent=True) returns None for a missing or
|
|
# unparseable body, and `None or {}` produced an empty dict that then
|
|
# satisfied the isinstance check below -- so malformed JSON, `null`,
|
|
# `[]` and `false` all reported success while applying nothing.
|
|
data = request.get_json(silent=True)
|
|
if not isinstance(data, dict):
|
|
return jsonify({'status': 'error', 'message': 'Body must be a JSON object'}), 400
|
|
|
|
config = _read_mqtt_bridge_config()
|
|
existing_password = config.get('mqtt_password')
|
|
|
|
updates = {}
|
|
for key in _MQTT_BRIDGE_DEFAULTS:
|
|
if key not in data:
|
|
continue
|
|
value, err = _coerce_mqtt_bridge_value(key, data[key])
|
|
if err:
|
|
return jsonify({'status': 'error', 'message': err}), 400
|
|
updates[key] = value
|
|
|
|
config.update(updates)
|
|
|
|
# Coerced, not merely truthy: the string "false" is truthy in Python,
|
|
# so a client echoing the field back as a string would have wiped a
|
|
# stored password it meant to keep.
|
|
if _coerce_to_bool(data.get('clear_password')):
|
|
config['mqtt_password'] = None
|
|
elif 'mqtt_password' in data and str(data['mqtt_password']) != '':
|
|
new_password = str(data['mqtt_password'])
|
|
if len(new_password) > 300:
|
|
return jsonify({'status': 'error', 'message': 'Password is too long'}), 400
|
|
config['mqtt_password'] = new_password
|
|
else:
|
|
config['mqtt_password'] = existing_password
|
|
|
|
# CWE-319: a password with TLS off is sent in the clear. On a trusted
|
|
# LAN that is a normal, deliberate setup, so this is refused rather
|
|
# than forbidden -- allow_insecure_mqtt is the explicit acknowledgement.
|
|
insecure = bool(config.get('mqtt_password')) and not config.get('mqtt_tls')
|
|
if insecure and not config.get('allow_insecure_mqtt'):
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'MQTT credentials would cross the network in cleartext '
|
|
'with TLS disabled. Enable mqtt_tls, or set '
|
|
'allow_insecure_mqtt to accept that on a trusted network.'
|
|
}), 400
|
|
if insecure:
|
|
logger.warning('MQTT bridge: a password is set without TLS and '
|
|
'allow_insecure_mqtt is on; credentials will cross the '
|
|
'network in cleartext')
|
|
|
|
_MQTT_BRIDGE_DIR.mkdir(parents=True, exist_ok=True)
|
|
# Write via a temp file in the same directory so a crash mid-write
|
|
# cannot leave a half-written config the bridge would refuse to load.
|
|
fd, tmp_path = tempfile.mkstemp(dir=str(_MQTT_BRIDGE_DIR), prefix='.bridge_config.')
|
|
try:
|
|
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
|
json.dump(config, handle, indent=2, sort_keys=True)
|
|
handle.write('\n')
|
|
os.chmod(tmp_path, 0o600)
|
|
os.replace(tmp_path, _MQTT_BRIDGE_CONFIG)
|
|
except Exception:
|
|
with contextlib.suppress(OSError):
|
|
os.unlink(tmp_path)
|
|
raise
|
|
|
|
service = _mqtt_bridge_service_state()
|
|
message = 'Bridge settings saved.'
|
|
if service['active']:
|
|
message += ' Restart the bridge for them to take effect.'
|
|
return jsonify({'status': 'success', 'message': message,
|
|
'data': {'password_set': bool(config.get('mqtt_password')),
|
|
'restart_required': service['active']}})
|
|
except Exception as e:
|
|
logger.exception('Error saving MQTT bridge settings')
|
|
return jsonify({'status': 'error', 'message': 'Could not save bridge settings',
|
|
'details': describe_exception(e)}), 500
|