From ec84133417ad2aa44955ccc1b0233f5685d83aa0 Mon Sep 17 00:00:00 2001 From: Grigo Date: Tue, 10 Nov 2026 10:58:05 +0000 Subject: [PATCH] rest api: accept Basic Auth with the panel password for writes Any login, password = web/settings password (or master password). 401 carries WWW-Authenticate so browsers/Postman prompt. X-Api-Key still works for existing integrations. Docs warn that over plain HTTP the password travels in every request. Co-Authored-By: Claude Opus 5.5 --- docs/rest-api.md | 35 +++++++++++++++++--------- webconfig/rest_api.py | 45 ++++++++++++++++++++++------------ webconfig/templates/index.html | 2 +- 3 files changed, 54 insertions(+), 28 deletions(-) diff --git a/docs/rest-api.md b/docs/rest-api.md index 24eda5c..dede31b 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -2,7 +2,18 @@ Базовый адрес — тот же, что у веб-конфигуратора (по умолчанию порт 8080). Отдельного процесса нет. -Чтение открыто. Запись требует заголовок `X-Api-Key`. Ключ лежит в `settings.apiKey`, создаётся сам при первом запуске и показывается на вкладке «Безопасность» конфигуратора. Запроса «отдать ключ» в этом API нет: его увидел бы любой в сети. Неверный или пустой ключ — HTTP 401: +Чтение открыто. Запись требует авторизацию, подходит любой из двух способов: + +- **Basic Auth с паролем веб-панели** (основной способ). Логин любой, например `admin`; пароль тот же, что для входа в конфигуратор и настройки на экране (подходит и мастер-пароль). Сменили пароль в панели — меняется и пароль для API. + + ```sh + curl -u admin:ПАРОЛЬ -X POST http://:8080/api/v1/buttons//press + ``` +- **Заголовок `X-Api-Key`** (оставлен для уже настроенных интеграций). Ключ лежит в `settings.apiKey` и показывается на вкладке «Безопасность» конфигуратора. + +Устройство работает по HTTP, поэтому пароль в Basic Auth передаётся по сети открытым текстом в каждом запросе (так же, как при входе в веб-панель). Кто перехватит трафик, получит пароль администратора, а с ним и OTA, то есть root на устройстве. Ключ API при утечке даёт меньше: только методы записи API. В доверенной сети это приемлемо; если интеграция ходит через чужую сеть, лучше ключ. + +Без авторизации или с неверными данными — HTTP 401 с заголовком `WWW-Authenticate: Basic`: ```json { "ok": false, "error": "unauthorized" } @@ -111,7 +122,7 @@ ## PUT /api/v1/sensors/{mac}/name -Заголовок `X-Api-Key` обязателен. +Нужна авторизация (Basic или `X-Api-Key`, см. начало). ```json { "name": "Резервуар_1" } @@ -136,7 +147,7 @@ ## PUT /api/v1/settings -Заголовок `X-Api-Key` обязателен. +Нужна авторизация (Basic или `X-Api-Key`, см. начало). Принимается только `report_interval_min`, целое от 1 до 65535. Иначе HTTP 400 `invalid_interval`. `stale_timeout_s` записать нельзя. @@ -154,7 +165,7 @@ ## PUT /api/v1/sensors/{mac}/calibration -Заголовок `X-Api-Key` обязателен. +Нужна авторизация (Basic или `X-Api-Key`, см. начало). ```json { "empty_mm": 1200, "full_mm": 200 } @@ -178,7 +189,7 @@ ## GET /api/v1/buttons/{id} -Заголовок `X-Api-Key` обязателен. +Нужна авторизация (Basic или `X-Api-Key`, см. начало). Полная карточка кнопки: подпись, иконка, действие, тексты ответа, удержание и цвет. Неизвестный id — HTTP 404 `button_not_found`. @@ -206,7 +217,7 @@ ## POST /api/v1/buttons -Заголовок `X-Api-Key` обязателен. +Нужна авторизация (Basic или `X-Api-Key`, см. начало). Создаёт кнопку. Идентификатор назначается сам. Поля те же, что при правке, плюс плоские имена действия: `actionType` (`http_get` или `http_post`), `url`, `headers`, `body`, `timeoutMs` (1000…600000), `successText`, `errorText`, `pendingText`, `fadeMs`, `triggerMode` (`click` или `hold`), `holdMs`, `color`, `iconPath`, `latchReset`, `responseCheck`. Некорректное тело — HTTP 400 `invalid_button` и поле `message` с причиной. Ответ HTTP 201 — созданная карточка. @@ -223,13 +234,13 @@ ## PUT /api/v1/buttons/{id} -Заголовок `X-Api-Key` обязателен. +Нужна авторизация (Basic или `X-Api-Key`, см. начало). Меняет только переданные поля. Имена полей те же, что у создания. Неизвестный id — HTTP 404 `button_not_found`. Некорректное поле — HTTP 400 `invalid_button`. Ответ — карточка после записи. Экран подхватывает конфиг сам. ## DELETE /api/v1/buttons/{id} -Заголовок `X-Api-Key` обязателен. +Нужна авторизация (Basic или `X-Api-Key`, см. начало). Удаляет кнопку. Неизвестный id — HTTP 404 `button_not_found`. @@ -239,7 +250,7 @@ ## POST /api/v1/buttons/reorder -Заголовок `X-Api-Key` обязателен. +Нужна авторизация (Basic или `X-Api-Key`, см. начало). Порядок кнопок на экране. В `order` — идентификаторы спереди назад. Пропущенные остаются в конце в прежнем порядке. `order` не массив строк — HTTP 400 `invalid_order`. @@ -271,7 +282,7 @@ ## PUT /api/v1/background -Заголовок `X-Api-Key` обязателен. +Нужна авторизация (Basic или `X-Api-Key`, см. начало). Меняет только переданные поля. Цвет — `#rrggbb`. `animated` — логическое. Иначе HTTP 400 `invalid_background`. Ответ — фон после записи. @@ -286,7 +297,7 @@ ## POST /api/v1/buttons/{id}/press -Заголовок `X-Api-Key` обязателен. +Нужна авторизация (Basic или `X-Api-Key`, см. начало). То же действие, что нажатие на экране, включая защёлку. Тело не нужно. @@ -300,7 +311,7 @@ ## POST /api/v1/system/reboot -Заголовок `X-Api-Key` обязателен. +Нужна авторизация (Basic или `X-Api-Key`, см. начало). Перезагрузка устройства тем же путём, что кнопка в конфигураторе. Ответ уходит до того, как машина погаснет. diff --git a/webconfig/rest_api.py b/webconfig/rest_api.py index 88241cd..b1969f3 100644 --- a/webconfig/rest_api.py +++ b/webconfig/rest_api.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """Integrator REST API, prefix /api/v1. -Read methods are open. Writes require the X-Api-Key header. +Read methods are open. Writes require HTTP Basic auth with the web panel +password (any user name) or, for older integrations, the X-Api-Key header. Sensor math matches collector/protocol.py (tenths of a volt, 2xAA curve). """ import hmac @@ -18,6 +19,7 @@ from flask import Blueprint, jsonify, request from werkzeug.exceptions import HTTPException from api import _apply_button_action, _apply_button_latch_reset +from auth import passwords_match from config_store import load_config, save_config from paths import run_dir from sensors import ( @@ -69,11 +71,24 @@ def _api_key_ok() -> bool: return hmac.compare_digest(got.encode("utf-8"), expected.encode("utf-8")) -def require_api_key(fn): +def _basic_auth_ok() -> bool: + """Basic auth with the web panel password (settings.password or the + master password, same as /login). The user name is not checked: the panel + has no user names.""" + auth = request.authorization + if auth is None or (auth.type or "").lower() != "basic" or not auth.password: + return False + expected = str(load_config().get("settings", {}).get("password", "admin")) + return passwords_match(auth.password, expected) + + +def require_write_auth(fn): @wraps(fn) def wrapper(*args, **kwargs): - if not _api_key_ok(): - return _err("unauthorized", 401) + if not (_basic_auth_ok() or _api_key_ok()): + resp, status = _err("unauthorized", 401) + resp.headers["WWW-Authenticate"] = 'Basic realm="ButtonTask", charset="UTF-8"' + return resp, status return fn(*args, **kwargs) return wrapper @@ -341,7 +356,7 @@ def api_sensor_history(mac): @rest_bp.route("/sensors//name", methods=["PUT"]) -@require_api_key +@require_write_auth def api_sensor_name(mac): addr = _check_mac(mac) if addr is None: @@ -371,7 +386,7 @@ def api_settings_get(): @rest_bp.route("/settings", methods=["PUT"]) -@require_api_key +@require_write_auth def api_settings_put(): body = request.get_json(silent=True) or {} if "report_interval_min" not in body or isinstance(body.get("report_interval_min"), bool): @@ -421,7 +436,7 @@ def api_calibration_get(mac): @rest_bp.route("/sensors//calibration", methods=["PUT"]) -@require_api_key +@require_write_auth def api_calibration_put(mac): addr = _check_mac(mac) if addr is None: @@ -484,7 +499,7 @@ def api_buttons(): @rest_bp.route("/buttons", methods=["POST"]) -@require_api_key +@require_write_auth def api_button_create(): data = request.get_json(silent=True) or {} if not isinstance(data, dict): @@ -527,7 +542,7 @@ def api_button_create(): @rest_bp.route("/buttons/reorder", methods=["POST"]) -@require_api_key +@require_write_auth def api_button_reorder(): data = request.get_json(silent=True) or {} order = data.get("order") @@ -545,7 +560,7 @@ def api_button_reorder(): @rest_bp.route("/buttons/", methods=["GET"]) -@require_api_key +@require_write_auth def api_button_get(button_id): btn = _find_button(_buttons(), button_id) if btn is None: @@ -554,7 +569,7 @@ def api_button_get(button_id): @rest_bp.route("/buttons/", methods=["PUT"]) -@require_api_key +@require_write_auth def api_button_update(button_id): data = request.get_json(silent=True) or {} if not isinstance(data, dict): @@ -597,7 +612,7 @@ def api_button_update(button_id): @rest_bp.route("/buttons/", methods=["DELETE"]) -@require_api_key +@require_write_auth def api_button_delete(button_id): cfg = load_config() before = cfg.get("buttons") or [] @@ -624,7 +639,7 @@ def api_background_get(): @rest_bp.route("/background", methods=["PUT"]) -@require_api_key +@require_write_auth def api_background_put(): data = request.get_json(silent=True) or {} if not isinstance(data, dict): @@ -664,7 +679,7 @@ def api_background_put(): @rest_bp.route("/buttons//press", methods=["POST"]) -@require_api_key +@require_write_auth def api_button_press(button_id): buttons = load_config().get("buttons") or [] known = any(isinstance(b, dict) and b.get("id") == button_id for b in buttons) @@ -682,7 +697,7 @@ def api_button_press(button_id): @rest_bp.route("/system/reboot", methods=["POST"]) -@require_api_key +@require_write_auth def api_reboot(): try: trigger_reboot() diff --git a/webconfig/templates/index.html b/webconfig/templates/index.html index ad87814..90f00ce 100644 --- a/webconfig/templates/index.html +++ b/webconfig/templates/index.html @@ -559,7 +559,7 @@

Ключ API

-

Заголовок X-Api-Key для методов записи REST API /api/v1. Открытого запроса «получить ключ» нет.

+

Методы записи REST API /api/v1 принимают Basic Auth с паролем этой панели (логин любой, например admin) или заголовок X-Api-Key с ключом ниже. Ключ безопаснее: при утечке он не даёт доступа к панели.