Compare commits
4 Commits
b21817ab9f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e74f3dc67a | |||
| 345fe19ca7 | |||
| e91d4605b1 | |||
| b44004627b |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -9,3 +9,11 @@ venv/
|
|||||||
|
|
||||||
# Локальный конфиг устройства (содержит lanip_key)
|
# Локальный конфиг устройства (содержит lanip_key)
|
||||||
docs/legacy/config_kata.json
|
docs/legacy/config_kata.json
|
||||||
|
|
||||||
|
# Сборка
|
||||||
|
build/
|
||||||
|
build-*/
|
||||||
|
|
||||||
|
# Coredump-файлы отладки
|
||||||
|
core
|
||||||
|
core.*
|
||||||
|
|||||||
105
CMakeLists.txt
Normal file
105
CMakeLists.txt
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
|
if(ESP_PLATFORM)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Сборка как компонента ESP-IDF (корень репозитория = компонент; имя
|
||||||
|
# компонента = имя каталога, из которого он подключён).
|
||||||
|
# ВАЖНО: cmake_minimum_required должен остаться до этой ветки, project()
|
||||||
|
# для IDF-сборки не вызывается.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
idf_component_register(
|
||||||
|
SRCS
|
||||||
|
"src/ayla/crypto.cpp"
|
||||||
|
"src/ayla/envelope.cpp"
|
||||||
|
"src/ayla/httpc.cpp"
|
||||||
|
"src/ayla/httpd.cpp"
|
||||||
|
"src/ayla/log.cpp"
|
||||||
|
"src/ayla/session.cpp"
|
||||||
|
"src/ayla/platform/esp-idf/platform.cpp"
|
||||||
|
INCLUDE_DIRS
|
||||||
|
"include"
|
||||||
|
PRIV_INCLUDE_DIRS
|
||||||
|
"."
|
||||||
|
"src"
|
||||||
|
PRIV_REQUIRES
|
||||||
|
lwip
|
||||||
|
esp_timer
|
||||||
|
esp_hw_support
|
||||||
|
pthread
|
||||||
|
mbedtls
|
||||||
|
)
|
||||||
|
target_compile_options(${COMPONENT_LIB} PRIVATE
|
||||||
|
-Wall -Wextra -Werror
|
||||||
|
-fno-exceptions -fno-rtti
|
||||||
|
)
|
||||||
|
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20)
|
||||||
|
else()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Обычная (POSIX) сборка: статическая библиотека + тесты.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
project(fgl-aircon VERSION 0.1.0 LANGUAGES CXX)
|
||||||
|
|
||||||
|
option(FGL_BUILD_TESTS "Build tests" ON)
|
||||||
|
option(FGL_BUNDLED_MBEDTLS "Fetch and build Mbed TLS instead of system" OFF)
|
||||||
|
|
||||||
|
# ---- mbedtls ----
|
||||||
|
if(NOT FGL_BUNDLED_MBEDTLS)
|
||||||
|
find_path(MBEDTLS_INCLUDE_DIR mbedtls/aes.h
|
||||||
|
HINTS /usr/include/mbedtls3 /usr/include
|
||||||
|
)
|
||||||
|
find_library(MBEDTLS_CRYPTO_LIB
|
||||||
|
NAMES mbedcrypto-3 mbedcrypto
|
||||||
|
)
|
||||||
|
if(NOT MBEDTLS_INCLUDE_DIR OR NOT MBEDTLS_CRYPTO_LIB)
|
||||||
|
message(STATUS "System Mbed TLS not found - falling back to bundled")
|
||||||
|
set(FGL_BUNDLED_MBEDTLS ON)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
if(FGL_BUNDLED_MBEDTLS)
|
||||||
|
include(FetchContent)
|
||||||
|
FetchContent_Declare(mbedtls
|
||||||
|
GIT_REPOSITORY https://github.com/Mbed-TLS/mbedtls.git
|
||||||
|
GIT_TAG v3.6.2
|
||||||
|
GIT_SHALLOW TRUE
|
||||||
|
)
|
||||||
|
set(ENABLE_PROGRAMS OFF CACHE BOOL "" FORCE)
|
||||||
|
set(ENABLE_TESTING OFF CACHE BOOL "" FORCE)
|
||||||
|
set(USE_SHARED_MBEDTLS_LIBRARY OFF CACHE BOOL "" FORCE)
|
||||||
|
set(USE_STATIC_MBEDTLS_LIBRARY ON CACHE BOOL "" FORCE)
|
||||||
|
FetchContent_MakeAvailable(mbedtls)
|
||||||
|
set(FGL_MBEDTLS_TARGET mbedcrypto STATIC)
|
||||||
|
set(FGL_MBEDTLS_INCLUDE_DIR "")
|
||||||
|
else()
|
||||||
|
set(FGL_MBEDTLS_TARGET "${MBEDTLS_CRYPTO_LIB}")
|
||||||
|
set(FGL_MBEDTLS_INCLUDE_DIR "${MBEDTLS_INCLUDE_DIR}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_library(fgl-aircon STATIC
|
||||||
|
src/ayla/crypto.cpp
|
||||||
|
src/ayla/envelope.cpp
|
||||||
|
src/ayla/httpc.cpp
|
||||||
|
src/ayla/httpd.cpp
|
||||||
|
src/ayla/log.cpp
|
||||||
|
src/ayla/session.cpp
|
||||||
|
src/ayla/platform/posix/platform.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(fgl-aircon
|
||||||
|
PUBLIC
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/include"
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}"
|
||||||
|
"${FGL_MBEDTLS_INCLUDE_DIR}"
|
||||||
|
PRIVATE
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
||||||
|
)
|
||||||
|
target_link_libraries(fgl-aircon PUBLIC ${FGL_MBEDTLS_TARGET})
|
||||||
|
target_compile_features(fgl-aircon PUBLIC cxx_std_20)
|
||||||
|
target_compile_options(fgl-aircon PRIVATE
|
||||||
|
-Wall -Wextra -Werror
|
||||||
|
-fno-exceptions -fno-rtti
|
||||||
|
)
|
||||||
|
|
||||||
|
if(FGL_BUILD_TESTS)
|
||||||
|
enable_testing()
|
||||||
|
add_subdirectory(tests)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
@@ -30,13 +30,14 @@
|
|||||||
### 2.1. [ГЛАВНАЯ ПРИЧИНА «РАССИНХРОНИЗАЦИИ»] Keep-alive 1200 с вместо 10–15 с
|
### 2.1. [ГЛАВНАЯ ПРИЧИНА «РАССИНХРОНИЗАЦИИ»] Keep-alive 1200 с вместо 10–15 с
|
||||||
|
|
||||||
`notifier.py:_KEEP_ALIVE_INTERVAL = 1200.0`. APK: 10 с (или `lan.json:keepAlive/3`).
|
`notifier.py:_KEEP_ALIVE_INTERVAL = 1200.0`. APK: 10 с (или `lan.json:keepAlive/3`).
|
||||||
Проверено на приборе: **единственный механизм восстановления после расхождения
|
Проверено на приборе: **модуль игнорирует 400/401 на свои POST; переkey
|
||||||
CBC-цепочек — принудительный re-key, который модуль делает при получении
|
происходит при `local_reg` после зазора ≥ ~44–50 с от предыдущего** (при
|
||||||
`local_reg` для сессии старше ≈44 с. Ответы 400/401 модуль игнорирует.**
|
keep-alive 10–15 с re-key вообще не происходит). Следствие для legacy: любая
|
||||||
Следствие для legacy: любая потерянная пара запрос-ответ/обрыв соединения →
|
потерянная пара запрос-ответ → обе стороны «глохнут» до следующего local_reg
|
||||||
обе стороны «глохнут» на срок до 20 минут (до следующего local_reg). Наблюдаемый
|
(до 20 минут), который завершится re-key — наблюдаемый симптом «перестаёт
|
||||||
симптом «перестаёт понимать кондиционер» с самопроизвольным восстановлением —
|
понимать кондиционер, потом сам чинится» — именно это. Корректная стратегия
|
||||||
именно это.
|
для новой реализации: при ошибке расшифровки — пауза ~50 с, затем local_reg
|
||||||
|
(re-key гарантирован).
|
||||||
|
|
||||||
Дополнительно: длинные паузы между local_reg держат сессию «полуживой»
|
Дополнительно: длинные паузы между local_reg держат сессию «полуживой»
|
||||||
(модуль не видит keep-alive, но слот может удерживаться), и конфликт за
|
(модуль не видит keep-alive, но слот может удерживаться), и конфликт за
|
||||||
|
|||||||
@@ -199,9 +199,13 @@ int32_t fgl_convert_from_input(fgl_template, fgl_prop, int32_t disp,
|
|||||||
### 5.2. Keep-alive и re-key
|
### 5.2. Keep-alive и re-key
|
||||||
* Таймер `keepalive_ms` (default **15000**); по истечении — `PUT local_reg`
|
* Таймер `keepalive_ms` (default **15000**); по истечении — `PUT local_reg`
|
||||||
c `notify=(очередь непуста)`. Каждый `commands.json` перезапускает таймер.
|
c `notify=(очередь непуста)`. Каждый `commands.json` перезапускает таймер.
|
||||||
* Re-key (очередной local_reg при возрасте сессии ≥ ~44 с) — штатное
|
* Re-key — событие по инициативе модуля (при зазоре local_reg ≥ ~44–50 с,
|
||||||
событие: перегенерация шифров/цепочек, сессия не пересоздаётся, начальная
|
[ПРОВЕРЕНО НА ПРИБОРЕ]; при штатном keep-alive НЕ происходит): обработать
|
||||||
синхронизация не повторяется; seq_no приложения продолжает глобальный счётчик.
|
как обычный KE (перегенерация шифров/цепочек), сессию не пересоздавать,
|
||||||
|
начальную синхронизацию не повторять; seq_no приложения продолжает
|
||||||
|
глобальный счётчик.
|
||||||
|
* Восстановление при ошибке расшифровки: тишина > порога (50 с по умолчанию)
|
||||||
|
и возврат — модуль гарантированно ре-кает [ПРОВЕРЕНО НА ПРИБОРЕ].
|
||||||
* Анти-спам: ≤1 local_reg/с; notify=1 — один на пакет команд.
|
* Анти-спам: ≤1 local_reg/с; notify=1 — один на пакет команд.
|
||||||
|
|
||||||
### 5.3. Очередь команд
|
### 5.3. Очередь команд
|
||||||
@@ -289,9 +293,9 @@ HA-превью (PLAN_HOME_ASSISTANT §4) и тестами.
|
|||||||
|
|
||||||
| # | Содержимое | Критерии приёмки |
|
| # | Содержимое | Критерии приёмки |
|
||||||
|---|------------|------------------|
|
|---|------------|------------------|
|
||||||
| M0 | Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI | Собирается linux+esp-idf; пустой httpd отвечает 404 |
|
| M0 ✅ | Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI | Собирается linux+esp-idf; пустой httpd отвечает 404 |
|
||||||
| M1 | `src/ayla`: crypto+envelope, мини-httpd/httpc, jsmn-вендор | Векторы зелёные; httpd-тесты; совместимость с probe_reference.py |
|
| M1 ✅ | `src/ayla`: crypto+envelope, мини-httpd/httpc, jsmn-вендор | Векторы зелёные; httpd-тесты; совместимость с probe_reference.py |
|
||||||
| M2 | `src/ayla`: сессия (установка/активация/keep-alive/re-key/слоты/503/delete) с mock-модулем | Все сценарии mock; на приборе: активация ≤5 с, re-key каждые 45–60 с |
|
| M2 ✅ | `src/ayla`: сессия (установка/активация/keep-alive/re-key/слоты/503/delete) с mock-модулем | Все сценарии mock; на приборе: активация ≤5 с; семантика re-key: при зазоре local_reg ≥ ~44–50 с (при честном keep-alive 15 с — 0 re-key за 100 с; при 50 с — 3 re-key) |
|
||||||
| M3 | `src/aircon`: шаблоны, конверсии+override, публичный API, batch | `tests/aircon` зелёные; на приборе: чтение всех свойств, batch=1 notify |
|
| M3 | `src/aircon`: шаблоны, конверсии+override, публичный API, batch | `tests/aircon` зелёные; на приборе: чтение всех свойств, batch=1 notify |
|
||||||
| M4 | fglctl-пример, `tools/fglair-discover` (в т.ч. `--format esphome-secrets`), README библиотеки (сборка IDF/POSIX, тесты) | 24 ч на приборе: 0 рассинхронов; README готов |
|
| M4 | fglctl-пример, `tools/fglair-discover` (в т.ч. `--format esphome-secrets`), README библиотеки (сборка IDF/POSIX, тесты) | 24 ч на приборе: 0 рассинхронов; README готов |
|
||||||
| M5 | (Опция) `FglHub` N устройств; mDNS-резолвер как опция host-разрешения | Два устройства одновременно |
|
| M5 | (Опция) `FglHub` N устройств; mDNS-резолвер как опция host-разрешения | Два устройства одновременно |
|
||||||
|
|||||||
@@ -216,15 +216,21 @@ GET http://<ip приложения>:<порт>/local_lan/commands.json
|
|||||||
один «пустой» опрос `commands.json` — это признак принятой сессии.
|
один «пустой» опрос `commands.json` — это признак принятой сессии.
|
||||||
2. `local_reg` от endpoint'а с живой сессией **моложе ~40 с** → только
|
2. `local_reg` от endpoint'а с живой сессией **моложе ~40 с** → только
|
||||||
keep-alive, без key exchange.
|
keep-alive, без key exchange.
|
||||||
3. `local_reg` от endpoint'а с сессией **старше ~44 с** → модуль принудительно
|
3. `local_reg` при **зазоре ≥ ~44–50 с** от предыдущего local_reg →
|
||||||
инициирует новый key exchange (ротация сессионных ключей). Т.е. при штатном
|
модуль принудительно инициирует новый key exchange («вернувшееся»
|
||||||
keep-alive каждые 10–15 с ключи ротируются примерно каждые 45–60 с.
|
приложение получает свежие ключи). При штатном keep-alive каждые 10–15 с
|
||||||
`time_1` модуля — тикающий счётчик с шагом ≈10 нс (аптайм); порог,
|
re-key НЕ происходит — сессия живёт сколь угодно долго (проверено:
|
||||||
вероятно, 44 с в этих единицах либо просто 4.4e9 тиков.
|
100 с при 15 с keep-alive — 0 re-key; 125 с при 50 с keep-alive — 3 re-key,
|
||||||
|
оба без потерь). `time_1` модуля — тикающий счётчик с шагом ≈10 нс (аптайм);
|
||||||
|
порог, вероятно, 4.4e9 тиков (~44 с) от последнего local_reg.
|
||||||
4. **Ответы 401/400 на POST модуля игнорируются**: сессия продолжает работать,
|
4. **Ответы 401/400 на POST модуля игнорируются**: сессия продолжает работать,
|
||||||
re-key не вызывается. Единственный механизм восстановления после расхождения
|
re-key не вызывается. Восстановление после расхождения CBC-цепочек —
|
||||||
CBC-цепочек — принудительный re-key по `local_reg` (п. 3). Поэтому интервал
|
намеренная «тишина» приложения на > порога из п. 3 с последующим
|
||||||
keep-alive = интервал потенциального «зависания» при десинхроне.
|
`local_reg`: модуль сочтёт приложение вернувшимся и ре-кает. Т.е. стратегия
|
||||||
|
самовосстановления: при ошибке расшифровки — пауза keep-alive ~50–60 с,
|
||||||
|
затем возобновить (проверено на приборе). Отдельный случай — бракованная
|
||||||
|
подпись при живой цепочке (сообщение расшифровано, подпись не сошлась):
|
||||||
|
цепочка НЕ расходится, следующий push восстанавливает работу без re-key.
|
||||||
5. `delete_session` освобождает слот немедленно; следующий `local_reg` того же
|
5. `delete_session` освобождает слот немедленно; следующий `local_reg` того же
|
||||||
endpoint'а создаёт новую сессию.
|
endpoint'а создаёт новую сессию.
|
||||||
6. Наблюдавшийся (не воспроизведённый повторно) режим отказа: модуль отвечает
|
6. Наблюдавшийся (не воспроизведённый повторно) режим отказа: модуль отвечает
|
||||||
@@ -316,11 +322,12 @@ data: {"id":"<id команды>","ack_status":200,"ack_message":0,"dsn":"..."}
|
|||||||
|
|
||||||
* **Потеря CBC-цепочки** (§3.4): модуль не может расшифровать ответ приложения /
|
* **Потеря CBC-цепочки** (§3.4): модуль не может расшифровать ответ приложения /
|
||||||
приложение не может расшифровать push модуля. Ответы 401/400 на POST модуля
|
приложение не может расшифровать push модуля. Ответы 401/400 на POST модуля
|
||||||
**игнорируются** — модуль продолжает слать в «сломанный» канал. Восстановление
|
**игнорируются** — модуль продолжает слать в «сломанный» канал. Восстановление:
|
||||||
происходит только когда очередной `local_reg` (по возрасту ≥ ~44 с или от
|
приложение замолкает на > ~44–50 с (порог «возврата» из п. 4.4.3) и шлёт
|
||||||
нового endpoint'а) вызовет новый key exchange. Следствие: **интервал
|
`local_reg` — модуль переkey'ается. Реализация ядра: при ошибке расшифровки
|
||||||
keep-alive = максимальное время «мёртвой» сессии при десинхроне**
|
пауза keep-alive ~50 с, затем возобновление. В legacy-скрипте пауза получалась
|
||||||
(10–15 с — незаметно; 1200 с как в legacy-скрипте — 20 минут глухоты).
|
«бесплатно» из-за keep-alive 1200 с: каждый цикл завершался re-key при
|
||||||
|
возврате — потому рассинхрон «сам чинился» через ~20 минут.
|
||||||
* **Смена lanip_key** (`key_id` не совпал): теоретический путь по APK — 412 +
|
* **Смена lanip_key** (`key_id` не совпал): теоретический путь по APK — 412 +
|
||||||
`refreshLanConfig()` из облака. За 5 лет эксплуатации прибора ротации ключа
|
`refreshLanConfig()` из облака. За 5 лет эксплуатации прибора ротации ключа
|
||||||
не наблюдалось ни разу; ключ, по-видимому, зашит в модуль, облако лишь хранит
|
не наблюдалось ни разу; ключ, по-видимому, зашит в модуль, облако лишь хранит
|
||||||
|
|||||||
7
include/fgl-aircon/version.hpp
Normal file
7
include/fgl-aircon/version.hpp
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Версия библиотеки fgl-aircon.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#define FGL_AIRCON_VERSION_MAJOR 0
|
||||||
|
#define FGL_AIRCON_VERSION_MINOR 1
|
||||||
|
#define FGL_AIRCON_VERSION_PATCH 0
|
||||||
|
#define FGL_AIRCON_VERSION_STRING "0.1.0"
|
||||||
30
scripts/ci.sh
Executable file
30
scripts/ci.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# CI: сборка и тесты POSIX-платформы.
|
||||||
|
# ESP-IDF-сборка проверяется отдельно (см. README):
|
||||||
|
# . $IDF_PATH/export.sh && idf.py build (в тестовом проекте-компоненте)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
BUILD_DIR="${BUILD_DIR:-build-ci}"
|
||||||
|
cmake -S . -B "$BUILD_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||||
|
|
||||||
|
cmake --build "$BUILD_DIR"
|
||||||
|
ctest --test-dir "$BUILD_DIR" --output-on-failure
|
||||||
|
|
||||||
|
# Санитайзеры (gcc): ловим OOB/UB в ядре и тестах.
|
||||||
|
SAN_DIR="${SAN_DIR:-build-san}"
|
||||||
|
cmake -S . -B "$SAN_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer"
|
||||||
|
cmake --build "$SAN_DIR"
|
||||||
|
ctest --test-dir "$SAN_DIR" --output-on-failure
|
||||||
|
|
||||||
|
# clang (если доступен): отдельный компилятор = отдельные диагностики.
|
||||||
|
if command -v clang++ >/dev/null 2>&1; then
|
||||||
|
CLANG_DIR="${CLANG_DIR:-build-clang}"
|
||||||
|
cmake -S . -B "$CLANG_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DCMAKE_CXX_COMPILER=clang++
|
||||||
|
cmake --build "$CLANG_DIR"
|
||||||
|
ctest --test-dir "$CLANG_DIR" --output-on-failure
|
||||||
|
else
|
||||||
|
echo "clang++ не найден — сборка пропущена"
|
||||||
|
fi
|
||||||
|
echo "CI OK"
|
||||||
86
scripts/gen_kdf_vectors.py
Normal file
86
scripts/gen_kdf_vectors.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Регенерация tests/ayla/kdf_vectors.hpp. Эталон — APK AylaEncryption
|
||||||
|
# (совпадает с tools/probe_reference.py). Зависимость: pycryptodome.
|
||||||
|
# Запуск: python scripts/gen_kdf_vectors.py > tests/ayla/kdf_vectors.hpp
|
||||||
|
import base64, hmac, json, random, string
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
|
random.seed(20260922)
|
||||||
|
|
||||||
|
def rand_token(n=16, alpha=string.ascii_letters + string.digits):
|
||||||
|
return "".join(random.choice(alpha) for _ in range(n))
|
||||||
|
|
||||||
|
def cstr(s):
|
||||||
|
return json.dumps(s)
|
||||||
|
|
||||||
|
vectors = []
|
||||||
|
for i in range(4):
|
||||||
|
lanip_key = base64.b64encode(random.randbytes(15 + i)).decode()
|
||||||
|
rnd1 = rand_token(16, string.ascii_letters + string.digits + "+/")
|
||||||
|
rnd2 = rand_token(16)
|
||||||
|
t1 = random.randrange(10**12, 10**15)
|
||||||
|
t2 = random.randrange(10**12, 10**15)
|
||||||
|
k = lanip_key.encode(); b1, b2 = rnd1.encode(), rnd2.encode()
|
||||||
|
s1, s2 = str(t1).encode(), str(t2).encode()
|
||||||
|
|
||||||
|
def m(msg, suf):
|
||||||
|
msg = msg + bytes([suf])
|
||||||
|
return hmac.digest(k, hmac.digest(k, msg, "sha256") + msg, "sha256")
|
||||||
|
|
||||||
|
A, D = b1 + b2 + s1 + s2, b2 + b1 + s2 + s1
|
||||||
|
app_sign, dev_sign = m(A, 0x30), m(D, 0x30)
|
||||||
|
e = AES.new(m(A, 0x31), AES.MODE_CBC, m(A, 0x32)[:16])
|
||||||
|
msgs = [
|
||||||
|
'{"seq_no":0,"data":{}}',
|
||||||
|
'{"seq_no":1,"data":{"cmds":[{"cmd":{"method":"GET","resource":"property.json?name=fan_speed","uri":"/local_lan/property/datapoint.json","data":"","cmd_id":3}}]}}',
|
||||||
|
'{"seq_no":2,"data":{"properties":[{"property":{"base_type":"integer","name":"fan_speed","value":3,"id":"ab12cd34"}}]}}',
|
||||||
|
]
|
||||||
|
encs = []
|
||||||
|
for mtext in msgs:
|
||||||
|
raw = mtext.encode()
|
||||||
|
n = ((len(raw) + 1 + 15) // 16) * 16 # Java-паддинг (>=1 NUL)
|
||||||
|
encs.append(base64.b64encode(e.encrypt(raw.ljust(n, b"\x00"))).decode())
|
||||||
|
legacy_msg = '{"seq_no":3,"data":{"pad":"XX"}}' # 32 байта, без NUL
|
||||||
|
assert len(legacy_msg) % 16 == 0 and legacy_msg[-1] != "\x00"
|
||||||
|
encs.append(base64.b64encode(e.encrypt(legacy_msg.encode())).decode())
|
||||||
|
msgs.append(legacy_msg)
|
||||||
|
|
||||||
|
vectors.append(dict(
|
||||||
|
lanip_key=lanip_key, random_1=rnd1, random_2=rnd2,
|
||||||
|
time_1=t1, time_2=t2, plaintexts=msgs, enc_base64=encs,
|
||||||
|
app_sign_msg0=base64.b64encode(hmac.digest(app_sign, msgs[0].encode(), "sha256")).decode(),
|
||||||
|
dev_sign_msg1=base64.b64encode(hmac.digest(dev_sign, msgs[1].encode(), "sha256")).decode()))
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append("// СГЕНЕРИРОВАНО scripts/gen_kdf_vectors.py (эталон = APK AylaEncryption).")
|
||||||
|
lines.append("// Не редактировать руками; регенерация:")
|
||||||
|
lines.append("// python scripts/gen_kdf_vectors.py > tests/ayla/kdf_vectors.hpp")
|
||||||
|
lines.append("#pragma once")
|
||||||
|
lines.append("#include <cstddef>")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("namespace fgl::ayla::test {")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("struct KdfVector {")
|
||||||
|
lines.append(" const char* lanip_key;")
|
||||||
|
lines.append(" const char* random_1;")
|
||||||
|
lines.append(" const char* random_2;")
|
||||||
|
lines.append(" long long time_1;")
|
||||||
|
lines.append(" long long time_2;")
|
||||||
|
lines.append(" const char* plaintexts[4];")
|
||||||
|
lines.append(" const char* enc_base64[4]; // 0..2: Java-паддинг; 3: legacy без NUL")
|
||||||
|
lines.append(" const char* app_sign_msg0_b64;")
|
||||||
|
lines.append(" const char* dev_sign_msg1_b64;")
|
||||||
|
lines.append("};")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("inline constexpr KdfVector kKdfVectors[] = {")
|
||||||
|
for v in vectors:
|
||||||
|
pt = ", ".join(cstr(m) for m in v["plaintexts"])
|
||||||
|
en = ", ".join(cstr(e) for e in v["enc_base64"])
|
||||||
|
lines.append(f" {{{cstr(v['lanip_key'])}, {cstr(v['random_1'])}, {cstr(v['random_2'])}, "
|
||||||
|
f"{v['time_1']}LL, {v['time_2']}LL, {{{pt}}}, {{{en}}}, "
|
||||||
|
f"{cstr(v['app_sign_msg0'])}, {cstr(v['dev_sign_msg1'])}}},")
|
||||||
|
lines.append("};")
|
||||||
|
lines.append("inline constexpr size_t kKdfVectorCount = sizeof(kKdfVectors) / sizeof(kKdfVectors[0]);")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("} // namespace fgl::ayla::test")
|
||||||
|
print("\n".join(lines))
|
||||||
137
src/ayla/crypto.cpp
Normal file
137
src/ayla/crypto.cpp
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
#include "ayla/crypto.hpp"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "mbedtls/platform_util.h"
|
||||||
|
|
||||||
|
namespace fgl::ayla {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void hmac_sha256(const uint8_t* key, size_t key_len, const uint8_t* data,
|
||||||
|
size_t data_len, uint8_t out[kSha256Len]) {
|
||||||
|
const mbedtls_md_info_t* md = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||||||
|
mbedtls_md_hmac(md, key, key_len, data, data_len, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool DirectionCrypto::derive(const uint8_t* key, size_t key_len,
|
||||||
|
const uint8_t* msg, size_t msg_len,
|
||||||
|
uint8_t out[kSha256Len]) {
|
||||||
|
// key = HMAC_k( HMAC_k(msg) || msg )
|
||||||
|
if (msg_len > sizeof(uint8_t) * (kSha256Len + 192) - kSha256Len) {
|
||||||
|
return false; // невозможно по протоколу (<=73 байт)
|
||||||
|
}
|
||||||
|
uint8_t inner[kSha256Len];
|
||||||
|
hmac_sha256(key, key_len, msg, msg_len, inner);
|
||||||
|
uint8_t buf[kSha256Len + 192];
|
||||||
|
memcpy(buf, inner, kSha256Len);
|
||||||
|
memcpy(buf + kSha256Len, msg, msg_len);
|
||||||
|
hmac_sha256(key, key_len, buf, kSha256Len + msg_len, out);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DirectionCrypto::init(const char* lanip_key, const char* rnd_a,
|
||||||
|
const char* rnd_b, int64_t t_a, int64_t t_b) {
|
||||||
|
if (lanip_key == nullptr || rnd_a == nullptr || rnd_b == nullptr) return false;
|
||||||
|
initialized_ = false;
|
||||||
|
mode_ = 0;
|
||||||
|
// затираем предыдущий материал
|
||||||
|
mbedtls_platform_zeroize(sign_key_, sizeof(sign_key_));
|
||||||
|
mbedtls_platform_zeroize(aes_key_, sizeof(aes_key_));
|
||||||
|
mbedtls_platform_zeroize(chain_iv_, sizeof(chain_iv_));
|
||||||
|
|
||||||
|
uint8_t msg[128];
|
||||||
|
size_t off = 0;
|
||||||
|
auto append = [&](const char* s) {
|
||||||
|
size_t n = strlen(s);
|
||||||
|
if (off + n > sizeof(msg)) return false;
|
||||||
|
memcpy(msg + off, s, n);
|
||||||
|
off += n;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
char t_a_buf[24], t_b_buf[24];
|
||||||
|
snprintf(t_a_buf, sizeof(t_a_buf), "%lld", static_cast<long long>(t_a));
|
||||||
|
snprintf(t_b_buf, sizeof(t_b_buf), "%lld", static_cast<long long>(t_b));
|
||||||
|
if (!append(rnd_a) || !append(rnd_b) || !append(t_a_buf) || !append(t_b_buf) ||
|
||||||
|
off + 1 > sizeof(msg)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8_t* k = reinterpret_cast<const uint8_t*>(lanip_key);
|
||||||
|
size_t klen = strlen(lanip_key);
|
||||||
|
|
||||||
|
uint8_t block[kSha256Len];
|
||||||
|
msg[off] = 0x30; // sign key
|
||||||
|
if (!derive(k, klen, msg, off + 1, block)) return false;
|
||||||
|
memcpy(sign_key_, block, kHmacKeyLen);
|
||||||
|
|
||||||
|
msg[off] = 0x31; // aes key
|
||||||
|
if (!derive(k, klen, msg, off + 1, block)) return false;
|
||||||
|
memcpy(aes_key_, block, kAesKeyLen);
|
||||||
|
|
||||||
|
msg[off] = 0x32; // iv seed (первые 16 байт)
|
||||||
|
if (!derive(k, klen, msg, off + 1, block)) return false;
|
||||||
|
memcpy(chain_iv_, block, kAesBlockLen);
|
||||||
|
|
||||||
|
mbedtls_aes_init(&aes_enc_);
|
||||||
|
mbedtls_aes_init(&aes_dec_);
|
||||||
|
if (mbedtls_aes_setkey_enc(&aes_enc_, aes_key_, 256) != 0) return false;
|
||||||
|
if (mbedtls_aes_setkey_dec(&aes_dec_, aes_key_, 256) != 0) return false;
|
||||||
|
|
||||||
|
initialized_ = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
long DirectionCrypto::encrypt(uint8_t* out, size_t out_cap, const uint8_t* in,
|
||||||
|
size_t in_len) {
|
||||||
|
if (!initialized_) return -1;
|
||||||
|
if (mode_ == 0) mode_ = 1;
|
||||||
|
if (mode_ != 1) return -1; // направление уже в режиме расшифровки
|
||||||
|
// Java-паддинг: >=1 NUL, кратно 16.
|
||||||
|
size_t padded = ((in_len + 1 + kAesBlockLen - 1) / kAesBlockLen) * kAesBlockLen;
|
||||||
|
if (padded > out_cap) return -1;
|
||||||
|
uint8_t buf[kAesBlockLen * 128]; // до 2048 байт (kHttpdMaxBody)
|
||||||
|
if (padded > sizeof(buf)) return -1;
|
||||||
|
memcpy(buf, in, in_len);
|
||||||
|
memset(buf + in_len, 0, padded - in_len);
|
||||||
|
// mbedtls_aes_crypt_cbc использует и обновляет chain_iv_ на месте —
|
||||||
|
// это и есть состояние цепочки.
|
||||||
|
if (mbedtls_aes_crypt_cbc(&aes_enc_, MBEDTLS_AES_ENCRYPT, padded, chain_iv_,
|
||||||
|
buf, out) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return static_cast<long>(padded);
|
||||||
|
}
|
||||||
|
|
||||||
|
long DirectionCrypto::decrypt(uint8_t* out, size_t out_cap, const uint8_t* in,
|
||||||
|
size_t in_len) {
|
||||||
|
if (!initialized_) return -1;
|
||||||
|
if (mode_ == 0) mode_ = 2;
|
||||||
|
if (mode_ != 2) return -1; // направление уже в режиме шифрования
|
||||||
|
if (in_len == 0 || in_len % kAesBlockLen != 0 || in_len > out_cap) return -1;
|
||||||
|
if (mbedtls_aes_crypt_cbc(&aes_dec_, MBEDTLS_AES_DECRYPT, in_len, chain_iv_,
|
||||||
|
in, out) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// Снять завершающие NUL (по крайней мере один в Java-паддинге, но
|
||||||
|
// принимаем и строго-кратные длины без NUL).
|
||||||
|
size_t len = in_len;
|
||||||
|
while (len > 0 && out[len - 1] == 0) len--;
|
||||||
|
return static_cast<long>(len);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DirectionCrypto::sign(uint8_t out[kSha256Len], const uint8_t* data,
|
||||||
|
size_t len) const {
|
||||||
|
hmac_sha256(sign_key_, kHmacKeyLen, data, len, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SessionCrypto::init(const char* lanip_key, const char* random_1,
|
||||||
|
const char* random_2, int64_t time_1, int64_t time_2) {
|
||||||
|
return app.init(lanip_key, random_1, random_2, time_1, time_2) &&
|
||||||
|
dev.init(lanip_key, random_2, random_1, time_2, time_1);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgl::ayla
|
||||||
70
src/ayla/crypto.hpp
Normal file
70
src/ayla/crypto.hpp
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Сессионная криптография Ayla LAN (docs/PROTOCOL.md §3).
|
||||||
|
// KDF: key = HMAC-SHA256_k(HMAC-SHA256_k(msg) || msg), msg = rnd_a|rnd_b|t_a|t_b|X,
|
||||||
|
// X: 0x30 sign / 0x31 crypto / 0x32 iv(первые 16 байт).
|
||||||
|
// Направления: app (rnd1,rnd2,t1,t2) — исходящие; dev (rnd2,rnd1,t2,t1) — входящие.
|
||||||
|
// ВАЖНО: AES-CBC-цепочка непрерывна в рамках сессии (каждое сообщение
|
||||||
|
// продолжает цепочку с последнего шифроблока предыдущего).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "mbedtls/aes.h"
|
||||||
|
#include "mbedtls/md.h"
|
||||||
|
|
||||||
|
namespace fgl::ayla {
|
||||||
|
|
||||||
|
constexpr size_t kSha256Len = 32;
|
||||||
|
constexpr size_t kAesKeyLen = 32; // AES-256
|
||||||
|
constexpr size_t kAesBlockLen = 16;
|
||||||
|
constexpr size_t kHmacKeyLen = 32;
|
||||||
|
|
||||||
|
class DirectionCrypto {
|
||||||
|
public:
|
||||||
|
// Выводит ключи направления. rnd_a/rnd_b — 16-символьные токены,
|
||||||
|
// t_a/t_b — 64-битные «времена» (материал KDF, значение произвольно).
|
||||||
|
// app: (random_1, random_2, time_1, time_2); dev: (random_2, random_1, time_2, time_1).
|
||||||
|
bool init(const char* lanip_key, const char* rnd_a, const char* rnd_b,
|
||||||
|
int64_t t_a, int64_t t_b);
|
||||||
|
|
||||||
|
// Шифрует in (любая длина) с zero-паддингом (>=1 NUL, кратно 16).
|
||||||
|
// out_cap должен быть >= padded_len = ((in_len+1)/16+1)*16.
|
||||||
|
// Возвращает длину шифротекста или -1. Продолжает CBC-цепочку.
|
||||||
|
// ВАЖНО: у направления ОДНА цепочка; первый вызов фиксирует режим
|
||||||
|
// (шифрование либо расшифровка) — смешение режимов возвращает -1.
|
||||||
|
long encrypt(uint8_t* out, size_t out_cap, const uint8_t* in, size_t in_len);
|
||||||
|
|
||||||
|
// Расшифровывает in (длина кратна 16), снимает завершающие NUL.
|
||||||
|
// Возвращает длину открытого текста или -1. Продолжает CBC-цепочку
|
||||||
|
// (даже если далее подпись не сойдётся — состояние обязано двигаться).
|
||||||
|
long decrypt(uint8_t* out, size_t out_cap, const uint8_t* in, size_t in_len);
|
||||||
|
|
||||||
|
// HMAC-SHA256(sign_key, data) — подпись/проверка сообщений направления.
|
||||||
|
void sign(uint8_t out[kSha256Len], const uint8_t* data, size_t len) const;
|
||||||
|
|
||||||
|
bool initialized() const { return initialized_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
static bool derive(const uint8_t* key, size_t key_len, const uint8_t* msg,
|
||||||
|
size_t msg_len, uint8_t out[kSha256Len]);
|
||||||
|
|
||||||
|
bool initialized_ = false;
|
||||||
|
uint8_t mode_ = 0; // 0 не зафиксирован, 1 шифрование, 2 расшифровка
|
||||||
|
uint8_t sign_key_[kHmacKeyLen];
|
||||||
|
uint8_t aes_key_[kAesKeyLen];
|
||||||
|
uint8_t chain_iv_[kAesBlockLen]; // текущее состояние CBC-цепочки
|
||||||
|
mbedtls_aes_context aes_enc_;
|
||||||
|
mbedtls_aes_context aes_dec_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Пара направлений одной сессии.
|
||||||
|
struct SessionCrypto {
|
||||||
|
DirectionCrypto app; // приложение -> модуль
|
||||||
|
DirectionCrypto dev; // модуль -> приложение
|
||||||
|
|
||||||
|
// lanip_key — строка base64 КАК ЕСТЬ (utf-8 байты, не декодировать).
|
||||||
|
bool init(const char* lanip_key, const char* random_1, const char* random_2,
|
||||||
|
int64_t time_1, int64_t time_2);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace fgl::ayla
|
||||||
180
src/ayla/envelope.cpp
Normal file
180
src/ayla/envelope.cpp
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
#include "ayla/envelope.hpp"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "mbedtls/base64.h"
|
||||||
|
|
||||||
|
|
||||||
|
namespace fgl::ayla {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool b64_encode(const uint8_t* in, size_t in_len, char* out, size_t out_cap) {
|
||||||
|
size_t olen = 0;
|
||||||
|
if (mbedtls_base64_encode(reinterpret_cast<unsigned char*>(out), out_cap,
|
||||||
|
&olen, in, in_len) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true; // mbedtls пишет NUL-терминатор
|
||||||
|
}
|
||||||
|
|
||||||
|
bool b64_decode(const char* in, uint8_t* out, size_t out_cap, size_t* out_len) {
|
||||||
|
size_t olen = 0;
|
||||||
|
if (mbedtls_base64_decode(out, out_cap, &olen,
|
||||||
|
reinterpret_cast<const unsigned char*>(in),
|
||||||
|
strlen(in)) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*out_len = olen;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекает seq_no верхнего уровня из '{"seq_no":N,"data":...}'.
|
||||||
|
// Ручной depth-1 сканер (без ограничений на размер data; вход уже
|
||||||
|
// проверен подписью). Понимает пробелы между лексемами.
|
||||||
|
bool extract_seq_no(const char* json, int64_t* seq_no) {
|
||||||
|
const char* p = json;
|
||||||
|
while (*p == ' ' || *p == '\t') p++;
|
||||||
|
if (*p != '{') return false;
|
||||||
|
p++;
|
||||||
|
for (;;) {
|
||||||
|
while (*p == ' ' || *p == ',' || *p == '\t') p++;
|
||||||
|
if (*p == '}' || *p == '\0') return false;
|
||||||
|
// ключ
|
||||||
|
if (*p != '"') return false;
|
||||||
|
const char* k = ++p;
|
||||||
|
while (*p != '"' && *p != '\0') p++;
|
||||||
|
if (*p != '"') return false;
|
||||||
|
size_t klen = static_cast<size_t>(p - k);
|
||||||
|
p++;
|
||||||
|
while (*p == ' ' || *p == '\t') p++;
|
||||||
|
if (*p != ':') return false;
|
||||||
|
p++;
|
||||||
|
while (*p == ' ' || *p == '\t') p++;
|
||||||
|
if (klen == 6 && strncmp(k, "seq_no", 6) == 0) {
|
||||||
|
if (*p == '-' || (*p >= '0' && *p <= '9')) {
|
||||||
|
*seq_no = strtoll(p, nullptr, 10);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// пропустить значение другого ключа
|
||||||
|
if (*p == '{' || *p == '[') {
|
||||||
|
int depth = 0;
|
||||||
|
bool in_str = false;
|
||||||
|
for (; *p != '\0'; p++) {
|
||||||
|
if (in_str) {
|
||||||
|
if (*p == '\\') {
|
||||||
|
p++;
|
||||||
|
if (*p == '\0') return false; // обрыв после escape
|
||||||
|
} else if (*p == '"') {
|
||||||
|
in_str = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (*p == '"') {
|
||||||
|
in_str = true;
|
||||||
|
} else if (*p == '{' || *p == '[') {
|
||||||
|
depth++;
|
||||||
|
} else if (*p == '}' || *p == ']') {
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) {
|
||||||
|
p++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (*p == '\0') return false;
|
||||||
|
} else if (*p == '"') {
|
||||||
|
p++;
|
||||||
|
while (*p != '"' && *p != '\0') {
|
||||||
|
if (*p == '\\') {
|
||||||
|
p++;
|
||||||
|
if (*p == '\0') return false; // обрыв после escape
|
||||||
|
}
|
||||||
|
p++;
|
||||||
|
}
|
||||||
|
if (*p != '"') return false;
|
||||||
|
p++;
|
||||||
|
} else {
|
||||||
|
while (*p != ',' && *p != '}' && *p != '\0') p++;
|
||||||
|
if (*p == '\0') return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сравнение за константное время (аналог mbedtls_ct_memcmp, без зависимости
|
||||||
|
// от версии библиотеки).
|
||||||
|
int ct_memcmp(const uint8_t* a, const uint8_t* b, size_t n) {
|
||||||
|
uint8_t diff = 0;
|
||||||
|
for (size_t i = 0; i < n; i++) diff |= static_cast<uint8_t>(a[i] ^ b[i]);
|
||||||
|
return diff != 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool envelope_pack(DirectionCrypto& dir, int64_t seq_no,
|
||||||
|
const char* data_json, char* out, size_t out_cap) {
|
||||||
|
if (data_json == nullptr || out == nullptr) return false;
|
||||||
|
|
||||||
|
// Открытый текст: {"seq_no":N,"data":<data_json>}
|
||||||
|
char plain[kEnvelopeMaxPlain];
|
||||||
|
int n = snprintf(plain, sizeof(plain), "{\"seq_no\":%lld,\"data\":%s}",
|
||||||
|
static_cast<long long>(seq_no), data_json);
|
||||||
|
if (n <= 0 || static_cast<size_t>(n) >= sizeof(plain)) return false;
|
||||||
|
size_t plain_len = static_cast<size_t>(n);
|
||||||
|
|
||||||
|
// Подпись — по байтам открытого текста без паддинга.
|
||||||
|
uint8_t mac[kSha256Len];
|
||||||
|
dir.sign(mac, reinterpret_cast<const uint8_t*>(plain), plain_len);
|
||||||
|
char sign_b64[64];
|
||||||
|
if (!b64_encode(mac, sizeof(mac), sign_b64, sizeof(sign_b64))) return false;
|
||||||
|
|
||||||
|
// Шифрование с zero-паддингом (>=1 NUL, кратно 16).
|
||||||
|
uint8_t ct[kEnvelopeMaxPlain + kAesBlockLen];
|
||||||
|
long ct_len = dir.encrypt(ct, sizeof(ct),
|
||||||
|
reinterpret_cast<const uint8_t*>(plain), plain_len);
|
||||||
|
if (ct_len < 0) return false;
|
||||||
|
char enc_b64[kEnvelopeMaxB64];
|
||||||
|
if (!b64_encode(ct, static_cast<size_t>(ct_len), enc_b64, sizeof(enc_b64))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int m = snprintf(out, out_cap, "{\"enc\":\"%s\",\"sign\":\"%s\"}", enc_b64,
|
||||||
|
sign_b64);
|
||||||
|
return m > 0 && static_cast<size_t>(m) < out_cap;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool envelope_unpack(DirectionCrypto& dir, const char* enc_b64,
|
||||||
|
const char* sign_b64, char* json_out, size_t out_cap,
|
||||||
|
int64_t* seq_no) {
|
||||||
|
if (enc_b64 == nullptr || sign_b64 == nullptr || json_out == nullptr) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint8_t ct[kEnvelopeMaxPlain + kAesBlockLen];
|
||||||
|
size_t ct_len = 0;
|
||||||
|
if (!b64_decode(enc_b64, ct, sizeof(ct), &ct_len)) return false;
|
||||||
|
|
||||||
|
// Расшифровка: CBC-цепочка двигается независимо от исхода проверки подписи.
|
||||||
|
uint8_t plain[kEnvelopeMaxPlain + kAesBlockLen];
|
||||||
|
long plain_len = dir.decrypt(plain, sizeof(plain), ct, ct_len);
|
||||||
|
if (plain_len < 0 || static_cast<size_t>(plain_len) >= out_cap) return false;
|
||||||
|
|
||||||
|
// Проверка подписи по расшифрованным байтам (без паддинга);
|
||||||
|
// сравнение декодированных байт — константное время.
|
||||||
|
uint8_t mac[kSha256Len];
|
||||||
|
dir.sign(mac, plain, static_cast<size_t>(plain_len));
|
||||||
|
uint8_t given[kSha256Len];
|
||||||
|
size_t given_len = 0;
|
||||||
|
if (!b64_decode(sign_b64, given, sizeof(given), &given_len)) return false;
|
||||||
|
if (given_len != kSha256Len) return false;
|
||||||
|
if (ct_memcmp(mac, given, kSha256Len) != 0) return false;
|
||||||
|
|
||||||
|
memcpy(json_out, plain, static_cast<size_t>(plain_len));
|
||||||
|
json_out[plain_len] = '\0';
|
||||||
|
return extract_seq_no(json_out, seq_no);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgl::ayla
|
||||||
31
src/ayla/envelope.hpp
Normal file
31
src/ayla/envelope.hpp
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Защищённый конверт Ayla LAN: {"enc":"<b64 AES-CBC>","sign":"<b64 HMAC>"},
|
||||||
|
// открытый текст: {"seq_no":N,"data":<json-объект>} (docs/PROTOCOL.md §3.3).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "ayla/crypto.hpp"
|
||||||
|
|
||||||
|
namespace fgl::ayla {
|
||||||
|
|
||||||
|
constexpr size_t kEnvelopeMaxPlain = 1024; // открытый текст конверта
|
||||||
|
constexpr size_t kEnvelopeMaxB64 = 1800; // base64 шифротекста с запасом
|
||||||
|
|
||||||
|
// Собирает открытый текст '{"seq_no":<seq>,"data":<data_json>}' и упаковывает
|
||||||
|
// в конверт (envelope). dir — направление ОТПРАВИТЕЛЯ (app); состояние
|
||||||
|
// CBC-цепочки направления ДВИГАЕТСЯ. out — строка NUL-terminated.
|
||||||
|
// data_json — готовый JSON-объект (обычно построен json::Writer).
|
||||||
|
bool envelope_pack(DirectionCrypto& dir, int64_t seq_no,
|
||||||
|
const char* data_json, char* out, size_t out_cap);
|
||||||
|
|
||||||
|
// Распаковывает конверт (enc_b64/sign_b64 — NUL-terminated) направлением dev:
|
||||||
|
// расшифровывает (цепочка движется даже при последующей ошибке подписи!),
|
||||||
|
// проверяет подпись. При успехе json_out содержит ОТКРЫТЫЙ ТЕКСТ
|
||||||
|
// ('{"seq_no":..,"data":..}') NUL-terminated, seq_no записан в *seq_no.
|
||||||
|
// Возвращает false при ошибке base64/расшифровки/подписи.
|
||||||
|
bool envelope_unpack(DirectionCrypto& dir, const char* enc_b64,
|
||||||
|
const char* sign_b64, char* json_out, size_t out_cap,
|
||||||
|
int64_t* seq_no);
|
||||||
|
|
||||||
|
} // namespace fgl::ayla
|
||||||
128
src/ayla/httpc.cpp
Normal file
128
src/ayla/httpc.cpp
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
#include "ayla/httpc.hpp"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "ayla/log.hpp"
|
||||||
|
#include "ayla/platform/platform.hpp"
|
||||||
|
|
||||||
|
namespace fgl::ayla {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Читает статус-строку "HTTP/1.x NNN ..."; возвращает код или -1.
|
||||||
|
int read_status(int fd) {
|
||||||
|
char line[64];
|
||||||
|
size_t len = 0;
|
||||||
|
for (;;) {
|
||||||
|
if (len + 1 >= sizeof(line)) return -1;
|
||||||
|
uint8_t ch;
|
||||||
|
long n = fgl::plat::tcp_recv(fd, &ch, 1);
|
||||||
|
if (n <= 0) return -1;
|
||||||
|
if (ch == '\n') break;
|
||||||
|
line[len++] = static_cast<char>(ch);
|
||||||
|
}
|
||||||
|
// "HTTP/1.1 202 Accepted"
|
||||||
|
if (len < 12 || strncmp(line, "HTTP/", 5) != 0) return -1;
|
||||||
|
const char* sp = strchr(line, ' ');
|
||||||
|
if (sp == nullptr) return -1;
|
||||||
|
int code = atoi(sp + 1);
|
||||||
|
return (code >= 200 && code <= 599) ? code : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дочитывает заголовки до пустой строки и тело по Content-Length (до лимита).
|
||||||
|
bool drain_response(int fd) {
|
||||||
|
char line[256];
|
||||||
|
long content_length = 0;
|
||||||
|
for (;;) {
|
||||||
|
size_t i = 0;
|
||||||
|
for (;;) {
|
||||||
|
if (i + 1 >= sizeof(line)) return false;
|
||||||
|
uint8_t ch;
|
||||||
|
long n = fgl::plat::tcp_recv(fd, &ch, 1);
|
||||||
|
if (n <= 0) return false;
|
||||||
|
if (ch == '\n') break;
|
||||||
|
line[i++] = static_cast<char>(ch);
|
||||||
|
}
|
||||||
|
if (i > 0 && line[i - 1] == '\r') i--;
|
||||||
|
line[i] = '\0';
|
||||||
|
if (i == 0) break; // конец заголовков
|
||||||
|
if ((line[0] | 0x20) == 'c' && strncasecmp(line, "Content-Length:", 15) == 0) {
|
||||||
|
content_length = strtol(line + 15, nullptr, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (content_length > 0) {
|
||||||
|
uint8_t sink[256];
|
||||||
|
long remaining = content_length;
|
||||||
|
if (remaining > 4096) remaining = 4096; // тела local_reg нет — ограничим
|
||||||
|
while (remaining > 0) {
|
||||||
|
size_t chunk = remaining < static_cast<long>(sizeof(sink))
|
||||||
|
? static_cast<size_t>(remaining)
|
||||||
|
: sizeof(sink);
|
||||||
|
long n = fgl::plat::tcp_recv(fd, sink, chunk);
|
||||||
|
if (n <= 0) break;
|
||||||
|
remaining -= n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool httpc_perform(const HttpcRequest& req, HttpcResponse* resp) {
|
||||||
|
if (resp == nullptr || req.host == nullptr) return false;
|
||||||
|
resp->status = 0;
|
||||||
|
resp->transport_ok = false;
|
||||||
|
|
||||||
|
int fd = fgl::plat::tcp_connect(req.host, req.port, req.timeout_ms);
|
||||||
|
if (fd < 0) {
|
||||||
|
FGL_LOGD("httpc: connect %s:%u failed", req.host,
|
||||||
|
static_cast<unsigned>(req.port));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fgl::plat::tcp_set_timeout(fd, req.timeout_ms, req.timeout_ms);
|
||||||
|
|
||||||
|
char head[256];
|
||||||
|
int n = snprintf(head, sizeof(head),
|
||||||
|
"%s %s%s%s HTTP/1.1\r\n"
|
||||||
|
"Host: %s\r\n"
|
||||||
|
"Accept: application/json\r\n"
|
||||||
|
"Connection: close\r\n"
|
||||||
|
"Content-Type: %s\r\n"
|
||||||
|
"Content-Length: %u\r\n"
|
||||||
|
"\r\n",
|
||||||
|
req.method, req.path, req.query != nullptr ? "?" : "",
|
||||||
|
req.query != nullptr ? req.query : "", req.host,
|
||||||
|
req.content_type, static_cast<unsigned>(req.body_len));
|
||||||
|
if (n <= 0 || static_cast<size_t>(n) >= sizeof(head)) {
|
||||||
|
fgl::plat::tcp_close(fd);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool sent = fgl::plat::tcp_send(fd, head, static_cast<size_t>(n)) == n;
|
||||||
|
if (sent && req.body != nullptr && req.body_len > 0) {
|
||||||
|
sent = fgl::plat::tcp_send(fd, req.body, req.body_len) ==
|
||||||
|
static_cast<long>(req.body_len);
|
||||||
|
}
|
||||||
|
if (!sent) {
|
||||||
|
FGL_LOGD("httpc: send failed");
|
||||||
|
fgl::plat::tcp_close(fd);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int status = read_status(fd);
|
||||||
|
if (status < 0) {
|
||||||
|
fgl::plat::tcp_close(fd);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
drain_response(fd);
|
||||||
|
fgl::plat::tcp_shutdown(fd);
|
||||||
|
fgl::plat::tcp_close(fd);
|
||||||
|
|
||||||
|
resp->status = status;
|
||||||
|
resp->transport_ok = true;
|
||||||
|
FGL_LOGD("httpc: %s %s -> %d", req.method, req.path, status);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgl::ayla
|
||||||
32
src/ayla/httpc.hpp
Normal file
32
src/ayla/httpc.hpp
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Минимальный HTTP-клиент для исходящих запросов к модулю (local_reg).
|
||||||
|
// Один запрос — одно соединение; достаточно для keep-alive-цикла протокола
|
||||||
|
// (проверено на приборе: модуль принимает local_reg на новых соединениях).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace fgl::ayla {
|
||||||
|
|
||||||
|
struct HttpcRequest {
|
||||||
|
const char* method = "PUT"; // "POST" | "PUT"
|
||||||
|
const char* host = nullptr; // dotted-quad или DNS-имя
|
||||||
|
uint16_t port = 80;
|
||||||
|
const char* path = "/local_reg.json";
|
||||||
|
const char* query = nullptr; // без '?', может быть nullptr
|
||||||
|
const char* content_type = "application/json";
|
||||||
|
const uint8_t* body = nullptr;
|
||||||
|
size_t body_len = 0;
|
||||||
|
uint32_t timeout_ms = 10000;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HttpcResponse {
|
||||||
|
int status = 0; // HTTP-код или 0 при ошибке транспорта
|
||||||
|
bool transport_ok = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Выполняет запрос блокирующе. Тело ответа не читается (у local_reg оно
|
||||||
|
// пусто), но соединение закрывается корректно (shutdown перед close).
|
||||||
|
bool httpc_perform(const HttpcRequest& req, HttpcResponse* resp);
|
||||||
|
|
||||||
|
} // namespace fgl::ayla
|
||||||
285
src/ayla/httpd.cpp
Normal file
285
src/ayla/httpd.cpp
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
#include "ayla/httpd.hpp"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <new>
|
||||||
|
|
||||||
|
#include "ayla/log.hpp"
|
||||||
|
|
||||||
|
namespace fgl::ayla {
|
||||||
|
|
||||||
|
struct ThreadCtx {
|
||||||
|
HttpServer* self;
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Статусные строки для используемых кодов.
|
||||||
|
struct StatusText { int code; const char* text; };
|
||||||
|
constexpr StatusText kStatusTexts[] = {
|
||||||
|
{200, "OK"}, {206, "Partial Content"}, {400, "Bad Request"},
|
||||||
|
{401, "Unauthorized"}, {404, "Not Found"}, {412, "Precondition Failed"},
|
||||||
|
{426, "Upgrade Required"}, {500, "Internal Server Error"},
|
||||||
|
};
|
||||||
|
|
||||||
|
const char* status_text(int code) {
|
||||||
|
for (const auto& st : kStatusTexts) {
|
||||||
|
if (st.code == code) return st.text;
|
||||||
|
}
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool recv_line(int fd, char* buf, size_t buflen, size_t* out_len) {
|
||||||
|
size_t len = 0;
|
||||||
|
for (;;) {
|
||||||
|
if (len + 1 >= buflen) return false; // слишком длинно
|
||||||
|
uint8_t ch;
|
||||||
|
long n = fgl::plat::tcp_recv(fd, &ch, 1);
|
||||||
|
if (n <= 0) return false;
|
||||||
|
if (ch == '\n') {
|
||||||
|
if (len > 0 && buf[len - 1] == '\r') len--; // CRLF
|
||||||
|
buf[len] = '\0';
|
||||||
|
*out_len = len;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
buf[len++] = static_cast<char>(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регистронезависимое сравнение первых n символов.
|
||||||
|
bool ieq_prefix(const char* a, const char* b, size_t n) {
|
||||||
|
for (size_t i = 0; i < n; i++) {
|
||||||
|
char ca = a[i] | 0x20, cb = b[i] | 0x20;
|
||||||
|
if (ca != cb) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читает заголовки до пустой строки; суммарный объём ограничен
|
||||||
|
// kHttpdMaxHeadersTotal (защита от бесконечного стрима заголовков).
|
||||||
|
bool recv_headers(int fd, long* content_length, bool* connection_close) {
|
||||||
|
char line[kHttpdMaxHeaderLine];
|
||||||
|
size_t len;
|
||||||
|
size_t total = 2; // завершающий CRLF
|
||||||
|
*content_length = 0;
|
||||||
|
*connection_close = false;
|
||||||
|
for (;;) {
|
||||||
|
if (!recv_line(fd, line, sizeof(line), &len)) return false;
|
||||||
|
if (len == 0) return true; // конец заголовков
|
||||||
|
total += len + 2;
|
||||||
|
if (total > kHttpdMaxHeadersTotal) return false;
|
||||||
|
if (ieq_prefix(line, "Content-Length:", 15)) {
|
||||||
|
*content_length = strtol(line + 15, nullptr, 10);
|
||||||
|
} else if (ieq_prefix(line, "Connection:", 11)) {
|
||||||
|
const char* v = line + 11;
|
||||||
|
while (*v == ' ') v++;
|
||||||
|
if (ieq_prefix(v, "close", 5)) *connection_close = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// close_after — добавить Connection: close и не ждать продолжения.
|
||||||
|
bool send_response(int fd, const HttpResponse& resp, bool close_after) {
|
||||||
|
char head[192];
|
||||||
|
int n = snprintf(head, sizeof(head),
|
||||||
|
"HTTP/1.1 %d %s\r\n"
|
||||||
|
"Content-Type: %s\r\n"
|
||||||
|
"Content-Length: %u\r\n"
|
||||||
|
"Connection: %s\r\n"
|
||||||
|
"\r\n",
|
||||||
|
resp.status, status_text(resp.status), resp.content_type,
|
||||||
|
static_cast<unsigned>(resp.body_len),
|
||||||
|
close_after ? "close" : "keep-alive");
|
||||||
|
if (n <= 0 || static_cast<size_t>(n) >= sizeof(head)) return false;
|
||||||
|
if (fgl::plat::tcp_send(fd, head, static_cast<size_t>(n)) !=
|
||||||
|
static_cast<long>(n)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (resp.body != nullptr && resp.body_len > 0) {
|
||||||
|
if (fgl::plat::tcp_send(fd, resp.body, resp.body_len) !=
|
||||||
|
static_cast<long>(resp.body_len)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void parse_target(const char* full_target, HttpRequest* req) {
|
||||||
|
const char* q = strchr(full_target, '?');
|
||||||
|
size_t path_len = (q != nullptr) ? static_cast<size_t>(q - full_target)
|
||||||
|
: strlen(full_target);
|
||||||
|
if (path_len >= sizeof(req->target)) path_len = sizeof(req->target) - 1;
|
||||||
|
memcpy(req->target, full_target, path_len);
|
||||||
|
req->target[path_len] = '\0';
|
||||||
|
req->query[0] = '\0';
|
||||||
|
if (q != nullptr && q[1] != '\0') {
|
||||||
|
size_t qlen = strlen(q + 1);
|
||||||
|
if (qlen >= sizeof(req->query)) qlen = sizeof(req->query) - 1;
|
||||||
|
memcpy(req->query, q + 1, qlen);
|
||||||
|
req->query[qlen] = '\0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr uint32_t kHttpdThreadStack = 24576; // commands-путь: envelope+crypto ~10КБ поверх буферов запроса
|
||||||
|
constexpr uint32_t kAcceptPollMs = 100;
|
||||||
|
constexpr uint32_t kClientRxTimeoutMs = 30000;
|
||||||
|
constexpr uint32_t kClientTxTimeoutMs = 10000;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
HttpServer::~HttpServer() { stop(); }
|
||||||
|
|
||||||
|
bool HttpServer::start(uint16_t port, HttpHandler handler, void* ctx,
|
||||||
|
const char* thread_name) {
|
||||||
|
if (running_.load(std::memory_order_acquire)) return false;
|
||||||
|
handler_ = handler;
|
||||||
|
ctx_ = ctx;
|
||||||
|
int fd = fgl::plat::tcp_listen(port);
|
||||||
|
if (fd < 0) {
|
||||||
|
FGL_LOGE("httpd: tcp_listen(%u) failed", static_cast<unsigned>(port));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
listen_fd_ = fd;
|
||||||
|
port_ = fgl::plat::tcp_local_port(fd);
|
||||||
|
|
||||||
|
auto* tc = new (std::nothrow) ThreadCtx{this};
|
||||||
|
if (tc == nullptr) {
|
||||||
|
fgl::plat::tcp_close(listen_fd_);
|
||||||
|
listen_fd_ = -1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
thread_ctx_ = tc;
|
||||||
|
running_.store(true, std::memory_order_release);
|
||||||
|
if (!fgl::plat::thread_create(
|
||||||
|
[](void* p) {
|
||||||
|
static_cast<ThreadCtx*>(p)->self->run();
|
||||||
|
},
|
||||||
|
tc, thread_name, kHttpdThreadStack, &thread_)) {
|
||||||
|
running_.store(false, std::memory_order_release);
|
||||||
|
delete tc;
|
||||||
|
thread_ctx_ = nullptr;
|
||||||
|
fgl::plat::tcp_close(listen_fd_);
|
||||||
|
listen_fd_ = -1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
FGL_LOGI("httpd: listening on port %u", static_cast<unsigned>(port_));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HttpServer::stop() {
|
||||||
|
if (!running_.exchange(false, std::memory_order_acq_rel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Прерываем активное соединение, чтобы поток не ждал rx-таймаута.
|
||||||
|
int active = active_fd_.exchange(-1, std::memory_order_acq_rel);
|
||||||
|
if (active >= 0) {
|
||||||
|
fgl::plat::tcp_shutdown(active);
|
||||||
|
}
|
||||||
|
// Гарантированно ждём завершения потока; poll-цикл замечает running_
|
||||||
|
// в пределах kAcceptPollMs.
|
||||||
|
if (thread_ != nullptr) {
|
||||||
|
fgl::plat::thread_join(thread_);
|
||||||
|
thread_ = nullptr;
|
||||||
|
}
|
||||||
|
delete thread_ctx_;
|
||||||
|
thread_ctx_ = nullptr;
|
||||||
|
if (listen_fd_ >= 0) {
|
||||||
|
fgl::plat::tcp_close(listen_fd_);
|
||||||
|
listen_fd_ = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HttpServer::run() {
|
||||||
|
while (running_.load(std::memory_order_acquire)) {
|
||||||
|
if (!fgl::plat::tcp_poll_readable(listen_fd_, kAcceptPollMs)) {
|
||||||
|
continue; // таймаут поллинга — перепроверяем running_
|
||||||
|
}
|
||||||
|
uint32_t peer_ip = 0;
|
||||||
|
uint16_t peer_port = 0;
|
||||||
|
int fd = fgl::plat::tcp_accept(listen_fd_, &peer_ip, &peer_port);
|
||||||
|
if (fd < 0) {
|
||||||
|
if (!running_.load(std::memory_order_acquire)) break;
|
||||||
|
fgl::plat::sleep_ms(20);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fgl::plat::tcp_set_timeout(fd, kClientRxTimeoutMs, kClientTxTimeoutMs);
|
||||||
|
active_fd_.store(fd, std::memory_order_release);
|
||||||
|
if (!running_.load(std::memory_order_acquire)) {
|
||||||
|
// stop() мог произойти в зазоре accept->store: закрываем и выходим.
|
||||||
|
// (Порядок store(fd)->load(running_) против exchange-ов в stop()
|
||||||
|
// закрывает все интерливинги: либо stop() увидит fd и shutdown'нет.)
|
||||||
|
active_fd_.store(-1, std::memory_order_release);
|
||||||
|
fgl::plat::tcp_close(fd);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
handle_connection(fd, peer_ip, peer_port);
|
||||||
|
active_fd_.store(-1, std::memory_order_release);
|
||||||
|
fgl::plat::tcp_close(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HttpServer::handle_connection(int fd, uint32_t peer_ip, uint16_t peer_port) {
|
||||||
|
while (running_.load(std::memory_order_acquire)) { // keep-alive
|
||||||
|
char line[kHttpdMaxTarget + kHttpdMaxQuery + 16];
|
||||||
|
size_t len;
|
||||||
|
if (!recv_line(fd, line, sizeof(line), &len)) return;
|
||||||
|
|
||||||
|
HttpRequest req {};
|
||||||
|
req.peer_ip = peer_ip;
|
||||||
|
req.peer_port = peer_port;
|
||||||
|
|
||||||
|
// "<METHOD> <target> HTTP/x.y"
|
||||||
|
char* sp1 = strchr(line, ' ');
|
||||||
|
if (sp1 == nullptr) return;
|
||||||
|
*sp1 = '\0';
|
||||||
|
if (strlen(line) >= sizeof(req.method)) return;
|
||||||
|
strcpy(req.method, line);
|
||||||
|
char* target = sp1 + 1;
|
||||||
|
char* sp2 = strrchr(target, ' ');
|
||||||
|
if (sp2 == nullptr) return;
|
||||||
|
*sp2 = '\0';
|
||||||
|
parse_target(target, &req);
|
||||||
|
|
||||||
|
long content_length = 0;
|
||||||
|
bool connection_close = false;
|
||||||
|
if (!recv_headers(fd, &content_length, &connection_close)) return;
|
||||||
|
if (content_length < 0 ||
|
||||||
|
static_cast<size_t>(content_length) > kHttpdMaxBody) {
|
||||||
|
HttpResponse resp;
|
||||||
|
resp.status = 400;
|
||||||
|
send_response(fd, resp, /*close_after=*/true);
|
||||||
|
// shutdown до close предотвращает RST, обгоняющий ответ
|
||||||
|
// (recv после SHUT_RDWR сразу вернёт 0, дренаж не нужен).
|
||||||
|
fgl::plat::tcp_shutdown(fd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (content_length > 0) {
|
||||||
|
size_t need = static_cast<size_t>(content_length);
|
||||||
|
size_t done = 0;
|
||||||
|
while (done < need) {
|
||||||
|
long n = fgl::plat::tcp_recv(fd, req.body + done, need - done);
|
||||||
|
if (n <= 0) return;
|
||||||
|
done += static_cast<size_t>(n);
|
||||||
|
}
|
||||||
|
req.body_len = done;
|
||||||
|
}
|
||||||
|
|
||||||
|
FGL_LOGD("httpd: %s %s?%s from %u.%u.%u.%u", req.method, req.target,
|
||||||
|
req.query, static_cast<unsigned>((peer_ip >> 24) & 0xffu),
|
||||||
|
static_cast<unsigned>((peer_ip >> 16) & 0xffu),
|
||||||
|
static_cast<unsigned>((peer_ip >> 8) & 0xffu),
|
||||||
|
static_cast<unsigned>(peer_ip & 0xffu));
|
||||||
|
|
||||||
|
HttpResponse resp; // по умолчанию 404 — «пустой httpd»
|
||||||
|
resp.status = 404;
|
||||||
|
bool keep = true;
|
||||||
|
if (handler_ != nullptr) {
|
||||||
|
keep = handler_(req, resp, ctx_);
|
||||||
|
}
|
||||||
|
if (!send_response(fd, resp, /*close_after=*/!keep)) return;
|
||||||
|
if (!keep || connection_close) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgl::ayla
|
||||||
78
src/ayla/httpd.hpp
Normal file
78
src/ayla/httpd.hpp
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// Мини-HTTP/1.1 сервер для входящих запросов модуля кондиционера.
|
||||||
|
// Один поток, последовательная обработка соединений, keep-alive.
|
||||||
|
// Поведенческие требования — docs/PROTOCOL.md §4-5: пути /local_lan/*,
|
||||||
|
// коды 200/206/401/412/426, Content-Length-фрейминг, RST-обрывы — норма.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "ayla/platform/platform.hpp"
|
||||||
|
|
||||||
|
namespace fgl::ayla {
|
||||||
|
|
||||||
|
constexpr size_t kHttpdMaxTarget = 128; // путь без query
|
||||||
|
constexpr size_t kHttpdMaxQuery = 96; // query-строка без '?'
|
||||||
|
constexpr size_t kHttpdMaxHeaderLine = 256; // одна строка заголовка
|
||||||
|
constexpr size_t kHttpdMaxBody = 2048; // тело запроса (envelope ~0.5КБ)
|
||||||
|
constexpr size_t kHttpdMaxHeadersTotal = 1024; // суммарный лимит заголовков
|
||||||
|
|
||||||
|
struct HttpRequest {
|
||||||
|
char method[8];
|
||||||
|
char target[kHttpdMaxTarget]; // путь без query
|
||||||
|
char query[kHttpdMaxQuery]; // query-строка без '?'
|
||||||
|
uint8_t body[kHttpdMaxBody];
|
||||||
|
size_t body_len;
|
||||||
|
uint32_t peer_ip; // host byte order
|
||||||
|
uint16_t peer_port;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HttpResponse {
|
||||||
|
int status = 200; // HTTP-код
|
||||||
|
const char* content_type = "application/json";
|
||||||
|
const uint8_t* body = nullptr;
|
||||||
|
size_t body_len = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик: заполняет resp. Возвращает true — соединение продолжается
|
||||||
|
// (keep-alive), false — закрыть после ответа.
|
||||||
|
using HttpHandler = bool (*)(const HttpRequest& req, HttpResponse& resp,
|
||||||
|
void* ctx);
|
||||||
|
|
||||||
|
class HttpServer {
|
||||||
|
public:
|
||||||
|
HttpServer() = default;
|
||||||
|
~HttpServer();
|
||||||
|
|
||||||
|
HttpServer(const HttpServer&) = delete;
|
||||||
|
HttpServer& operator=(const HttpServer&) = delete;
|
||||||
|
|
||||||
|
// Запускает сервер (собственный поток). port==0 — любой свободный
|
||||||
|
// (узнать выбранный: port()).
|
||||||
|
// Возвращает false при ошибке bind/listen.
|
||||||
|
bool start(uint16_t port, HttpHandler handler, void* ctx,
|
||||||
|
const char* thread_name = "fgl_httpd");
|
||||||
|
// Останавливает сервер и гарантированно завершает его поток
|
||||||
|
// (активное соединение прерывается shutdown'ом).
|
||||||
|
// КОНТРАКТ: start()/stop() вызываются из одного (управляющего) потока.
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
uint16_t port() const { return port_; }
|
||||||
|
bool is_running() const { return running_.load(std::memory_order_acquire); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void run();
|
||||||
|
void handle_connection(int fd, uint32_t peer_ip, uint16_t peer_port);
|
||||||
|
|
||||||
|
HttpHandler handler_ = nullptr;
|
||||||
|
void* ctx_ = nullptr;
|
||||||
|
int listen_fd_ = -1;
|
||||||
|
uint16_t port_ = 0;
|
||||||
|
fgl::plat::ThreadId thread_ = nullptr; // владелец-поток, join в stop()
|
||||||
|
struct ThreadCtx* thread_ctx_ = nullptr;
|
||||||
|
std::atomic<bool> running_{false};
|
||||||
|
std::atomic<int> active_fd_{-1}; // fd обрабатываемого соединения
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace fgl::ayla
|
||||||
253
src/ayla/json.hpp
Normal file
253
src/ayla/json.hpp
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
// Минимальный JSON: writer с фиксированным буфером + парсер (jsmn).
|
||||||
|
// Никаких аллокаций; экранирование строк — достаточное для протокола.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#define JSMN_STATIC
|
||||||
|
#include "third_party/jsmn/jsmn.h"
|
||||||
|
|
||||||
|
namespace fgl::ayla::json {
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Writer. Стек глубин <= 8; состояние контейнеров (были ли элементы).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class Writer {
|
||||||
|
public:
|
||||||
|
Writer(char* buf, size_t cap) : buf_(buf), cap_(cap) {
|
||||||
|
buf_[0] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
void begin_object() { value_comma(); put('{'); push('{'); }
|
||||||
|
void end_object() { pop(); put('}'); }
|
||||||
|
void begin_array() { value_comma(); put('['); push('['); }
|
||||||
|
void end_array() { pop(); put(']'); }
|
||||||
|
|
||||||
|
// Ключ объекта; за ним должно следовать значение.
|
||||||
|
void key(const char* name) {
|
||||||
|
if (items_[depth_]) put(',');
|
||||||
|
items_[depth_] = true;
|
||||||
|
put('"');
|
||||||
|
str_escape(name);
|
||||||
|
put_raw("\":", 2);
|
||||||
|
await_value_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void string(const char* v) {
|
||||||
|
value_comma();
|
||||||
|
put('"');
|
||||||
|
str_escape(v);
|
||||||
|
put('"');
|
||||||
|
}
|
||||||
|
void integer(int64_t v) {
|
||||||
|
value_comma();
|
||||||
|
char tmp[24];
|
||||||
|
int n = snprintf(tmp, sizeof(tmp), "%lld", static_cast<long long>(v));
|
||||||
|
put_raw(tmp, static_cast<size_t>(n));
|
||||||
|
}
|
||||||
|
void boolean(bool v) { value_comma(); put_raw(v ? "true" : "false", v ? 4 : 5); }
|
||||||
|
void literal(const char* v) { value_comma(); put_raw(v, strlen(v)); }
|
||||||
|
void raw_json(const char* v) { value_comma(); put_raw(v, strlen(v)); }
|
||||||
|
|
||||||
|
const char* c_str() const { return buf_; }
|
||||||
|
size_t size() const { return pos_; }
|
||||||
|
bool ok() const { return ok_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr int kMaxDepth = 8;
|
||||||
|
|
||||||
|
// Запятая/маркер перед ЗНАЧЕНИЕМ (не ключом, не закрытием).
|
||||||
|
void value_comma() {
|
||||||
|
if (await_value_) {
|
||||||
|
await_value_ = false;
|
||||||
|
return; // ключ уже поставил ':' — запятая не нужна
|
||||||
|
}
|
||||||
|
if (items_[depth_]) put(',');
|
||||||
|
items_[depth_] = true;
|
||||||
|
}
|
||||||
|
void push(char c) {
|
||||||
|
if (depth_ + 1 >= kMaxDepth) {
|
||||||
|
ok_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
depth_++;
|
||||||
|
items_[depth_] = false;
|
||||||
|
(void)c;
|
||||||
|
}
|
||||||
|
void pop() {
|
||||||
|
if (depth_ > 0) depth_--;
|
||||||
|
await_value_ = false;
|
||||||
|
}
|
||||||
|
void put(char c) {
|
||||||
|
if (!ok_ || pos_ + 1 >= cap_) {
|
||||||
|
ok_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buf_[pos_++] = c;
|
||||||
|
buf_[pos_] = '\0';
|
||||||
|
}
|
||||||
|
void put_raw(const char* s, size_t n) {
|
||||||
|
if (!ok_ || pos_ + n + 1 > cap_) {
|
||||||
|
ok_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memcpy(buf_ + pos_, s, n);
|
||||||
|
pos_ += n;
|
||||||
|
buf_[pos_] = '\0';
|
||||||
|
}
|
||||||
|
void str_escape(const char* s) {
|
||||||
|
for (const char* p = s; *p != '\0'; p++) {
|
||||||
|
char c = *p;
|
||||||
|
if (c == '"' || c == '\\') {
|
||||||
|
put('\\');
|
||||||
|
put(c);
|
||||||
|
} else if (c == '\n') {
|
||||||
|
put_raw("\\n", 2);
|
||||||
|
} else if (c == '\t') {
|
||||||
|
put_raw("\\t", 2);
|
||||||
|
} else if (c == '\r') {
|
||||||
|
put_raw("\\r", 2);
|
||||||
|
} else if (c == '\b') {
|
||||||
|
put_raw("\\b", 2);
|
||||||
|
} else if (c == '\f') {
|
||||||
|
put_raw("\\f", 2);
|
||||||
|
} else if (static_cast<unsigned char>(c) < 0x20) {
|
||||||
|
char tmp[8];
|
||||||
|
snprintf(tmp, sizeof(tmp), "\\u%04x", c);
|
||||||
|
put_raw(tmp, 6);
|
||||||
|
} else {
|
||||||
|
put(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
char* buf_;
|
||||||
|
size_t cap_;
|
||||||
|
size_t pos_ = 0;
|
||||||
|
bool ok_ = true;
|
||||||
|
int depth_ = 0;
|
||||||
|
bool items_[kMaxDepth + 1] = {};
|
||||||
|
bool await_value_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Parser (jsmn): токены поверх исходного буфера, без копирований.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
constexpr int kMaxTokens = 64;
|
||||||
|
|
||||||
|
class Doc {
|
||||||
|
public:
|
||||||
|
// Разбирает buf (NUL-terminated, будет модифицироваться при unescape).
|
||||||
|
// После вызова buf должен жить, пока используется Doc.
|
||||||
|
bool parse(char* buf) {
|
||||||
|
buf_ = buf;
|
||||||
|
jsmn_init(&p_);
|
||||||
|
count_ = jsmn_parse(&p_, buf, strlen(buf), tokens_, kMaxTokens);
|
||||||
|
return count_ >= 1 && tokens_[0].type == JSMN_OBJECT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значение ключа по имени (первое вхождение в порядке документа —
|
||||||
|
// глубина не различается; для форматов протокора ключи уникальны).
|
||||||
|
// Для строки возвращает указатель внутрь buf_ (БЕЗ кавычек,
|
||||||
|
// НЕ NUL-terminated) и длину.
|
||||||
|
bool find(const char* name, const char** out_start, size_t* out_len,
|
||||||
|
jsmntype_t* out_type = nullptr) const {
|
||||||
|
for (int i = 1; i + 1 < count_; i++) {
|
||||||
|
if (tokens_[i].type == JSMN_STRING && tokens_[i].size == 1) {
|
||||||
|
int len = tokens_[i].end - tokens_[i].start;
|
||||||
|
if (len == static_cast<int>(strlen(name)) &&
|
||||||
|
strncmp(buf_ + tokens_[i].start, name, len) == 0) {
|
||||||
|
const jsmntok_t& v = tokens_[i + 1];
|
||||||
|
*out_start = buf_ + v.start;
|
||||||
|
*out_len = static_cast<size_t>(v.end - v.start);
|
||||||
|
if (out_type != nullptr) *out_type = v.type;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка в out (снятие кавычек; поддержка \" \\ \/ \n \t \r \b \f).
|
||||||
|
bool get_string(const char* name, char* out, size_t out_cap) const {
|
||||||
|
const char* s;
|
||||||
|
size_t len;
|
||||||
|
jsmntype_t type;
|
||||||
|
if (!find(name, &s, &len, &type) || type != JSMN_STRING) return false;
|
||||||
|
size_t o = 0;
|
||||||
|
for (size_t i = 0; i < len; i++) {
|
||||||
|
if (o + 1 >= out_cap) return false;
|
||||||
|
char c = s[i];
|
||||||
|
if (c == '\\' && i + 1 < len) {
|
||||||
|
char e = s[++i];
|
||||||
|
switch (e) {
|
||||||
|
case '"': out[o++] = '"'; break;
|
||||||
|
case '\\': out[o++] = '\\'; break;
|
||||||
|
case '/': out[o++] = '/'; break;
|
||||||
|
case 'n': out[o++] = '\n'; break;
|
||||||
|
case 't': out[o++] = '\t'; break;
|
||||||
|
case 'r': out[o++] = '\r'; break;
|
||||||
|
case 'b': out[o++] = '\b'; break;
|
||||||
|
case 'f': out[o++] = '\f'; break;
|
||||||
|
default: return false; // \uXXXX не требуется протоколом
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out[o++] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[o] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get_int(const char* name, int64_t* out) const {
|
||||||
|
const char* s;
|
||||||
|
size_t len;
|
||||||
|
jsmntype_t type;
|
||||||
|
if (!find(name, &s, &len, &type) || type != JSMN_PRIMITIVE) return false;
|
||||||
|
int64_t v = 0;
|
||||||
|
bool neg = false;
|
||||||
|
size_t i = 0;
|
||||||
|
if (i < len && (s[i] == '-' || s[i] == '+')) {
|
||||||
|
neg = s[i] == '-';
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i >= len) return false;
|
||||||
|
for (; i < len; i++) {
|
||||||
|
if (s[i] < '0' || s[i] > '9') return false;
|
||||||
|
int64_t digit = s[i] - '0';
|
||||||
|
if (v > (INT64_MAX - digit) / 10) return false; // переполнение
|
||||||
|
v = v * 10 + digit;
|
||||||
|
}
|
||||||
|
*out = neg ? -v : v;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get_bool(const char* name, bool* out) const {
|
||||||
|
const char* s;
|
||||||
|
size_t len;
|
||||||
|
jsmntype_t type;
|
||||||
|
if (!find(name, &s, &len, &type) || type != JSMN_PRIMITIVE) return false;
|
||||||
|
if (len == 4 && strncmp(s, "true", 4) == 0) {
|
||||||
|
*out = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (len == 5 && strncmp(s, "false", 5) == 0) {
|
||||||
|
*out = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int tokens_used() const { return count_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
char* buf_ = nullptr;
|
||||||
|
jsmn_parser p_;
|
||||||
|
jsmntok_t tokens_[kMaxTokens];
|
||||||
|
int count_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace fgl::ayla::json
|
||||||
42
src/ayla/log.cpp
Normal file
42
src/ayla/log.cpp
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#include "log.hpp"
|
||||||
|
|
||||||
|
#include <cstdarg>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
namespace fgl::ayla::log {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
Sink g_sink = nullptr;
|
||||||
|
void* g_sink_ctx = nullptr;
|
||||||
|
int g_min_level = kInfo;
|
||||||
|
constexpr size_t kMaxLine = 256;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void set_sink(Sink sink, void* ctx) {
|
||||||
|
g_sink = sink;
|
||||||
|
g_sink_ctx = ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
int min_level() { return g_min_level; }
|
||||||
|
void set_min_level(int level) { g_min_level = level; }
|
||||||
|
|
||||||
|
void write(int level, const char* fmt, ...) {
|
||||||
|
if (g_sink == nullptr || level < g_min_level) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
char buf[kMaxLine];
|
||||||
|
va_list args;
|
||||||
|
va_start(args, fmt);
|
||||||
|
int n = vsnprintf(buf, sizeof(buf), fmt, args);
|
||||||
|
va_end(args);
|
||||||
|
if (n < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
size_t len = static_cast<size_t>(n);
|
||||||
|
if (len >= sizeof(buf)) {
|
||||||
|
len = sizeof(buf) - 1; // обрезано
|
||||||
|
}
|
||||||
|
g_sink(level, buf, len, g_sink_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgl::ayla::log
|
||||||
32
src/ayla/log.hpp
Normal file
32
src/ayla/log.hpp
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Логирование ядра: без printf в stdout, вывод через настраиваемый sink.
|
||||||
|
// Вызовы из внутреннего потока сессии; sink должен быть быстрым и реентерабельным.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
namespace fgl::ayla::log {
|
||||||
|
|
||||||
|
enum Level : int {
|
||||||
|
kDebug = 0,
|
||||||
|
kInfo = 1,
|
||||||
|
kWarn = 2,
|
||||||
|
kError = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
using Sink = void (*)(int level, const char* msg, size_t len, void* ctx);
|
||||||
|
|
||||||
|
// Устанавливает приёмник логов (глобально). nullptr — логирование отключено.
|
||||||
|
void set_sink(Sink sink, void* ctx);
|
||||||
|
|
||||||
|
// Форматирует в фиксированный буфер (без кучи) и отправляет в sink.
|
||||||
|
void write(int level, const char* fmt, ...) __attribute__((format(printf, 2, 3)));
|
||||||
|
|
||||||
|
int min_level(); // по умолчанию kInfo
|
||||||
|
void set_min_level(int level);
|
||||||
|
|
||||||
|
} // namespace fgl::ayla::log
|
||||||
|
|
||||||
|
#define FGL_LOGD(...) ::fgl::ayla::log::write(::fgl::ayla::log::kDebug, __VA_ARGS__)
|
||||||
|
#define FGL_LOGI(...) ::fgl::ayla::log::write(::fgl::ayla::log::kInfo, __VA_ARGS__)
|
||||||
|
#define FGL_LOGW(...) ::fgl::ayla::log::write(::fgl::ayla::log::kWarn, __VA_ARGS__)
|
||||||
|
#define FGL_LOGE(...) ::fgl::ayla::log::write(::fgl::ayla::log::kError, __VA_ARGS__)
|
||||||
243
src/ayla/platform/esp-idf/platform.cpp
Normal file
243
src/ayla/platform/esp-idf/platform.cpp
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
// ESP-IDF-реализация платформенного слоя (lwip + esp_timer + esp_random).
|
||||||
|
// Потоки — через pthread-слой ESP-IDF (поддерживается pthread_join).
|
||||||
|
#ifdef ESP_PLATFORM
|
||||||
|
|
||||||
|
#include "ayla/platform/platform.hpp"
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <lwip/sockets.h>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
|
||||||
|
#include <sys/select.h>
|
||||||
|
|
||||||
|
#include "esp_random.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
|
||||||
|
#include <new>
|
||||||
|
|
||||||
|
namespace fgl::plat {
|
||||||
|
|
||||||
|
uint64_t now_ms() {
|
||||||
|
return static_cast<uint64_t>(esp_timer_get_time()) / 1000ull;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool random(uint8_t* buf, size_t len) {
|
||||||
|
esp_fill_random(buf, len);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct ThreadStart {
|
||||||
|
void (*fn)(void*);
|
||||||
|
void* ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
void* thread_trampoline(void* arg) {
|
||||||
|
auto* start = static_cast<ThreadStart*>(arg);
|
||||||
|
ThreadStart tmp = *start;
|
||||||
|
delete start;
|
||||||
|
tmp.fn(tmp.ctx);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool thread_create(void (*fn)(void*), void* ctx, const char* name,
|
||||||
|
uint32_t stack_bytes, ThreadId* out_id) {
|
||||||
|
auto* start = new (std::nothrow) ThreadStart{fn, ctx};
|
||||||
|
if (start == nullptr) return false;
|
||||||
|
pthread_t tid;
|
||||||
|
pthread_attr_t attr;
|
||||||
|
pthread_attr_init(&attr);
|
||||||
|
// Стек ESP-IDF по умолчанию мал для httpd; задаём явно.
|
||||||
|
if (stack_bytes > 0) pthread_attr_setstacksize(&attr, stack_bytes);
|
||||||
|
int rc = pthread_create(&tid, &attr, thread_trampoline, start);
|
||||||
|
pthread_attr_destroy(&attr);
|
||||||
|
if (rc != 0) {
|
||||||
|
delete start;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pthread_setname_np(tid, name != nullptr ? name : "fgl");
|
||||||
|
if (out_id != nullptr) {
|
||||||
|
*out_id = reinterpret_cast<ThreadId>(tid);
|
||||||
|
} else {
|
||||||
|
pthread_detach(tid);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void thread_join(ThreadId id) {
|
||||||
|
pthread_join(reinterpret_cast<pthread_t>(id), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void sleep_ms(uint32_t ms) { vTaskDelay(pdMS_TO_TICKS(ms)); }
|
||||||
|
|
||||||
|
int tcp_listen(uint16_t port) {
|
||||||
|
int fd = lwip_socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (fd < 0) return -1;
|
||||||
|
int one = 1;
|
||||||
|
lwip_setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
||||||
|
struct sockaddr_in addr {};
|
||||||
|
addr.sin_family = AF_INET;
|
||||||
|
addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||||
|
addr.sin_port = htons(port);
|
||||||
|
if (lwip_bind(fd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)) < 0 ||
|
||||||
|
lwip_listen(fd, 4) < 0) {
|
||||||
|
lwip_close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t tcp_local_port(int fd) {
|
||||||
|
struct sockaddr_in addr {};
|
||||||
|
socklen_t addrlen = sizeof(addr);
|
||||||
|
if (lwip_getsockname(fd, reinterpret_cast<struct sockaddr*>(&addr), &addrlen) != 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return ntohs(addr.sin_port);
|
||||||
|
}
|
||||||
|
|
||||||
|
int tcp_accept(int listen_fd, uint32_t* peer_ip, uint16_t* peer_port) {
|
||||||
|
struct sockaddr_in addr {};
|
||||||
|
socklen_t addrlen = sizeof(addr);
|
||||||
|
int fd = lwip_accept(listen_fd, reinterpret_cast<struct sockaddr*>(&addr), &addrlen);
|
||||||
|
if (fd < 0) return -1;
|
||||||
|
int one = 1;
|
||||||
|
lwip_setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
|
||||||
|
if (peer_ip != nullptr) *peer_ip = ntohl(addr.sin_addr.s_addr);
|
||||||
|
if (peer_port != nullptr) *peer_port = ntohs(addr.sin_port);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
int tcp_connect(const char* host, uint16_t port, uint32_t timeout_ms) {
|
||||||
|
struct addrinfo hints {};
|
||||||
|
hints.ai_family = AF_INET;
|
||||||
|
hints.ai_socktype = SOCK_STREAM;
|
||||||
|
struct addrinfo* list = nullptr;
|
||||||
|
if (lwip_getaddrinfo(host, nullptr, &hints, &list) != 0 || list == nullptr) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
int fd = -1;
|
||||||
|
for (struct addrinfo* ai = list; ai != nullptr; ai = ai->ai_next) {
|
||||||
|
auto* addr = reinterpret_cast<struct sockaddr_in*>(ai->ai_addr);
|
||||||
|
addr->sin_port = htons(port);
|
||||||
|
fd = lwip_socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
|
||||||
|
if (fd < 0) continue;
|
||||||
|
int flags = lwip_fcntl(fd, F_GETFL, 0);
|
||||||
|
lwip_fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
||||||
|
int rc = lwip_connect(fd, ai->ai_addr, ai->ai_addrlen);
|
||||||
|
if (rc == 0) {
|
||||||
|
lwip_fcntl(fd, F_SETFL, flags);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (errno == EINPROGRESS) {
|
||||||
|
fd_set wfds;
|
||||||
|
FD_ZERO(&wfds);
|
||||||
|
FD_SET(fd, &wfds);
|
||||||
|
struct timeval tv {};
|
||||||
|
tv.tv_sec = timeout_ms / 1000;
|
||||||
|
tv.tv_usec = static_cast<long>(timeout_ms % 1000) * 1000L;
|
||||||
|
if (lwip_select(fd + 1, nullptr, &wfds, nullptr, &tv) > 0) {
|
||||||
|
int soerr = 0;
|
||||||
|
socklen_t slen = sizeof(soerr);
|
||||||
|
lwip_getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &slen);
|
||||||
|
if (soerr == 0) {
|
||||||
|
lwip_fcntl(fd, F_SETFL, flags);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lwip_close(fd);
|
||||||
|
fd = -1;
|
||||||
|
}
|
||||||
|
lwip_freeaddrinfo(list);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool local_ip_for(const char* host, char* out, size_t out_cap) {
|
||||||
|
struct addrinfo hints {};
|
||||||
|
hints.ai_family = AF_INET;
|
||||||
|
hints.ai_socktype = SOCK_DGRAM;
|
||||||
|
struct addrinfo* list = nullptr;
|
||||||
|
if (lwip_getaddrinfo(host, "80", &hints, &list) != 0 || list == nullptr) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int fd = lwip_socket(AF_INET, SOCK_DGRAM, 0);
|
||||||
|
if (fd < 0) {
|
||||||
|
lwip_freeaddrinfo(list);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool ok = lwip_connect(fd, list->ai_addr, list->ai_addrlen) == 0;
|
||||||
|
struct sockaddr_in local {};
|
||||||
|
socklen_t slen = sizeof(local);
|
||||||
|
if (ok && lwip_getsockname(fd, reinterpret_cast<struct sockaddr*>(&local),
|
||||||
|
&slen) == 0) {
|
||||||
|
const char* s = inet_ntop(AF_INET, &local.sin_addr, out,
|
||||||
|
static_cast<socklen_t>(out_cap));
|
||||||
|
ok = s != nullptr;
|
||||||
|
} else {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
lwip_freeaddrinfo(list);
|
||||||
|
lwip_close(fd);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
long tcp_send(int fd, const void* buf, size_t len) {
|
||||||
|
const uint8_t* p = static_cast<const uint8_t*>(buf);
|
||||||
|
size_t done = 0;
|
||||||
|
while (done < len) {
|
||||||
|
long n = lwip_send(fd, p + done, len - done, 0);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
done += static_cast<size_t>(n);
|
||||||
|
}
|
||||||
|
return static_cast<long>(done);
|
||||||
|
}
|
||||||
|
|
||||||
|
long tcp_recv(int fd, void* buf, size_t len) {
|
||||||
|
for (;;) {
|
||||||
|
long n = lwip_recv(fd, buf, len, 0);
|
||||||
|
if (n < 0 && errno == EINTR) continue;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool tcp_set_timeout(int fd, uint32_t rx_ms, uint32_t tx_ms) {
|
||||||
|
struct timeval tv {};
|
||||||
|
tv.tv_sec = rx_ms / 1000;
|
||||||
|
tv.tv_usec = static_cast<long>(rx_ms % 1000) * 1000L;
|
||||||
|
if (lwip_setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0) return false;
|
||||||
|
tv.tv_sec = tx_ms / 1000;
|
||||||
|
tv.tv_usec = static_cast<long>(tx_ms % 1000) * 1000L;
|
||||||
|
return lwip_setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool tcp_poll_readable(int fd, uint32_t timeout_ms) {
|
||||||
|
fd_set rfds;
|
||||||
|
FD_ZERO(&rfds);
|
||||||
|
FD_SET(fd, &rfds);
|
||||||
|
struct timeval tv {};
|
||||||
|
tv.tv_sec = timeout_ms / 1000;
|
||||||
|
tv.tv_usec = static_cast<long>(timeout_ms % 1000) * 1000L;
|
||||||
|
return lwip_select(fd + 1, &rfds, nullptr, nullptr, &tv) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool tcp_shutdown(int fd) {
|
||||||
|
return lwip_shutdown(fd, SHUT_RDWR) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void tcp_close(int fd) {
|
||||||
|
if (fd >= 0) lwip_close(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgl::plat
|
||||||
|
|
||||||
|
#endif // ESP_PLATFORM
|
||||||
60
src/ayla/platform/platform.hpp
Normal file
60
src/ayla/platform/platform.hpp
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Платформенный слой: сокеты, таймеры, CSPRNG, потоки.
|
||||||
|
// Одна реализация на платформу: platform/posix (Linux) и platform/esp-idf.
|
||||||
|
// Все функции — блокирующие; коды ошибок через возвращаемое значение (-1),
|
||||||
|
// без исключений. IP-адреса — uint32_t в host byte order.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace fgl::plat {
|
||||||
|
|
||||||
|
// ---- время ----
|
||||||
|
// Монотонные миллисекунды с неопределённой точки отсчёта.
|
||||||
|
uint64_t now_ms();
|
||||||
|
|
||||||
|
// ---- CSPRNG ----
|
||||||
|
// Заполняет buf cryptographically-secure случайными байтами.
|
||||||
|
bool random(uint8_t* buf, size_t len);
|
||||||
|
|
||||||
|
// ---- потоки ----
|
||||||
|
// Идентификатор потока (непрозрачный). Валиден между thread_create и
|
||||||
|
// завершением thread_join.
|
||||||
|
using ThreadId = void*;
|
||||||
|
// Создаёт поток с именем name (для отладки) и стеком stack_bytes (0 — по
|
||||||
|
// умолчанию платформы; для ESP-IDF задавать явно, >= 4096).
|
||||||
|
// out_id может быть nullptr (тогда присоединиться нельзя).
|
||||||
|
bool thread_create(void (*fn)(void*), void* ctx, const char* name,
|
||||||
|
uint32_t stack_bytes, ThreadId* out_id);
|
||||||
|
// Блокирующе ждёт завершения потока. Гарантирует, что код потока больше не
|
||||||
|
// исполняется (объекты потока можно освобождать после возврата).
|
||||||
|
void thread_join(ThreadId id);
|
||||||
|
// Мягкая блокирующая задержка текущего потока.
|
||||||
|
void sleep_ms(uint32_t ms);
|
||||||
|
|
||||||
|
// ---- TCP (клиент и сервер) ----
|
||||||
|
// Создаёт слушающий сокет; возвращает fd или -1. port==0 — любой свободный
|
||||||
|
// (узнать выбранный: tcp_local_port).
|
||||||
|
int tcp_listen(uint16_t port);
|
||||||
|
// Локальный порт сокета (для ephemeral-слушателем).
|
||||||
|
uint16_t tcp_local_port(int fd);
|
||||||
|
// Принимает соединение (блокирующе); возвращает fd клиента или -1.
|
||||||
|
// peer_ip (host byte order) / peer_port могут быть nullptr.
|
||||||
|
int tcp_accept(int listen_fd, uint32_t* peer_ip, uint16_t* peer_port);
|
||||||
|
// Подключается к host:port (host — DNS-имя или dotted-quad) с таймаутом.
|
||||||
|
int tcp_connect(const char* host, uint16_t port, uint32_t timeout_ms);
|
||||||
|
// Локальный IP-адрес (dotted) интерфейса, которым достигается host
|
||||||
|
// (без реального трафика: UDP connect). Для local_reg.
|
||||||
|
bool local_ip_for(const char* host, char* out, size_t out_cap);
|
||||||
|
long tcp_send(int fd, const void* buf, size_t len); // >0 / -1
|
||||||
|
// Блокирующее чтение; 0 — EOF, -1 — ошибка/таймаут.
|
||||||
|
long tcp_recv(int fd, void* buf, size_t len);
|
||||||
|
// Таймауты на последующие recv/send (0 — без таймаута).
|
||||||
|
bool tcp_set_timeout(int fd, uint32_t rx_ms, uint32_t tx_ms);
|
||||||
|
// Ожидание читаемости fd (для выходa из блокирующего accept). Таймаут в мс.
|
||||||
|
bool tcp_poll_readable(int fd, uint32_t timeout_ms);
|
||||||
|
// Прерывает блокированные recv/send на сокете (для остановки сервера).
|
||||||
|
bool tcp_shutdown(int fd);
|
||||||
|
void tcp_close(int fd);
|
||||||
|
|
||||||
|
} // namespace fgl::plat
|
||||||
250
src/ayla/platform/posix/platform.cpp
Normal file
250
src/ayla/platform/posix/platform.cpp
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
// POSIX-реализация платформенного слоя (Linux).
|
||||||
|
#include "ayla/platform/platform.hpp"
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <netinet/tcp.h>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <poll.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <sys/random.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <new>
|
||||||
|
|
||||||
|
namespace fgl::plat {
|
||||||
|
|
||||||
|
uint64_t now_ms() {
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
return static_cast<uint64_t>(ts.tv_sec) * 1000u +
|
||||||
|
static_cast<uint64_t>(ts.tv_nsec) / 1000000u;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool random(uint8_t* buf, size_t len) {
|
||||||
|
size_t done = 0;
|
||||||
|
while (done < len) {
|
||||||
|
ssize_t n = ::getrandom(buf + done, len - done, 0);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
done += static_cast<size_t>(n);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct ThreadStart {
|
||||||
|
void (*fn)(void*);
|
||||||
|
void* ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
void* thread_trampoline(void* arg) {
|
||||||
|
auto* start = static_cast<ThreadStart*>(arg);
|
||||||
|
ThreadStart tmp = *start;
|
||||||
|
delete start;
|
||||||
|
tmp.fn(tmp.ctx);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool thread_create(void (*fn)(void*), void* ctx, const char* name,
|
||||||
|
uint32_t stack_bytes, ThreadId* out_id) {
|
||||||
|
auto* start = new (std::nothrow) ThreadStart{fn, ctx};
|
||||||
|
if (start == nullptr) return false;
|
||||||
|
pthread_t tid;
|
||||||
|
pthread_attr_t attr;
|
||||||
|
pthread_attr_init(&attr);
|
||||||
|
if (stack_bytes > 0) {
|
||||||
|
if (pthread_attr_setstacksize(&attr, stack_bytes) != 0) {
|
||||||
|
// glibc отвергает < PTHREAD_STACK_MIN; остаётся дефолт (больше — не меньше)
|
||||||
|
// логируем только: ядро запрашивает >= PTHREAD_STACK_MIN.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int rc = pthread_create(&tid, &attr, thread_trampoline, start);
|
||||||
|
pthread_attr_destroy(&attr);
|
||||||
|
if (rc != 0) {
|
||||||
|
delete start;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pthread_setname_np(tid, name != nullptr ? name : "fgl");
|
||||||
|
if (out_id != nullptr) {
|
||||||
|
*out_id = reinterpret_cast<ThreadId>(tid);
|
||||||
|
} else {
|
||||||
|
pthread_detach(tid);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void thread_join(ThreadId id) {
|
||||||
|
auto tid = reinterpret_cast<pthread_t>(id);
|
||||||
|
pthread_join(tid, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void sleep_ms(uint32_t ms) {
|
||||||
|
struct timespec req;
|
||||||
|
req.tv_sec = ms / 1000;
|
||||||
|
req.tv_nsec = static_cast<long>(ms % 1000) * 1000000L;
|
||||||
|
while (nanosleep(&req, &req) == -1 && errno == EINTR) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int tcp_listen(uint16_t port) {
|
||||||
|
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (fd < 0) return -1;
|
||||||
|
int one = 1;
|
||||||
|
::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
||||||
|
struct sockaddr_in addr {};
|
||||||
|
addr.sin_family = AF_INET;
|
||||||
|
addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||||
|
addr.sin_port = htons(port);
|
||||||
|
if (::bind(fd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)) < 0 ||
|
||||||
|
::listen(fd, 4) < 0) {
|
||||||
|
::close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t tcp_local_port(int fd) {
|
||||||
|
struct sockaddr_in addr {};
|
||||||
|
socklen_t addrlen = sizeof(addr);
|
||||||
|
if (::getsockname(fd, reinterpret_cast<struct sockaddr*>(&addr), &addrlen) != 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return ntohs(addr.sin_port);
|
||||||
|
}
|
||||||
|
|
||||||
|
int tcp_accept(int listen_fd, uint32_t* peer_ip, uint16_t* peer_port) {
|
||||||
|
struct sockaddr_in addr {};
|
||||||
|
socklen_t addrlen = sizeof(addr);
|
||||||
|
int fd = ::accept(listen_fd, reinterpret_cast<struct sockaddr*>(&addr), &addrlen);
|
||||||
|
if (fd < 0) return -1;
|
||||||
|
int one = 1;
|
||||||
|
::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
|
||||||
|
if (peer_ip != nullptr) *peer_ip = ntohl(addr.sin_addr.s_addr);
|
||||||
|
if (peer_port != nullptr) *peer_port = ntohs(addr.sin_port);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
int tcp_connect(const char* host, uint16_t port, uint32_t timeout_ms) {
|
||||||
|
struct addrinfo hints {};
|
||||||
|
hints.ai_family = AF_INET;
|
||||||
|
hints.ai_socktype = SOCK_STREAM;
|
||||||
|
struct addrinfo* list = nullptr;
|
||||||
|
if (::getaddrinfo(host, nullptr, &hints, &list) != 0 || list == nullptr) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
int fd = -1;
|
||||||
|
for (struct addrinfo* ai = list; ai != nullptr; ai = ai->ai_next) {
|
||||||
|
auto* addr = reinterpret_cast<struct sockaddr_in*>(ai->ai_addr);
|
||||||
|
addr->sin_port = htons(port);
|
||||||
|
fd = ::socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
|
||||||
|
if (fd < 0) continue;
|
||||||
|
// Неблокирующее подключение + poll для таймаута.
|
||||||
|
int flags = ::fcntl(fd, F_GETFL, 0);
|
||||||
|
::fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
||||||
|
int rc = ::connect(fd, ai->ai_addr, ai->ai_addrlen);
|
||||||
|
if (rc == 0) {
|
||||||
|
::fcntl(fd, F_SETFL, flags);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (errno == EINPROGRESS) {
|
||||||
|
struct pollfd pfd {fd, POLLOUT, 0};
|
||||||
|
if (::poll(&pfd, 1, static_cast<int>(timeout_ms)) > 0) {
|
||||||
|
int soerr = 0;
|
||||||
|
socklen_t slen = sizeof(soerr);
|
||||||
|
::getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &slen);
|
||||||
|
if (soerr == 0) {
|
||||||
|
::fcntl(fd, F_SETFL, flags);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
::close(fd);
|
||||||
|
fd = -1;
|
||||||
|
}
|
||||||
|
::freeaddrinfo(list);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool local_ip_for(const char* host, char* out, size_t out_cap) {
|
||||||
|
struct addrinfo hints {};
|
||||||
|
hints.ai_family = AF_INET;
|
||||||
|
hints.ai_socktype = SOCK_DGRAM;
|
||||||
|
struct addrinfo* list = nullptr;
|
||||||
|
if (::getaddrinfo(host, "80", &hints, &list) != 0 || list == nullptr) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int fd = ::socket(AF_INET, SOCK_DGRAM, 0);
|
||||||
|
if (fd < 0) {
|
||||||
|
::freeaddrinfo(list);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool ok = ::connect(fd, list->ai_addr, list->ai_addrlen) == 0;
|
||||||
|
struct sockaddr_in local {};
|
||||||
|
socklen_t slen = sizeof(local);
|
||||||
|
if (ok && ::getsockname(fd, reinterpret_cast<struct sockaddr*>(&local),
|
||||||
|
&slen) == 0) {
|
||||||
|
const char* s = inet_ntop(AF_INET, &local.sin_addr, out,
|
||||||
|
static_cast<socklen_t>(out_cap));
|
||||||
|
ok = s != nullptr;
|
||||||
|
} else {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
::freeaddrinfo(list);
|
||||||
|
::close(fd);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
long tcp_send(int fd, const void* buf, size_t len) {
|
||||||
|
const uint8_t* p = static_cast<const uint8_t*>(buf);
|
||||||
|
size_t done = 0;
|
||||||
|
while (done < len) {
|
||||||
|
long n = ::send(fd, p + done, len - done, MSG_NOSIGNAL);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
done += static_cast<size_t>(n);
|
||||||
|
}
|
||||||
|
return static_cast<long>(done);
|
||||||
|
}
|
||||||
|
|
||||||
|
long tcp_recv(int fd, void* buf, size_t len) {
|
||||||
|
for (;;) {
|
||||||
|
long n = ::recv(fd, buf, len, 0);
|
||||||
|
if (n < 0 && errno == EINTR) continue;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool tcp_set_timeout(int fd, uint32_t rx_ms, uint32_t tx_ms) {
|
||||||
|
struct timeval tv {};
|
||||||
|
tv.tv_sec = rx_ms / 1000;
|
||||||
|
tv.tv_usec = static_cast<long>(rx_ms % 1000) * 1000L;
|
||||||
|
if (::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0) return false;
|
||||||
|
tv.tv_sec = tx_ms / 1000;
|
||||||
|
tv.tv_usec = static_cast<long>(tx_ms % 1000) * 1000L;
|
||||||
|
return ::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool tcp_poll_readable(int fd, uint32_t timeout_ms) {
|
||||||
|
struct pollfd pfd {fd, POLLIN, 0};
|
||||||
|
return ::poll(&pfd, 1, static_cast<int>(timeout_ms)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool tcp_shutdown(int fd) {
|
||||||
|
return ::shutdown(fd, SHUT_RDWR) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void tcp_close(int fd) {
|
||||||
|
if (fd >= 0) ::close(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgl::plat
|
||||||
909
src/ayla/session.cpp
Normal file
909
src/ayla/session.cpp
Normal file
@@ -0,0 +1,909 @@
|
|||||||
|
#include "ayla/session.hpp"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <mutex>
|
||||||
|
#include <new>
|
||||||
|
|
||||||
|
#include "ayla/envelope.hpp"
|
||||||
|
#include "ayla/httpc.hpp"
|
||||||
|
#include "ayla/json.hpp"
|
||||||
|
#include "ayla/log.hpp"
|
||||||
|
#include "ayla/platform/platform.hpp"
|
||||||
|
|
||||||
|
namespace fgl::ayla {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr uint32_t kLoopTickMs = 20;
|
||||||
|
constexpr size_t kMaxName = 40;
|
||||||
|
constexpr size_t kMaxQueue = 32;
|
||||||
|
|
||||||
|
struct Command {
|
||||||
|
uint8_t type; // 1=GET, 2=SET, 3=DELETE
|
||||||
|
char name[kMaxName];
|
||||||
|
int64_t value;
|
||||||
|
char base_type[10];
|
||||||
|
int cmd_id;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool gen_random_token(char* out, size_t len) {
|
||||||
|
static const char kAlpha[] =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
uint8_t rnd[32];
|
||||||
|
size_t need = len <= sizeof(rnd) ? len : sizeof(rnd);
|
||||||
|
if (!plat::random(rnd, need)) return false;
|
||||||
|
for (size_t i = 0; i < len; i++) {
|
||||||
|
out[i] = kAlpha[rnd[i % need] % 62];
|
||||||
|
}
|
||||||
|
out[len] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "cmd_id=5&status=200" -> значения; отсутствующие остаются прежними.
|
||||||
|
void parse_query(const char* query, int* cmd_id, int* status) {
|
||||||
|
const char* p = query;
|
||||||
|
while (*p != '\0') {
|
||||||
|
const char* eq = strchr(p, '=');
|
||||||
|
const char* amp = strchr(p, '&');
|
||||||
|
const char* end = (amp != nullptr) ? amp : p + strlen(p);
|
||||||
|
if (eq != nullptr && eq < end) {
|
||||||
|
size_t klen = static_cast<size_t>(eq - p);
|
||||||
|
int v = atoi(eq + 1);
|
||||||
|
if (klen == 6 && strncmp(p, "cmd_id", 6) == 0) *cmd_id = v;
|
||||||
|
if (klen == 6 && strncmp(p, "status", 6) == 0) *status = v;
|
||||||
|
}
|
||||||
|
if (amp == nullptr) break;
|
||||||
|
p = amp + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
struct Session::Impl {
|
||||||
|
SessionConfig cfg{};
|
||||||
|
SessionTimings timings{};
|
||||||
|
SessionCallbacks cbs{};
|
||||||
|
|
||||||
|
char host[64] = {};
|
||||||
|
char dsn[40] = {};
|
||||||
|
char lanip_key[64] = {};
|
||||||
|
|
||||||
|
// ---- httpd-поток (используется только из httpd-потока) ----
|
||||||
|
HttpServer httpd;
|
||||||
|
SessionCrypto crypto;
|
||||||
|
bool crypto_ready = false;
|
||||||
|
int64_t out_seq = 0;
|
||||||
|
char ke_random2[17] = {};
|
||||||
|
// Рабочие буферы datapoint-пути (httpd однопоточен; вынесены из стека —
|
||||||
|
// экономия ~7КБ стека потока httpd).
|
||||||
|
char dp_body[kHttpdMaxBody + 1];
|
||||||
|
char dp_enc_b64[kEnvelopeMaxB64];
|
||||||
|
char dp_sign_b64[96];
|
||||||
|
char dp_plain[kEnvelopeMaxPlain];
|
||||||
|
char dp_data[kEnvelopeMaxPlain];
|
||||||
|
|
||||||
|
// ---- разделяемое ----
|
||||||
|
std::mutex queue_mu;
|
||||||
|
Command queue[kMaxQueue] = {};
|
||||||
|
uint8_t queue_len = 0;
|
||||||
|
int next_cmd_id = 1;
|
||||||
|
bool batch_open = false;
|
||||||
|
Command batch[kMaxQueue] = {};
|
||||||
|
uint8_t batch_len = 0;
|
||||||
|
|
||||||
|
std::atomic<uint8_t> state{static_cast<uint8_t>(SessionState::kIdle)};
|
||||||
|
std::atomic<int> last_error{static_cast<int>(SessionError::kNone)};
|
||||||
|
std::atomic<uint64_t> ke_time_ms{0}; // время ответа на KE (0 — не было)
|
||||||
|
std::atomic<uint64_t> last_local_reg_ms{0};
|
||||||
|
std::atomic<uint64_t> quiet_until_ms{0}; // пауза local_reg (восстановление)
|
||||||
|
std::atomic<uint64_t> retry_at_ms{0};
|
||||||
|
std::atomic<bool> had_poll_since_ke{false};
|
||||||
|
std::atomic<bool> ever_active{false}; // был online хотя бы раз
|
||||||
|
std::atomic<bool> want_notify{false}; // после batch commit / перехода online
|
||||||
|
std::atomic<bool> delete_pending{false};
|
||||||
|
std::atomic<bool> delete_served{false};
|
||||||
|
std::atomic<bool> decrypt_failed{false};
|
||||||
|
std::atomic<bool> running{false};
|
||||||
|
|
||||||
|
std::atomic<uint32_t> rekeys{0};
|
||||||
|
std::atomic<uint32_t> pushes_ok{0};
|
||||||
|
std::atomic<uint32_t> pushes_bad{0};
|
||||||
|
std::atomic<uint32_t> cmds_served{0};
|
||||||
|
|
||||||
|
plat::ThreadId thread = nullptr;
|
||||||
|
uint8_t backoff_attempts = 0;
|
||||||
|
uint32_t backoff_ms = 0;
|
||||||
|
bool reg_ok = false; // последний local_reg принят (202/200)
|
||||||
|
|
||||||
|
uint16_t listen_port_actual = 0;
|
||||||
|
|
||||||
|
// ---------- helpers (вызывается из обоих потоков) ----------
|
||||||
|
void set_state(SessionState st, SessionError err) {
|
||||||
|
uint8_t prev = state.exchange(static_cast<uint8_t>(st),
|
||||||
|
std::memory_order_acq_rel);
|
||||||
|
last_error.store(static_cast<int>(err), std::memory_order_release);
|
||||||
|
if (prev != static_cast<uint8_t>(st) && cbs.on_state != nullptr) {
|
||||||
|
cbs.on_state(cbs.ctx, st, err); // без дублирования одинаковых состояний
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- очередь (mutex) ----------
|
||||||
|
bool enqueue_locked(const Command& cmd) {
|
||||||
|
// coalescing: SET замещает незабранный SET того же свойства;
|
||||||
|
// GET-дубликат отбрасывается; DELETE — единственный.
|
||||||
|
if (cmd.type == 3) {
|
||||||
|
for (uint8_t i = 0; i < queue_len; i++) {
|
||||||
|
if (queue[i].type == 3) return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (uint8_t i = 0; i < queue_len; i++) {
|
||||||
|
if (queue[i].type == cmd.type &&
|
||||||
|
strncmp(queue[i].name, cmd.name, kMaxName) == 0) {
|
||||||
|
if (cmd.type == 2) {
|
||||||
|
queue[i].value = cmd.value; // замещаем
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (cmd.type == 1) return true; // дубликат GET
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queue_len >= cfg.max_queue || queue_len >= kMaxQueue) return false;
|
||||||
|
queue[queue_len++] = cmd;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool submit(Command cmd) {
|
||||||
|
std::lock_guard<std::mutex> lk(queue_mu);
|
||||||
|
bool ok;
|
||||||
|
if (batch_open && cmd.type != 3) {
|
||||||
|
if (batch_len >= kMaxQueue) return false;
|
||||||
|
// coalescing внутри batch
|
||||||
|
for (uint8_t i = 0; i < batch_len; i++) {
|
||||||
|
if (batch[i].type == cmd.type &&
|
||||||
|
strncmp(batch[i].name, cmd.name, kMaxName) == 0) {
|
||||||
|
if (cmd.type == 2) {
|
||||||
|
batch[i].value = cmd.value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (cmd.type == 1) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batch[batch_len++] = cmd;
|
||||||
|
ok = true;
|
||||||
|
} else {
|
||||||
|
ok = enqueue_locked(cmd);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- httpd-обработчики (httpd-поток) ----------
|
||||||
|
static bool http_handler(const HttpRequest& req, HttpResponse& resp,
|
||||||
|
void* ctx);
|
||||||
|
|
||||||
|
void handle_key_exchange(const HttpRequest& req, HttpResponse& resp);
|
||||||
|
void handle_commands(HttpResponse& resp);
|
||||||
|
void handle_datapoint(const HttpRequest& req, HttpResponse& resp);
|
||||||
|
|
||||||
|
void build_get_payload(char* out, size_t out_cap, const Command& c,
|
||||||
|
int* seq_out);
|
||||||
|
void build_set_payload(char* out, size_t out_cap, const Command& c);
|
||||||
|
|
||||||
|
// ---------- session-поток ----------
|
||||||
|
static void session_thread_trampoline(void* ctx) {
|
||||||
|
static_cast<Impl*>(ctx)->session_loop();
|
||||||
|
}
|
||||||
|
void session_loop();
|
||||||
|
bool send_local_reg(bool notify, bool first);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// httpd
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
bool Session::Impl::http_handler(const HttpRequest& req, HttpResponse& resp,
|
||||||
|
void* ctx) {
|
||||||
|
auto* impl = static_cast<Impl*>(ctx);
|
||||||
|
if (strcmp(req.method, "POST") == 0) {
|
||||||
|
if (strcmp(req.target, "/local_lan/key_exchange.json") == 0) {
|
||||||
|
impl->handle_key_exchange(req, resp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (strcmp(req.target, "/local_lan/property/datapoint.json") == 0) {
|
||||||
|
impl->handle_datapoint(req, resp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (strcmp(req.target, "/local_lan/property/datapoint/ack.json") == 0 ||
|
||||||
|
strcmp(req.target, "/local_lan/node/property/datapoint.json") == 0 ||
|
||||||
|
strcmp(req.target, "/local_lan/node/property/datapoint/ack.json") == 0) {
|
||||||
|
FGL_LOGD("session: ack/node datapoint (пусто ok)");
|
||||||
|
resp.status = 200;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (strcmp(req.method, "GET") == 0) {
|
||||||
|
if (strcmp(req.target, "/local_lan/commands.json") == 0) {
|
||||||
|
if (!impl->crypto_ready) {
|
||||||
|
resp.status = 401;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
impl->handle_commands(resp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.status = 404;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Session::Impl::handle_key_exchange(const HttpRequest& req,
|
||||||
|
HttpResponse& resp) {
|
||||||
|
// Тело: {"key_exchange":{"ver":1,"proto":1,"key_id":N,"random_1":..,"time_1":N,"sec":""}}
|
||||||
|
char body[kHttpdMaxBody + 1];
|
||||||
|
size_t n = req.body_len < kHttpdMaxBody ? req.body_len : kHttpdMaxBody;
|
||||||
|
memcpy(body, req.body, n);
|
||||||
|
body[n] = '\0';
|
||||||
|
|
||||||
|
json::Doc doc;
|
||||||
|
if (!doc.parse(body)) {
|
||||||
|
resp.status = 400;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int64_t ver = 0, proto = 0, key_id = 0, time_1 = 0;
|
||||||
|
char random_1[32] = {}, sec[8] = {};
|
||||||
|
bool have_r1 = doc.get_string("random_1", random_1, sizeof(random_1));
|
||||||
|
bool have_t1 = doc.get_int("time_1", &time_1);
|
||||||
|
bool have_sec = doc.get_string("sec", sec, sizeof(sec));
|
||||||
|
bool have_ver = doc.get_int("ver", &ver);
|
||||||
|
bool have_proto = doc.get_int("proto", &proto);
|
||||||
|
bool have_kid = doc.get_int("key_id", &key_id);
|
||||||
|
if (!have_r1 || !have_t1 || !have_ver || !have_proto || !have_kid) {
|
||||||
|
FGL_LOGW("session: key_exchange неполный");
|
||||||
|
resp.status = 400;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ver != 1 || proto != 1 || (have_sec && sec[0] != '\0')) {
|
||||||
|
FGL_LOGW("session: key_exchange ver/proto/sec не поддержаны");
|
||||||
|
set_state(SessionState::kKeyError, SessionError::kBadKeyExchange);
|
||||||
|
resp.status = 426;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// sec длиннее буфера -> get_string=false, но поле есть: setup-режим
|
||||||
|
// не поддерживаем — тоже 426 (PROTOCOL §3.1).
|
||||||
|
const char* sec_start = nullptr;
|
||||||
|
size_t sec_len = 0;
|
||||||
|
jsmntype_t sec_type;
|
||||||
|
if (doc.find("sec", &sec_start, &sec_len, &sec_type) && sec_type == JSMN_STRING &&
|
||||||
|
sec_len >= sizeof(sec)) {
|
||||||
|
set_state(SessionState::kKeyError, SessionError::kBadKeyExchange);
|
||||||
|
resp.status = 426;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (static_cast<uint32_t>(key_id) != cfg.lanip_key_id) {
|
||||||
|
FGL_LOGE("session: key_id %lld != %u — ротация ключа?",
|
||||||
|
static_cast<long long>(key_id),
|
||||||
|
static_cast<unsigned>(cfg.lanip_key_id));
|
||||||
|
set_state(SessionState::kKeyError, SessionError::kKeyMismatch);
|
||||||
|
resp.status = 412;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gen_random_token(ke_random2, 16)) {
|
||||||
|
resp.status = 500;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int64_t time_2 = static_cast<int64_t>(plat::now_ms()) * 1000000ll;
|
||||||
|
crypto_ready = crypto.init(lanip_key, random_1, ke_random2, time_1,
|
||||||
|
time_2); // цепочки сбрасываются тут же
|
||||||
|
if (!crypto_ready) {
|
||||||
|
resp.status = 500;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
had_poll_since_ke.store(false, std::memory_order_release);
|
||||||
|
ke_time_ms.store(plat::now_ms(), std::memory_order_release);
|
||||||
|
bool rekey = ever_active.load(std::memory_order_acquire);
|
||||||
|
rekeys.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
if (!rekey && state.load(std::memory_order_acquire) !=
|
||||||
|
static_cast<uint8_t>(SessionState::kRegistering)) {
|
||||||
|
set_state(SessionState::kRegistering, SessionError::kNone);
|
||||||
|
}
|
||||||
|
FGL_LOGI("session: key exchange #%u (rekey=%d)",
|
||||||
|
static_cast<unsigned>(rekeys.load(std::memory_order_relaxed)), rekey);
|
||||||
|
|
||||||
|
static thread_local char out[128];
|
||||||
|
json::Writer w(out, sizeof(out));
|
||||||
|
w.begin_object();
|
||||||
|
w.key("random_2");
|
||||||
|
w.string(ke_random2);
|
||||||
|
w.key("time_2");
|
||||||
|
w.integer(time_2);
|
||||||
|
w.end_object();
|
||||||
|
resp.status = 200;
|
||||||
|
resp.body = reinterpret_cast<const uint8_t*>(out);
|
||||||
|
resp.body_len = strlen(out);
|
||||||
|
// буфер out живёт до конца ответа (send_response в handle_connection
|
||||||
|
// выполняется синхронно в том же кадре стека httpd-потока).
|
||||||
|
}
|
||||||
|
|
||||||
|
void Session::Impl::build_get_payload(char* out, size_t out_cap,
|
||||||
|
const Command& c, int* seq_out) {
|
||||||
|
(void)seq_out;
|
||||||
|
json::Writer w(out, out_cap);
|
||||||
|
w.begin_object();
|
||||||
|
w.key("cmds");
|
||||||
|
w.begin_array();
|
||||||
|
w.begin_object();
|
||||||
|
w.key("cmd");
|
||||||
|
w.begin_object();
|
||||||
|
w.key("cmd_id");
|
||||||
|
w.integer(c.cmd_id);
|
||||||
|
w.key("method");
|
||||||
|
w.string("GET");
|
||||||
|
w.key("resource");
|
||||||
|
char res[80];
|
||||||
|
snprintf(res, sizeof(res), "property.json?name=%s", c.name);
|
||||||
|
w.string(res);
|
||||||
|
w.key("data");
|
||||||
|
w.string("");
|
||||||
|
w.key("uri");
|
||||||
|
w.string("/local_lan/property/datapoint.json");
|
||||||
|
w.end_object();
|
||||||
|
w.end_object();
|
||||||
|
w.end_array();
|
||||||
|
w.end_object();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Session::Impl::build_set_payload(char* out, size_t out_cap,
|
||||||
|
const Command& c) {
|
||||||
|
json::Writer w(out, out_cap);
|
||||||
|
w.begin_object();
|
||||||
|
w.key("properties");
|
||||||
|
w.begin_array();
|
||||||
|
w.begin_object();
|
||||||
|
w.key("property");
|
||||||
|
w.begin_object();
|
||||||
|
w.key("base_type");
|
||||||
|
w.string(c.base_type);
|
||||||
|
w.key("name");
|
||||||
|
w.string(c.name);
|
||||||
|
w.key("value");
|
||||||
|
if (strcmp(c.base_type, "boolean") == 0) {
|
||||||
|
w.boolean(c.value != 0);
|
||||||
|
} else {
|
||||||
|
w.integer(c.value);
|
||||||
|
}
|
||||||
|
w.key("id");
|
||||||
|
char id[9];
|
||||||
|
gen_random_token(id, 8);
|
||||||
|
w.string(id);
|
||||||
|
w.end_object();
|
||||||
|
w.end_object();
|
||||||
|
w.end_array();
|
||||||
|
w.end_object();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Session::Impl::handle_commands(HttpResponse& resp) {
|
||||||
|
Command head{};
|
||||||
|
bool have = false;
|
||||||
|
uint8_t remaining = 0;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(queue_mu);
|
||||||
|
if (queue_len > 0) {
|
||||||
|
head = queue[0];
|
||||||
|
have = true;
|
||||||
|
queue_len--;
|
||||||
|
memmove(queue, queue + 1, queue_len * sizeof(Command));
|
||||||
|
remaining = queue_len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
char payload[512];
|
||||||
|
if (!have) {
|
||||||
|
payload[0] = '{';
|
||||||
|
payload[1] = '}';
|
||||||
|
payload[2] = '\0';
|
||||||
|
} else if (head.type == 1) {
|
||||||
|
build_get_payload(payload, sizeof(payload), head, nullptr);
|
||||||
|
} else if (head.type == 2) {
|
||||||
|
build_set_payload(payload, sizeof(payload), head);
|
||||||
|
} else { // DELETE session
|
||||||
|
json::Writer w(payload, sizeof(payload));
|
||||||
|
w.begin_object();
|
||||||
|
w.key("cmds");
|
||||||
|
w.begin_array();
|
||||||
|
w.begin_object();
|
||||||
|
w.key("cmd");
|
||||||
|
w.begin_object();
|
||||||
|
w.key("cmd_id");
|
||||||
|
w.integer(0);
|
||||||
|
w.key("method");
|
||||||
|
w.string("DELETE");
|
||||||
|
w.key("resource");
|
||||||
|
w.string("local_reg.json");
|
||||||
|
w.key("data");
|
||||||
|
w.string("delete_session");
|
||||||
|
w.key("uri");
|
||||||
|
w.string("/local_lan");
|
||||||
|
w.end_object();
|
||||||
|
w.end_object();
|
||||||
|
w.end_array();
|
||||||
|
w.end_object();
|
||||||
|
delete_served.store(true, std::memory_order_release);
|
||||||
|
delete_pending.store(false, std::memory_order_release);
|
||||||
|
FGL_LOGI("session: delete_session выдан модулю");
|
||||||
|
}
|
||||||
|
|
||||||
|
static thread_local char envelope[kEnvelopeMaxB64];
|
||||||
|
int64_t seq = out_seq++;
|
||||||
|
if (!envelope_pack(crypto.app, seq, payload, envelope, sizeof(envelope))) {
|
||||||
|
resp.status = 500;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cmds_served.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
had_poll_since_ke.store(true, std::memory_order_release);
|
||||||
|
uint8_t st8 = state.load(std::memory_order_acquire);
|
||||||
|
if (st8 == static_cast<uint8_t>(SessionState::kRegistering) ||
|
||||||
|
st8 == static_cast<uint8_t>(SessionState::kRecovering) ||
|
||||||
|
st8 == static_cast<uint8_t>(SessionState::kOffline)) {
|
||||||
|
// Опрос команд = сессия жива (в т.ч. после re-key при recovering/offline).
|
||||||
|
ever_active.store(true, std::memory_order_release);
|
||||||
|
decrypt_failed.store(false, std::memory_order_release);
|
||||||
|
set_state(SessionState::kOnline, SessionError::kNone);
|
||||||
|
want_notify.store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
resp.status = remaining > 0 ? 206 : 200;
|
||||||
|
resp.body = reinterpret_cast<const uint8_t*>(envelope);
|
||||||
|
resp.body_len = strlen(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Session::Impl::handle_datapoint(const HttpRequest& req,
|
||||||
|
HttpResponse& resp) {
|
||||||
|
size_t n = req.body_len < kHttpdMaxBody ? req.body_len : kHttpdMaxBody;
|
||||||
|
memcpy(dp_body, req.body, n);
|
||||||
|
dp_body[n] = '\0';
|
||||||
|
json::Doc wrap;
|
||||||
|
if (!wrap.parse(dp_body)) {
|
||||||
|
resp.status = 400;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!wrap.get_string("enc", dp_enc_b64, sizeof(dp_enc_b64)) ||
|
||||||
|
!wrap.get_string("sign", dp_sign_b64, sizeof(dp_sign_b64))) {
|
||||||
|
resp.status = 400;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t seq_no = -1;
|
||||||
|
if (!envelope_unpack(crypto.dev, dp_enc_b64, dp_sign_b64, dp_plain,
|
||||||
|
sizeof(dp_plain), &seq_no)) {
|
||||||
|
pushes_bad.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
FGL_LOGW("session: push не расшифрован/подпись (401); пауза и re-key");
|
||||||
|
if (state.load(std::memory_order_acquire) ==
|
||||||
|
static_cast<uint8_t>(SessionState::kOnline)) {
|
||||||
|
decrypt_failed.store(true, std::memory_order_release);
|
||||||
|
set_state(SessionState::kRecovering, SessionError::kDecryptFailed);
|
||||||
|
// Стратегия восстановления (PROTOCOL §4.4): замолчать на > порога
|
||||||
|
// «возврата» (~44-50с) — следующий local_reg заставит модуль re-key.
|
||||||
|
quiet_until_ms.store(plat::now_ms() + timings.recovering_quiet_ms,
|
||||||
|
std::memory_order_release);
|
||||||
|
}
|
||||||
|
resp.status = 401;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Восстановление после 401 с живой цепочкой (бракованная подпись):
|
||||||
|
// сообщение расшифровано — отменяем тишину и возвращаем online.
|
||||||
|
if (decrypt_failed.exchange(false, std::memory_order_acq_rel)) {
|
||||||
|
quiet_until_ms.store(0, std::memory_order_release);
|
||||||
|
set_state(SessionState::kOnline, SessionError::kNone);
|
||||||
|
FGL_LOGI("session: цепочка восстановлена (успешный push после 401)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// {"seq_no":N,"data":{"name":..,"value":..}}
|
||||||
|
json::Doc top;
|
||||||
|
if (!top.parse(dp_plain)) {
|
||||||
|
pushes_ok.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
resp.status = 200;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const char* data_start = nullptr;
|
||||||
|
size_t data_len = 0;
|
||||||
|
jsmntype_t data_type;
|
||||||
|
PropertyEvent ev{};
|
||||||
|
ev.seq_no = seq_no;
|
||||||
|
if (top.find("data", &data_start, &data_len, &data_type) &&
|
||||||
|
data_type == JSMN_OBJECT && data_len < sizeof(dp_data)) {
|
||||||
|
memcpy(dp_data, data_start, data_len);
|
||||||
|
dp_data[data_len] = '\0';
|
||||||
|
{
|
||||||
|
json::Doc data_doc;
|
||||||
|
if (data_doc.parse(dp_data)) {
|
||||||
|
char name[kMaxName];
|
||||||
|
if (data_doc.get_string("name", name, sizeof(name))) {
|
||||||
|
snprintf(ev.name, sizeof(ev.name), "%s", name);
|
||||||
|
int64_t iv = 0;
|
||||||
|
bool bv = false;
|
||||||
|
const char* sv = nullptr;
|
||||||
|
size_t sv_len = 0;
|
||||||
|
jsmntype_t vt;
|
||||||
|
if (data_doc.get_int("value", &iv)) {
|
||||||
|
ev.is_int = true;
|
||||||
|
ev.int_value = iv;
|
||||||
|
} else if (data_doc.get_bool("value", &bv)) {
|
||||||
|
ev.is_bool = true;
|
||||||
|
ev.bool_value = bv;
|
||||||
|
} else if (data_doc.find("value", &sv, &sv_len, &vt) &&
|
||||||
|
vt == JSMN_STRING && sv_len + 1 <= sizeof(ev.str_value)) {
|
||||||
|
memcpy(ev.str_value, sv, sv_len);
|
||||||
|
ev.str_value[sv_len] = '\0';
|
||||||
|
}
|
||||||
|
int cmd_id = -1, status = 0;
|
||||||
|
parse_query(req.query, &cmd_id, &status);
|
||||||
|
ev.cmd_id = cmd_id;
|
||||||
|
ev.status = status;
|
||||||
|
if (cbs.on_property != nullptr) {
|
||||||
|
cbs.on_property(cbs.ctx, ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pushes_ok.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
resp.status = 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// session-поток: local_reg / keep-alive / backoff / таймауты
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
bool Session::Impl::send_local_reg(bool notify, bool first) {
|
||||||
|
char path[96];
|
||||||
|
if (first) {
|
||||||
|
snprintf(path, sizeof(path), "/local_reg.json?dsn=%s", dsn);
|
||||||
|
} else {
|
||||||
|
snprintf(path, sizeof(path), "/local_reg.json");
|
||||||
|
}
|
||||||
|
char local_ip[24];
|
||||||
|
if (!plat::local_ip_for(host, local_ip, sizeof(local_ip))) {
|
||||||
|
FGL_LOGW("session: local_ip_for(%s) failed", host);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
char body[160];
|
||||||
|
json::Writer w(body, sizeof(body));
|
||||||
|
w.begin_object();
|
||||||
|
w.key("local_reg");
|
||||||
|
w.begin_object();
|
||||||
|
w.key("ip");
|
||||||
|
w.string(local_ip);
|
||||||
|
w.key("notify");
|
||||||
|
w.boolean(notify);
|
||||||
|
w.key("port");
|
||||||
|
w.integer(listen_port_actual);
|
||||||
|
w.key("uri");
|
||||||
|
w.string("/local_lan");
|
||||||
|
w.end_object();
|
||||||
|
w.end_object();
|
||||||
|
if (!w.ok()) return false;
|
||||||
|
|
||||||
|
HttpcRequest req;
|
||||||
|
req.method = first ? "POST" : "PUT";
|
||||||
|
req.host = host;
|
||||||
|
req.port = cfg.device_port;
|
||||||
|
req.path = path;
|
||||||
|
req.body = reinterpret_cast<const uint8_t*>(body);
|
||||||
|
req.body_len = strlen(body);
|
||||||
|
req.timeout_ms = 5000;
|
||||||
|
HttpcResponse resp;
|
||||||
|
if (!httpc_perform(req, &resp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
last_local_reg_ms.store(plat::now_ms(), std::memory_order_release);
|
||||||
|
if (resp.status == 503) {
|
||||||
|
set_state(SessionState::kOffline, SessionError::kNoSlot);
|
||||||
|
retry_at_ms.store(plat::now_ms() + timings.no_slot_retry_ms,
|
||||||
|
std::memory_order_release);
|
||||||
|
FGL_LOGW("session: local_reg -> 503 (нет слотов)");
|
||||||
|
backoff_attempts = 0;
|
||||||
|
backoff_ms = 0;
|
||||||
|
reg_ok = false; // сессия не активировалась — следующий local_reg POST?dsn
|
||||||
|
return true; // транспорт ок — это протокольный ответ
|
||||||
|
}
|
||||||
|
if (resp.status != 202 && resp.status != 200) {
|
||||||
|
FGL_LOGW("session: local_reg -> %d", resp.status);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (state.load(std::memory_order_acquire) ==
|
||||||
|
static_cast<uint8_t>(SessionState::kIdle)) {
|
||||||
|
set_state(SessionState::kRegistering, SessionError::kNone);
|
||||||
|
}
|
||||||
|
backoff_attempts = 0;
|
||||||
|
backoff_ms = 0;
|
||||||
|
reg_ok = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Session::Impl::session_loop() {
|
||||||
|
bool first_reg = true;
|
||||||
|
while (running.load(std::memory_order_acquire)) {
|
||||||
|
uint64_t now = plat::now_ms();
|
||||||
|
SessionState st =
|
||||||
|
static_cast<SessionState>(state.load(std::memory_order_acquire));
|
||||||
|
|
||||||
|
// kKeyError — устойчивая ошибка: конфиг менять вручную, не дёргаем модуль.
|
||||||
|
if (st == SessionState::kKeyError) {
|
||||||
|
plat::sleep_ms(kLoopTickMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тишина (восстановление после «KE без poll»).
|
||||||
|
if (now < quiet_until_ms.load(std::memory_order_acquire)) {
|
||||||
|
plat::sleep_ms(kLoopTickMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Активация: KE отвечен, но опроса нет.
|
||||||
|
uint64_t ke_time = ke_time_ms.load(std::memory_order_acquire);
|
||||||
|
if (st == SessionState::kRegistering && ke_time != 0 &&
|
||||||
|
!had_poll_since_ke.load(std::memory_order_acquire) &&
|
||||||
|
now - ke_time > timings.activation_timeout_ms) {
|
||||||
|
FGL_LOGW("session: активация не наступила (KE без poll) — пауза %ums",
|
||||||
|
static_cast<unsigned>(timings.recovering_quiet_ms));
|
||||||
|
ke_time_ms.store(0, std::memory_order_release);
|
||||||
|
quiet_until_ms.store(now + timings.recovering_quiet_ms,
|
||||||
|
std::memory_order_release);
|
||||||
|
set_state(SessionState::kRecovering, SessionError::kActivationTimeout);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backoff / отложенный повтор.
|
||||||
|
if (now < retry_at_ms.load(std::memory_order_acquire)) {
|
||||||
|
plat::sleep_ms(kLoopTickMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// local_reg: по keep-alive, по notify (batch/online) или первичный.
|
||||||
|
bool queue_nonempty;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(queue_mu);
|
||||||
|
queue_nonempty = queue_len > 0;
|
||||||
|
}
|
||||||
|
bool notify = queue_nonempty || want_notify.exchange(false,
|
||||||
|
std::memory_order_acq_rel);
|
||||||
|
uint64_t last_reg = last_local_reg_ms.load(std::memory_order_acquire);
|
||||||
|
bool due = notify || last_reg == 0 ||
|
||||||
|
now - last_reg >= timings.keepalive_ms;
|
||||||
|
if (!due) {
|
||||||
|
plat::sleep_ms(kLoopTickMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool first = first_reg || !reg_ok;
|
||||||
|
if (!send_local_reg(queue_nonempty, first)) {
|
||||||
|
// Транспортная ошибка: backoff.
|
||||||
|
backoff_ms = backoff_ms == 0 ? timings.backoff_base_ms
|
||||||
|
: (backoff_ms * 8) / 5; // x1.6
|
||||||
|
if (backoff_ms > timings.backoff_max_ms) {
|
||||||
|
backoff_ms = timings.backoff_max_ms;
|
||||||
|
}
|
||||||
|
backoff_attempts++;
|
||||||
|
if (backoff_attempts >= timings.backoff_attempts) {
|
||||||
|
set_state(SessionState::kOffline, SessionError::kUnreachable);
|
||||||
|
retry_at_ms.store(now + timings.backoff_max_ms,
|
||||||
|
std::memory_order_release);
|
||||||
|
backoff_attempts = 0;
|
||||||
|
backoff_ms = 0;
|
||||||
|
} else {
|
||||||
|
retry_at_ms.store(now + backoff_ms, std::memory_order_release);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (reg_ok) first_reg = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session (публичный класс)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Session* Session::create(const SessionConfig& cfg, const SessionCallbacks& cbs) {
|
||||||
|
if (cfg.host == nullptr || cfg.dsn == nullptr || cfg.lanip_key == nullptr) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (strlen(cfg.lanip_key) >= sizeof(Impl::lanip_key) ||
|
||||||
|
strlen(cfg.dsn) >= sizeof(Impl::dsn) ||
|
||||||
|
strlen(cfg.host) >= sizeof(Impl::host)) {
|
||||||
|
return nullptr; // не помещается во внутренние копии
|
||||||
|
}
|
||||||
|
auto* impl = new (std::nothrow) Impl();
|
||||||
|
if (impl == nullptr) return nullptr;
|
||||||
|
impl->cfg = cfg;
|
||||||
|
impl->cbs = cbs;
|
||||||
|
if (impl->cfg.max_queue == 0) impl->cfg.max_queue = 16;
|
||||||
|
if (impl->cfg.keepalive_ms == 0) impl->cfg.keepalive_ms = 15000;
|
||||||
|
impl->timings.keepalive_ms = impl->cfg.keepalive_ms;
|
||||||
|
snprintf(impl->host, sizeof(impl->host), "%s", cfg.host);
|
||||||
|
snprintf(impl->dsn, sizeof(impl->dsn), "%s", cfg.dsn);
|
||||||
|
snprintf(impl->lanip_key, sizeof(impl->lanip_key), "%s", cfg.lanip_key);
|
||||||
|
auto* s = new (std::nothrow) Session(cfg, cbs);
|
||||||
|
if (s == nullptr) {
|
||||||
|
delete impl;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
s->impl_ = impl;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
Session::Session(const SessionConfig&, const SessionCallbacks&) : impl_(nullptr) {}
|
||||||
|
|
||||||
|
SessionError Session::last_error() const {
|
||||||
|
if (impl_ == nullptr) return SessionError::kNone;
|
||||||
|
return static_cast<SessionError>(impl_->last_error.load(std::memory_order_acquire));
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionState Session::state() const {
|
||||||
|
if (impl_ == nullptr) return SessionState::kIdle;
|
||||||
|
return static_cast<SessionState>(impl_->state.load(std::memory_order_acquire));
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t Session::listen_port() const {
|
||||||
|
return impl_ != nullptr ? impl_->listen_port_actual : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t Session::rekey_count() const {
|
||||||
|
return impl_ != nullptr ? impl_->rekeys.load(std::memory_order_relaxed) : 0;
|
||||||
|
}
|
||||||
|
uint32_t Session::pushes_ok() const {
|
||||||
|
return impl_ != nullptr ? impl_->pushes_ok.load(std::memory_order_relaxed) : 0;
|
||||||
|
}
|
||||||
|
uint32_t Session::pushes_bad() const {
|
||||||
|
return impl_ != nullptr ? impl_->pushes_bad.load(std::memory_order_relaxed) : 0;
|
||||||
|
}
|
||||||
|
uint32_t Session::commands_served() const {
|
||||||
|
return impl_ != nullptr ? impl_->cmds_served.load(std::memory_order_relaxed) : 0;
|
||||||
|
}
|
||||||
|
bool Session::had_activity() const {
|
||||||
|
return impl_ != nullptr &&
|
||||||
|
impl_->had_poll_since_ke.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
Session::~Session() {
|
||||||
|
stop();
|
||||||
|
delete impl_;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::start() {
|
||||||
|
if (impl_ == nullptr || impl_->running.load(std::memory_order_acquire)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!impl_->httpd.start(impl_->cfg.listen_port, Impl::http_handler, impl_,
|
||||||
|
"fgl_session")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
impl_->listen_port_actual = impl_->httpd.port();
|
||||||
|
impl_->running.store(true, std::memory_order_release);
|
||||||
|
if (!plat::thread_create(Impl::session_thread_trampoline, impl_,
|
||||||
|
"fgl_sess", 8192, &impl_->thread)) {
|
||||||
|
impl_->running.store(false, std::memory_order_release);
|
||||||
|
impl_->httpd.stop();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Session::stop() {
|
||||||
|
if (impl_ == nullptr || !impl_->running.load(std::memory_order_acquire)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Штатное завершение: DELETE-команда + notify local_reg. Сессионный поток
|
||||||
|
// ещё работает и доставит notify; ждём выдачи команды модулю.
|
||||||
|
if (impl_->state.load(std::memory_order_acquire) !=
|
||||||
|
static_cast<uint8_t>(SessionState::kKeyError)) {
|
||||||
|
delete_session();
|
||||||
|
uint64_t deadline = plat::now_ms() + impl_->timings.delete_wait_ms;
|
||||||
|
while (plat::now_ms() < deadline &&
|
||||||
|
!impl_->delete_served.load(std::memory_order_acquire)) {
|
||||||
|
plat::sleep_ms(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl_->running.store(false, std::memory_order_release);
|
||||||
|
if (impl_->thread != nullptr) {
|
||||||
|
plat::thread_join(impl_->thread);
|
||||||
|
impl_->thread = nullptr;
|
||||||
|
}
|
||||||
|
impl_->httpd.stop();
|
||||||
|
// Сброс для возможного рестарта.
|
||||||
|
impl_->delete_served.store(false, std::memory_order_release);
|
||||||
|
impl_->delete_pending.store(false, std::memory_order_release);
|
||||||
|
impl_->quiet_until_ms.store(0, std::memory_order_release);
|
||||||
|
impl_->retry_at_ms.store(0, std::memory_order_release);
|
||||||
|
impl_->ke_time_ms.store(0, std::memory_order_release);
|
||||||
|
impl_->set_state(SessionState::kIdle, SessionError::kNone);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::get_property(const char* name) {
|
||||||
|
if (impl_ == nullptr || name == nullptr || strlen(name) >= kMaxName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Command cmd{};
|
||||||
|
cmd.type = 1;
|
||||||
|
snprintf(cmd.name, sizeof(cmd.name), "%s", name);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
||||||
|
cmd.cmd_id = impl_->next_cmd_id++;
|
||||||
|
}
|
||||||
|
if (!impl_->submit(cmd)) return false;
|
||||||
|
impl_->want_notify.store(true, std::memory_order_release);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::set_property(const char* name, int64_t value,
|
||||||
|
const char* base_type) {
|
||||||
|
if (impl_ == nullptr || name == nullptr || strlen(name) >= kMaxName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Command cmd{};
|
||||||
|
cmd.type = 2;
|
||||||
|
snprintf(cmd.name, sizeof(cmd.name), "%s", name);
|
||||||
|
cmd.value = value;
|
||||||
|
snprintf(cmd.base_type, sizeof(cmd.base_type), "%s", base_type);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
||||||
|
cmd.cmd_id = impl_->next_cmd_id++;
|
||||||
|
}
|
||||||
|
if (!impl_->submit(cmd)) return false;
|
||||||
|
impl_->want_notify.store(true, std::memory_order_release);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Session::set_timings_for_test(const SessionTimings& t) {
|
||||||
|
if (impl_ == nullptr) return;
|
||||||
|
SessionTimings tmp = t;
|
||||||
|
if (tmp.keepalive_ms < 100) tmp.keepalive_ms = 100; // анти-спам
|
||||||
|
impl_->timings = tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::begin_batch() {
|
||||||
|
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
||||||
|
if (impl_->batch_open) return false;
|
||||||
|
impl_->batch_open = true;
|
||||||
|
impl_->batch_len = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::commit_batch() {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
||||||
|
if (!impl_->batch_open) return false;
|
||||||
|
bool ok = true;
|
||||||
|
for (uint8_t i = 0; i < impl_->batch_len; i++) {
|
||||||
|
if (!impl_->enqueue_locked(impl_->batch[i])) ok = false;
|
||||||
|
}
|
||||||
|
impl_->batch_open = false;
|
||||||
|
impl_->batch_len = 0;
|
||||||
|
if (!ok) return false;
|
||||||
|
}
|
||||||
|
impl_->want_notify.store(true, std::memory_order_release);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::abort_batch() {
|
||||||
|
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
||||||
|
if (!impl_->batch_open) return false;
|
||||||
|
impl_->batch_open = false;
|
||||||
|
impl_->batch_len = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Session::delete_session() {
|
||||||
|
if (impl_ == nullptr) return false;
|
||||||
|
Command cmd{};
|
||||||
|
cmd.type = 3;
|
||||||
|
snprintf(cmd.name, sizeof(cmd.name), "local_reg.json");
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
||||||
|
if (!impl_->enqueue_locked(cmd)) return false;
|
||||||
|
}
|
||||||
|
impl_->delete_pending.store(true, std::memory_order_release);
|
||||||
|
impl_->want_notify.store(true, std::memory_order_release);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgl::ayla
|
||||||
126
src/ayla/session.hpp
Normal file
126
src/ayla/session.hpp
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// Сессия Ayla LAN (сторона «приложения»). docs/PROTOCOL.md §4-6, §4.4.
|
||||||
|
// Потоки: httpd (входящие от модуля: key exchange/commands/datapoint) и
|
||||||
|
// session (исходящие local_reg, таймеры, backoff). Крипто-цепочки и выдача
|
||||||
|
// команд — только в httpd-потоке; session-поток читает очередь под mutex.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "ayla/crypto.hpp"
|
||||||
|
#include "ayla/httpd.hpp"
|
||||||
|
|
||||||
|
namespace fgl::ayla {
|
||||||
|
|
||||||
|
enum class SessionState : uint8_t {
|
||||||
|
kIdle = 0, // создан, не запущен
|
||||||
|
kRegistering, // local_reg отправлен, ждём key exchange
|
||||||
|
kOnline, // сессия активна
|
||||||
|
kRecovering, // ожидание самолечения (re-key по keep-alive / активация)
|
||||||
|
kOffline, // модуль недоступен (backoff) или нет слотов
|
||||||
|
kKeyError, // lanip_key_id не совпал — требуется смена конфига
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class SessionError : int {
|
||||||
|
kNone = 0,
|
||||||
|
kNoSlot = 1, // 503: оба слота модуля заняты
|
||||||
|
kUnreachable = 2, // transport/backoff
|
||||||
|
kKeyMismatch = 3, // key_id != lanip_key_id (state = kKeyError)
|
||||||
|
kBadKeyExchange = 4, // ver/proto/sec не поддержаны
|
||||||
|
kActivationTimeout = 5,// KE прошёл, опроса commands.json нет (>5 c)
|
||||||
|
kDecryptFailed = 6, // подпись/расшифровка push не сошлись (ждём re-key)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Событие обновления свойства (push модуля). Значение — какой-то один тип.
|
||||||
|
struct PropertyEvent {
|
||||||
|
char name[40];
|
||||||
|
bool is_int = false;
|
||||||
|
int64_t int_value = 0;
|
||||||
|
bool is_bool = false;
|
||||||
|
bool bool_value = false;
|
||||||
|
char str_value[64]; // используется, если !is_int && !is_bool
|
||||||
|
int cmd_id = -1; // из ?cmd_id=N (ответ на GET), иначе -1
|
||||||
|
int status = 0; // из ?status=200
|
||||||
|
int64_t seq_no = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SessionConfig {
|
||||||
|
const char* host = nullptr; // DNS-имя или IP модуля
|
||||||
|
uint16_t device_port = 80; // порт local_reg модуля
|
||||||
|
const char* dsn = nullptr; // "AC000W00XXXXXXX"
|
||||||
|
const char* lanip_key = nullptr; // base64-строка как есть
|
||||||
|
uint32_t lanip_key_id = 0;
|
||||||
|
uint16_t listen_port = 10275; // 0 — любой свободный
|
||||||
|
uint32_t keepalive_ms = 15000;
|
||||||
|
uint8_t max_queue = 16; // лимит очереди команд
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SessionCallbacks {
|
||||||
|
// КОНТРАКТ: колбэки приходят из потоков ядра (httpd и/или session),
|
||||||
|
// возможно перекрытие во времени; обязаны быть быстрыми и реентерабельными.
|
||||||
|
// Вызывать stop() из колбэка запрещено (deadlock на join).
|
||||||
|
void (*on_state)(void* ctx, SessionState st, SessionError err);
|
||||||
|
void (*on_property)(void* ctx, const PropertyEvent& ev);
|
||||||
|
void* ctx = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Тайминги поведения (PROTOCOL §4.3-4.4; проверено на приборе).
|
||||||
|
struct SessionTimings {
|
||||||
|
uint32_t keepalive_ms = 15000; // период local_reg
|
||||||
|
uint32_t activation_timeout_ms = 5000; // нет poll после KE
|
||||||
|
uint32_t recovering_quiet_ms = 50000; // пауза при десинке/«KE без poll»
|
||||||
|
// (> порога возврата модуля ~44-50с)
|
||||||
|
uint32_t no_slot_retry_ms = 60000; // повтор после 503
|
||||||
|
uint32_t backoff_base_ms = 1000; // transport backoff, шаг x1.6
|
||||||
|
uint32_t backoff_max_ms = 60000;
|
||||||
|
uint8_t backoff_attempts = 6;
|
||||||
|
uint32_t delete_wait_ms = 2000;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Session {
|
||||||
|
public:
|
||||||
|
static Session* create(const SessionConfig& cfg, const SessionCallbacks& cbs);
|
||||||
|
~Session();
|
||||||
|
|
||||||
|
Session(const Session&) = delete;
|
||||||
|
Session& operator=(const Session&) = delete;
|
||||||
|
|
||||||
|
bool start();
|
||||||
|
// Штатное завершение: DELETE-команда + local_reg notify, ожидание выдачи,
|
||||||
|
// остановка потоков. state -> kIdle.
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
SessionState state() const;
|
||||||
|
SessionError last_error() const;
|
||||||
|
uint16_t listen_port() const;
|
||||||
|
// Телеметрия (диагностика).
|
||||||
|
uint32_t rekey_count() const;
|
||||||
|
uint32_t pushes_ok() const;
|
||||||
|
uint32_t pushes_bad() const;
|
||||||
|
uint32_t commands_served() const;
|
||||||
|
bool had_activity() const;
|
||||||
|
|
||||||
|
// ---- Команды (потокобезопасны; кладутся в очередь с coalescing) ----
|
||||||
|
// GET-команда: свойство придёт on_property (cmd_id совпадает).
|
||||||
|
bool get_property(const char* name);
|
||||||
|
// SET-команда (integer/boolean как int64).
|
||||||
|
bool set_property(const char* name, int64_t value,
|
||||||
|
const char* base_type = "integer");
|
||||||
|
// Пакет: собрать несколько команд, один notify на commit.
|
||||||
|
bool begin_batch();
|
||||||
|
bool commit_batch();
|
||||||
|
bool abort_batch();
|
||||||
|
// DELETE local_reg.json/delete_session (для stop() и ручного завершения).
|
||||||
|
bool delete_session();
|
||||||
|
|
||||||
|
// Тест-хук: тайминги. ТОЛЬКО до start() (после — читаются потоками ядра).
|
||||||
|
void set_timings_for_test(const SessionTimings& t);
|
||||||
|
|
||||||
|
private:
|
||||||
|
Session(const SessionConfig& cfg, const SessionCallbacks& cbs);
|
||||||
|
struct Impl;
|
||||||
|
Impl* impl_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace fgl::ayla
|
||||||
42
tests/CMakeLists.txt
Normal file
42
tests/CMakeLists.txt
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Тесты ядра. Структура зеркалит src: tests/ayla — протоколная часть,
|
||||||
|
# tests/aircon — конверсии/шаблоны (появятся в M3).
|
||||||
|
#
|
||||||
|
# Тесты собираются с исключениями (фреймворк), сама библиотека — без.
|
||||||
|
|
||||||
|
include(FetchContent)
|
||||||
|
FetchContent_Declare(
|
||||||
|
doctest
|
||||||
|
GIT_REPOSITORY https://github.com/doctest/doctest.git
|
||||||
|
GIT_TAG v2.4.12
|
||||||
|
GIT_SHALLOW TRUE
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(doctest)
|
||||||
|
|
||||||
|
function(fgl_add_test name)
|
||||||
|
add_executable(test_${name} ${ARGN})
|
||||||
|
target_compile_features(test_${name} PRIVATE cxx_std_20)
|
||||||
|
target_link_libraries(test_${name} PRIVATE fgl-aircon doctest_with_main)
|
||||||
|
target_include_directories(test_${name} PRIVATE "${CMAKE_SOURCE_DIR}/src")
|
||||||
|
add_test(NAME ${name} COMMAND test_${name})
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
# Раннер сессии — не тест, приложение для интеграционных сценариев
|
||||||
|
# (запускается tests/ayla/test_session_mock.py через ctest).
|
||||||
|
add_executable(session_runner ayla/session_runner.cpp)
|
||||||
|
target_compile_features(session_runner PRIVATE cxx_std_20)
|
||||||
|
target_link_libraries(session_runner PRIVATE fgl-aircon)
|
||||||
|
target_include_directories(session_runner PRIVATE "${CMAKE_SOURCE_DIR}/src")
|
||||||
|
|
||||||
|
fgl_add_test(ayla_platform ayla/test_platform.cpp)
|
||||||
|
fgl_add_test(ayla_httpd ayla/test_httpd.cpp)
|
||||||
|
fgl_add_test(ayla_crypto ayla/test_crypto.cpp)
|
||||||
|
fgl_add_test(ayla_envelope ayla/test_envelope.cpp)
|
||||||
|
fgl_add_test(ayla_json ayla/test_json.cpp)
|
||||||
|
fgl_add_test(ayla_httpc ayla/test_httpc.cpp)
|
||||||
|
|
||||||
|
# Интеграционные сценарии с mock-модулем (python, stdlib-only).
|
||||||
|
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
||||||
|
add_test(NAME ayla_session_mock
|
||||||
|
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/ayla/test_session_mock.py
|
||||||
|
$<TARGET_FILE:session_runner> ${CMAKE_CURRENT_SOURCE_DIR}/ayla/mock_ac.py)
|
||||||
|
set_tests_properties(ayla_session_mock PROPERTIES TIMEOUT 180)
|
||||||
29
tests/ayla/kdf_vectors.hpp
Normal file
29
tests/ayla/kdf_vectors.hpp
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// СГЕНЕРИРОВАНО scripts/gen_kdf_vectors.py (эталон = APK AylaEncryption).
|
||||||
|
// Не редактировать руками; регенерация:
|
||||||
|
// python scripts/gen_kdf_vectors.py > tests/ayla/kdf_vectors.hpp
|
||||||
|
#pragma once
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
namespace fgl::ayla::test {
|
||||||
|
|
||||||
|
struct KdfVector {
|
||||||
|
const char* lanip_key;
|
||||||
|
const char* random_1;
|
||||||
|
const char* random_2;
|
||||||
|
long long time_1;
|
||||||
|
long long time_2;
|
||||||
|
const char* plaintexts[4];
|
||||||
|
const char* enc_base64[4]; // 0..2: Java-паддинг; 3: legacy без NUL
|
||||||
|
const char* app_sign_msg0_b64;
|
||||||
|
const char* dev_sign_msg1_b64;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline constexpr KdfVector kKdfVectors[] = {
|
||||||
|
{"nRB152rvLrgpUyysvyS6", "Unajei61hBtnAXvL", "8q8tgzOx60ehhAtF", 274989731965726LL, 847349374141033LL, {"{\"seq_no\":0,\"data\":{}}", "{\"seq_no\":1,\"data\":{\"cmds\":[{\"cmd\":{\"method\":\"GET\",\"resource\":\"property.json?name=fan_speed\",\"uri\":\"/local_lan/property/datapoint.json\",\"data\":\"\",\"cmd_id\":3}}]}}", "{\"seq_no\":2,\"data\":{\"properties\":[{\"property\":{\"base_type\":\"integer\",\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}}", "{\"seq_no\":3,\"data\":{\"pad\":\"XX\"}}"}, {"hk/Iha4XexydQeV7cQ9K4rtNo4hMRPKaCBIINcuU0yo=", "FZnpg9JTsS+56Eh+80V9RlFn7iCVWlGU3xq9Wm8s9GfqLnq49I/V386EqnAMTAT3hZvq46WDGRnymwmlJF+gtOCoTlzvkD/2ozKtXeFMLtidTmqzDddOHP+uPNEXZcuVsfpuN2dLPctqTmM827lrbpwctKvuNTmzd1L6gpdgSQ4e9V4sd+9OA/fI1Eu4E1maJ+Tm4QdpKADrj50oh2qRsOqdjhAaWOjT5lgauLISqno=", "SZa7swUooRnYenDVlVocX8IoLIK9QZm1wybIEVE44g1Q63SDQ4ulpbOvgvTkmTuN4krAGJA6qKt7pm2HYhyIDhMz/fmNgee+FrK/C3Fdy1bFPl8SESR8UL9R7eN2TSxrtK51+uyFvm6DMvft6zmjZnfdyRkjfCN0gI8bGLXN4yU=", "l1DRivEQAazsO+2uWd0qdc00P6qjw5nIK7skXfYn9Bo="}, "IODB6lZl2ClQ/CHa3e0GMbG466jlZ59up4XgVP/00wY=", "sKBzGqhFIPb8DL8KbK0Np2Zag9C80JS8oHLiDXTu2DE="},
|
||||||
|
{"e3YfUELFmkeD4BWjSSnYxA==", "cxtcwrhtsyAcHm8S", "wfdv8OHvq0h03YnY", 761048435275066LL, 11876266770736LL, {"{\"seq_no\":0,\"data\":{}}", "{\"seq_no\":1,\"data\":{\"cmds\":[{\"cmd\":{\"method\":\"GET\",\"resource\":\"property.json?name=fan_speed\",\"uri\":\"/local_lan/property/datapoint.json\",\"data\":\"\",\"cmd_id\":3}}]}}", "{\"seq_no\":2,\"data\":{\"properties\":[{\"property\":{\"base_type\":\"integer\",\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}}", "{\"seq_no\":3,\"data\":{\"pad\":\"XX\"}}"}, {"AhUcfcYews8/ip360hj3RbKoCt8hwgEA3791gG1IQSM=", "S5PS+KqJ9vIu65Fd+hcnvllI5R34enCE7BT6ukZjdRoVTIEOhIQCzusUcu0vlm2ZLqJwSRBYPFlQKkGh5VDXyJZLQLj+tFZDCf2n4d6wPcFBHsH3VEZy8yl+odPXc6ogAEwtyJhBZ9fURrNgQiveWNG42zTQ13dElqU2RD1bmfilJe0FF84cdre8bHJm7HyWAg5XdQ4qOVzZhH+EWE1XfWeegPDoIsi5ch8QJOiK7KY=", "qbEQTeBvTYYDfFUVwf5oK8slPxW3vUk9jbN6z3sAoQ17s1OiaqHJQrYFr0dDQIvuQgqAcj8+lRtc97papq4Fgpz/QAKCDIZujClJR9KWHBUrTqE8j89QjwjaQZ8ze+hzqIvebwt2C7vM/YivEJz9aSuxi1iLcCcEhnR+VlHoeBw=", "+aAezE8EmyfdRzJnIrsFC3NYxsZRswDkhPNc7qypCZ8="}, "4sW8EmpwLVz/pPN7MZ0Art+uz7uZbZI7lDBuI0Lzjtw=", "h8bqQ1umSr3pTReBno61eTSsK5TCA+2xGAP+uzzx6FE="},
|
||||||
|
{"deQG9G4zw74OZBzgL31jxCI=", "AlUvHAXene8hTnpq", "rSvwucWBRIQgcG0i", 299350142996752LL, 501892609552265LL, {"{\"seq_no\":0,\"data\":{}}", "{\"seq_no\":1,\"data\":{\"cmds\":[{\"cmd\":{\"method\":\"GET\",\"resource\":\"property.json?name=fan_speed\",\"uri\":\"/local_lan/property/datapoint.json\",\"data\":\"\",\"cmd_id\":3}}]}}", "{\"seq_no\":2,\"data\":{\"properties\":[{\"property\":{\"base_type\":\"integer\",\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}}", "{\"seq_no\":3,\"data\":{\"pad\":\"XX\"}}"}, {"QSdv/i/vwK6JIni4wmMstgf0PiRc/fAPKkszlQrHeV8=", "q5t4RkNU8bu67S15mi27dT28UwWCcVQLh/PgBshhhQVljm950hqGsKPJMSNy5Bul41Ut6bd6sPGdUuFl82FPecIkloDTAx0e0htDjaESEcdUTao4Uxn4yLILG8PKHGog2bHHfGnG7AIk9XFNKnxXCIRtJetgu57gWTlFSxf4UIG+8zBZmb1Zn0R6csUkrFLCAzjfQzx430Gagcw1+lyfgL4EoZgx6SJhWQQ9mM/uyxE=", "eY/Y17nxXaSGSNKjq0XEDYw+Ks55Sze7bZw5yKGnwI6iS8uODSBila54I630XOp/mlAvdVE09StUi7xPAkrCG0gDJ7DsMLFONZR3HDWf7f63iYpdOOQlXRnH8SnQ7pmu4mqRp2JE/NqdWCWT6AMUzXOWDUbxfl3OShLrZf29uDU=", "8RWdrVA+NRbqkT4aXTgm0djTjfyz5rC2fGoh+RfRqow="}, "Z5Ah10Y/qJngN2DUJhhkEytTZsFCOiV2TaeTh61X5pc=", "TcQdYNIoQXEeDZd9ZJWAn9tayz8Zc6/ZR2MtnvQ6/xc="},
|
||||||
|
{"v8CQFGwiB+/z9UUEGdu7rZ0a", "27XqoPSfZ4553E3K", "rbwC6T3cXdodlhIS", 257649300961690LL, 851173235636538LL, {"{\"seq_no\":0,\"data\":{}}", "{\"seq_no\":1,\"data\":{\"cmds\":[{\"cmd\":{\"method\":\"GET\",\"resource\":\"property.json?name=fan_speed\",\"uri\":\"/local_lan/property/datapoint.json\",\"data\":\"\",\"cmd_id\":3}}]}}", "{\"seq_no\":2,\"data\":{\"properties\":[{\"property\":{\"base_type\":\"integer\",\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}}", "{\"seq_no\":3,\"data\":{\"pad\":\"XX\"}}"}, {"1c7PN6ycazQ1MjiwUmeMQLpp3LuYx3HTuxnS2kFgAw4=", "73cUheq6HOJlHzhuRMf1M5owaQFEw0wXvzsRB1nvGOPpNyLJKilrEVYkyeDH7MzqXRf9thxswnSLOfgK20NHyUozTe41BE/qz7/oVvFquPOPq6bjUWADzugjjOyDAezXyCb0Z+QvgnqruYjQHJJMxe3U2dcypLUXiScDui694bugFVhnIoQTRFyW0LxBff/ZCp3DBrAZUD178MseY9/h/kEx+Rc8qJzLUjvBHuqGqK8=", "7LRiFwjfdrHgksMvV6HwQM/kK8Yeiwgl+QYljJhboya3HzMa1MmGNPxfy0X8nck77ShFeSSpethG1Gk9/jfhFIeIqLoTNSGi/STwfE0S8RKjKx+wIaBSO2xyTbCx8s9g/+Jvn+wORpUdT45wz5sX1c/LjVJkItcjE/gtGPrDICY=", "hyr7N37WMcA/1SOjPceqwnjdCAhn1HWluLPw39EgiL8="}, "fWNCY5DVgVWIKQZoepEUxYE7yaKfxVsR7Bv3zQIkpRo=", "h/9MHTMSW84EFo96BQW2X73cUi0rILDWQoSIqTlEGew="},
|
||||||
|
};
|
||||||
|
inline constexpr size_t kKdfVectorCount = sizeof(kKdfVectors) / sizeof(kKdfVectors[0]);
|
||||||
|
|
||||||
|
} // namespace fgl::ayla::test
|
||||||
420
tests/ayla/mock_ac.py
Normal file
420
tests/ayla/mock_ac.py
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Мок-модуль кондиционера (сторона устройства) для интеграционных тестов
|
||||||
|
сессии. stdlib-only: AES-256 реализован на чистом python (объёмы крошечные).
|
||||||
|
|
||||||
|
Сценарные флаги:
|
||||||
|
--503 всегда отвечать 503 на local_reg (нет слотов)
|
||||||
|
--no-poll key exchange без опроса commands.json (зависание)
|
||||||
|
--rekey-every N ре-кей на каждый N-й local_reg (N=1 — каждый)
|
||||||
|
--stale-gap S ре-кей, если зазор между local_reg >= S секунд
|
||||||
|
(эмуляция «вернувшегося» приложения; по умолчанию 44)
|
||||||
|
--garbage-pushes N первые N push с отрезанным блоком шифротекста
|
||||||
|
(входящая цепочка расходится на 1 сообщение)
|
||||||
|
--break-outbound N N раз «не заметить» ответ commands.json (исходящий
|
||||||
|
десинк): затем подпись наших команд не сойдётся —
|
||||||
|
мок, как реальный модуль, ре-кает на следующем local_reg
|
||||||
|
--fail-pushes N первые N push'ей с испорченной подписью
|
||||||
|
--push-every S спонтанный push свойства tick каждые S секунд
|
||||||
|
--fail-first-ke первый key_exchange с ver=2 (ожидаем 426)
|
||||||
|
Вывод (stdout, строки):
|
||||||
|
REG <first|put> notify=0|1
|
||||||
|
KE <random1>
|
||||||
|
CMD <method> <resource|name> [value]
|
||||||
|
PUSH <name> <value>
|
||||||
|
DELETE
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import http.server
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
import string
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Чистый python AES-256 (encrypt/decrypt block), CBC поверх.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_SBOX = [
|
||||||
|
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
|
||||||
|
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
|
||||||
|
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
|
||||||
|
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
|
||||||
|
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
|
||||||
|
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
|
||||||
|
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
|
||||||
|
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
|
||||||
|
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
|
||||||
|
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
|
||||||
|
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
|
||||||
|
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
|
||||||
|
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
|
||||||
|
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
|
||||||
|
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
|
||||||
|
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16]
|
||||||
|
_RCON = [0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36,0x6c,0xd8,0xab,0x4d]
|
||||||
|
_INV_SBOX = [0]*256
|
||||||
|
for _i, _b in enumerate(_SBOX):
|
||||||
|
_INV_SBOX[_b] = _i
|
||||||
|
|
||||||
|
def _xtime(a):
|
||||||
|
a <<= 1
|
||||||
|
if a & 0x100:
|
||||||
|
a = (a ^ 0x1b) & 0xff
|
||||||
|
return a
|
||||||
|
|
||||||
|
def _expand_key(key): # 32 байта -> 60 слов по 4 байта (flat список)
|
||||||
|
w = list(key)
|
||||||
|
for i in range(32, 240, 4):
|
||||||
|
t = w[i-4:i]
|
||||||
|
if i % 32 == 0:
|
||||||
|
t = t[1:] + t[:1]
|
||||||
|
t = [_SBOX[b] for b in t]
|
||||||
|
t[0] ^= _RCON[i//32 - 1]
|
||||||
|
elif i % 32 == 16:
|
||||||
|
t = [_SBOX[b] for b in t]
|
||||||
|
w += [w[i-32+j] ^ t[j] for j in range(4)]
|
||||||
|
return w
|
||||||
|
|
||||||
|
def _aes_encrypt_block(w, block):
|
||||||
|
s = list(block)
|
||||||
|
def add_round_key(r):
|
||||||
|
for i in range(16):
|
||||||
|
s[i] ^= w[r*16 + i]
|
||||||
|
def sub_shift():
|
||||||
|
# SubBytes + ShiftRows (строка r — байты r, r+4, r+8, r+12 — влево на r)
|
||||||
|
t = [_SBOX[b] for b in s]
|
||||||
|
out = [0]*16
|
||||||
|
for r in range(4):
|
||||||
|
for c in range(4):
|
||||||
|
out[r + 4*c] = t[r + 4*((c + r) % 4)]
|
||||||
|
for i in range(16):
|
||||||
|
s[i] = out[i]
|
||||||
|
def mix():
|
||||||
|
t = [0]*16
|
||||||
|
for c in range(4):
|
||||||
|
col = s[c*4:c*4+4]
|
||||||
|
t[c*4+0] = _xtime(col[0]) ^ _xtime(col[1]) ^ col[1] ^ col[2] ^ col[3]
|
||||||
|
t[c*4+1] = col[0] ^ _xtime(col[1]) ^ _xtime(col[2]) ^ col[2] ^ col[3]
|
||||||
|
t[c*4+2] = col[0] ^ col[1] ^ _xtime(col[2]) ^ _xtime(col[3]) ^ col[3]
|
||||||
|
t[c*4+3] = _xtime(col[0]) ^ col[0] ^ col[1] ^ col[2] ^ _xtime(col[3])
|
||||||
|
for i in range(16):
|
||||||
|
s[i] = t[i]
|
||||||
|
add_round_key(0)
|
||||||
|
for rnd in range(1, 14):
|
||||||
|
sub_shift(); mix(); add_round_key(rnd)
|
||||||
|
sub_shift(); add_round_key(14)
|
||||||
|
return bytes(s)
|
||||||
|
|
||||||
|
def _aes_decrypt_block(w, block):
|
||||||
|
inv_sbox = _INV_SBOX
|
||||||
|
def inv_sub_shift(s):
|
||||||
|
t = [inv_sbox[b] for b in s]
|
||||||
|
out = [0]*16
|
||||||
|
for r in range(4):
|
||||||
|
for c in range(4):
|
||||||
|
out[r + 4*c] = t[r + 4*((c - r) % 4)] # инверсия сдвига влево на r
|
||||||
|
return out
|
||||||
|
def inv_mix(s):
|
||||||
|
def mul(a, b):
|
||||||
|
p = 0
|
||||||
|
for _ in range(8):
|
||||||
|
if b & 1:
|
||||||
|
p ^= a
|
||||||
|
hi = a & 0x80
|
||||||
|
a = (a << 1) & 0xff
|
||||||
|
if hi:
|
||||||
|
a ^= 0x1b
|
||||||
|
b >>= 1
|
||||||
|
return p
|
||||||
|
t = [0]*16
|
||||||
|
for c in range(4):
|
||||||
|
col = s[c*4:c*4+4]
|
||||||
|
t[c*4+0] = mul(col[0],14) ^ mul(col[1],11) ^ mul(col[2],13) ^ mul(col[3],9)
|
||||||
|
t[c*4+1] = mul(col[0],9) ^ mul(col[1],14) ^ mul(col[2],11) ^ mul(col[3],13)
|
||||||
|
t[c*4+2] = mul(col[0],13) ^ mul(col[1],9) ^ mul(col[2],14) ^ mul(col[3],11)
|
||||||
|
t[c*4+3] = mul(col[0],11) ^ mul(col[1],13) ^ mul(col[2],9) ^ mul(col[3],14)
|
||||||
|
return t
|
||||||
|
s = list(block)
|
||||||
|
def add_round_key(r):
|
||||||
|
for i in range(16):
|
||||||
|
s[i] ^= w[r*16 + i]
|
||||||
|
add_round_key(14)
|
||||||
|
for rnd in range(13, 0, -1):
|
||||||
|
s = inv_sub_shift(s) # InvShiftRows + InvSubBytes (коммутируют)
|
||||||
|
add_round_key(rnd)
|
||||||
|
s = inv_mix(s)
|
||||||
|
s = inv_sub_shift(s)
|
||||||
|
add_round_key(0)
|
||||||
|
return bytes(s)
|
||||||
|
|
||||||
|
class PyAes:
|
||||||
|
def __init__(self, key):
|
||||||
|
self.w = _expand_key(key)
|
||||||
|
def cbc_encrypt(self, iv, data):
|
||||||
|
out = b""
|
||||||
|
prev = iv
|
||||||
|
for i in range(0, len(data), 16):
|
||||||
|
blk = data[i:i+16]
|
||||||
|
blk = bytes(a ^ b for a, b in zip(blk, prev))
|
||||||
|
prev = _aes_encrypt_block(self.w, blk)
|
||||||
|
out += prev
|
||||||
|
return out, prev
|
||||||
|
def cbc_decrypt(self, iv, data):
|
||||||
|
out = b""
|
||||||
|
prev = iv
|
||||||
|
for i in range(0, len(data), 16):
|
||||||
|
blk = data[i:i+16]
|
||||||
|
dec = _aes_decrypt_block(self.w, blk)
|
||||||
|
out += bytes(a ^ b for a, b in zip(dec, prev))
|
||||||
|
prev = blk
|
||||||
|
return out, prev
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class MockCrypto:
|
||||||
|
"""Ключи одной стороны мока: dev (исходящие push) и app (входящие команды)."""
|
||||||
|
def __init__(self, lanip_key, rnd1, rnd2, t1, t2):
|
||||||
|
k = lanip_key.encode()
|
||||||
|
b1, b2 = rnd1.encode(), rnd2.encode()
|
||||||
|
s1, s2 = str(t1).encode(), str(t2).encode()
|
||||||
|
def m(msg, suf):
|
||||||
|
msg = msg + bytes([suf])
|
||||||
|
return hmac.digest(k, hmac.digest(k, msg, "sha256") + msg, "sha256")
|
||||||
|
A, D = b1 + b2 + s1 + s2, b2 + b1 + s2 + s1
|
||||||
|
self.dev_sign, self.dev_aes = m(D, 0x30), PyAes(m(D, 0x31))
|
||||||
|
self.app_sign, self.app_aes = m(A, 0x30), PyAes(m(A, 0x31))
|
||||||
|
self.dev_iv, self.app_iv = m(D, 0x32)[:16], m(A, 0x32)[:16]
|
||||||
|
|
||||||
|
def pack_push(self, seq, data_json):
|
||||||
|
plain = json.dumps({"seq_no": seq, "data": data_json},
|
||||||
|
separators=(",", ":")).encode()
|
||||||
|
sign = base64.b64encode(hmac.digest(self.dev_sign, plain, "sha256")).decode()
|
||||||
|
n = ((len(plain) + 1 + 15) // 16) * 16
|
||||||
|
ct, self.dev_iv = self.dev_aes.cbc_encrypt(self.dev_iv, plain.ljust(n, b"\x00"))
|
||||||
|
enc = base64.b64encode(ct).decode()
|
||||||
|
return json.dumps({"enc": enc, "sign": sign}, separators=(",", ":"))
|
||||||
|
|
||||||
|
def unpack_command(self, body):
|
||||||
|
d = json.loads(body)
|
||||||
|
pt, self.app_iv = self.app_aes.cbc_decrypt(
|
||||||
|
self.app_iv, base64.b64decode(d["enc"]))
|
||||||
|
pt = pt.rstrip(b"\x00")
|
||||||
|
# Подпись проверяется ДО разбора JSON (мусор не парсим).
|
||||||
|
ok = base64.b64encode(hmac.digest(self.app_sign, pt, "sha256")).decode() == d["sign"]
|
||||||
|
if not ok:
|
||||||
|
return False, None
|
||||||
|
return True, json.loads(pt.decode())
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class Mock:
|
||||||
|
def __init__(self, args):
|
||||||
|
self.args = args
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.props = {"operation_mode": 6, "fan_speed": 4, "tick": 0}
|
||||||
|
self.crypto = None
|
||||||
|
self.app_addr = None # (ip, port) приложения
|
||||||
|
self.push_seq = 0
|
||||||
|
self.reg_count = 0
|
||||||
|
self.last_reg_time = None
|
||||||
|
self.fail_pushes = args.fail_pushes
|
||||||
|
self.garbage_pushes = args.garbage_pushes
|
||||||
|
self.miss_response = args.break_outbound
|
||||||
|
self.stop = threading.Event()
|
||||||
|
|
||||||
|
# ---------- исходящие к приложению ----------
|
||||||
|
def http_call(self, method, path, body=b"", timeout=5):
|
||||||
|
ip, port = self.app_addr
|
||||||
|
c = socket.create_connection((ip, port), timeout=timeout)
|
||||||
|
req = (f"{method} {path} HTTP/1.1\r\nHost: {ip}\r\n"
|
||||||
|
f"Content-Type: application/json\r\n"
|
||||||
|
f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n").encode() + body
|
||||||
|
c.sendall(req)
|
||||||
|
raw = b""
|
||||||
|
while True:
|
||||||
|
chunk = c.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
raw += chunk
|
||||||
|
c.close()
|
||||||
|
head, _, resp_body = raw.partition(b"\r\n\r\n")
|
||||||
|
status = int(head.split(b" ")[1])
|
||||||
|
return status, resp_body
|
||||||
|
|
||||||
|
def do_key_exchange(self):
|
||||||
|
rnd1 = "".join(random.choice(string.ascii_letters + string.digits + "+/")
|
||||||
|
for _ in range(16))
|
||||||
|
t1 = int(time.monotonic_ns() // 1000)
|
||||||
|
ver = 2 if (self.args.fail_first_ke and self.reg_count == 1) else 1
|
||||||
|
body = json.dumps({"key_exchange": {
|
||||||
|
"ver": ver, "proto": 1, "key_id": self.args.key_id,
|
||||||
|
"random_1": rnd1, "time_1": t1, "sec": ""}},
|
||||||
|
separators=(",", ":")).encode()
|
||||||
|
status, resp = self.http_call("POST", "/local_lan/key_exchange.json", body)
|
||||||
|
print(f"KE {rnd1} -> {status}", flush=True)
|
||||||
|
if status != 200:
|
||||||
|
return False
|
||||||
|
d = json.loads(resp)
|
||||||
|
self.crypto = MockCrypto(self.args.lanip_key, rnd1, d["random_2"],
|
||||||
|
t1, d["time_2"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
def poll_commands(self):
|
||||||
|
"""Опрашивает commands.json, пока 206; исполняет команды."""
|
||||||
|
while True:
|
||||||
|
status, body = self.http_call("GET", "/local_lan/commands.json")
|
||||||
|
if status != 200 and status != 206:
|
||||||
|
print(f"POLL -> {status}", flush=True)
|
||||||
|
return
|
||||||
|
if not self.crypto:
|
||||||
|
return
|
||||||
|
if self.miss_response > 0:
|
||||||
|
# «Модуль не получил/не расшифровал ответ»: цепочка приложения
|
||||||
|
# ушла, у мока нет — исходящий десинк.
|
||||||
|
self.miss_response -= 1
|
||||||
|
print("CMD skipped (outbound desync)", flush=True)
|
||||||
|
return
|
||||||
|
ok, payload = self.crypto.unpack_command(body)
|
||||||
|
if not ok:
|
||||||
|
print("CMD bad-sign", flush=True)
|
||||||
|
# Реальный модуль: на следующем local_reg — key exchange.
|
||||||
|
self.crypto = None
|
||||||
|
return
|
||||||
|
data = payload.get("data", {})
|
||||||
|
cmds = data.get("cmds", [])
|
||||||
|
props = data.get("properties", [])
|
||||||
|
if cmds:
|
||||||
|
for c in cmds:
|
||||||
|
cmd = c.get("cmd", {})
|
||||||
|
if cmd.get("method") == "DELETE":
|
||||||
|
print("DELETE", flush=True)
|
||||||
|
return
|
||||||
|
res = cmd.get("resource", "")
|
||||||
|
name = res.split("name=")[-1]
|
||||||
|
cid = cmd.get("cmd_id", -1)
|
||||||
|
print(f"CMD GET {name} cid={cid}", flush=True)
|
||||||
|
self.push_datapoint(name, cid=cid)
|
||||||
|
elif props:
|
||||||
|
for p in props:
|
||||||
|
pr = p.get("property", {})
|
||||||
|
self.props[pr.get("name", "?")] = pr.get("value")
|
||||||
|
print(f"CMD SET {pr.get('name')}={pr.get('value')}", flush=True)
|
||||||
|
elif not data:
|
||||||
|
return
|
||||||
|
if status == 200:
|
||||||
|
return
|
||||||
|
|
||||||
|
def push_datapoint(self, name, cid=-1, corrupt=False, garbage=False):
|
||||||
|
self.push_seq += 1
|
||||||
|
body = self.crypto.pack_push(self.push_seq - 1,
|
||||||
|
{"name": name, "value": self.props.get(name, 0)})
|
||||||
|
if garbage:
|
||||||
|
# РЕАЛЬНЫЙ десинк CBC: отрезать последний блок шифротекста —
|
||||||
|
# цепочка мока ушла на блок дальше, приложение отстанет.
|
||||||
|
d = json.loads(body)
|
||||||
|
ct = base64.b64decode(d["enc"])
|
||||||
|
d["enc"] = base64.b64encode(ct[:-16]).decode()
|
||||||
|
body = json.dumps(d, separators=(",", ":"))
|
||||||
|
if corrupt:
|
||||||
|
d = json.loads(body)
|
||||||
|
s = d["sign"]
|
||||||
|
d["sign"] = ("A" if s[-2] != "A" else "B") + s[1:]
|
||||||
|
body = json.dumps(d, separators=(",", ":"))
|
||||||
|
path = "/local_lan/property/datapoint.json"
|
||||||
|
if cid >= 0:
|
||||||
|
path += f"?cmd_id={cid}&status=200"
|
||||||
|
status, _ = self.http_call("POST", path, body.encode())
|
||||||
|
print(f"PUSH {name} -> {status}{' CORRUPT' if corrupt else ''}", flush=True)
|
||||||
|
|
||||||
|
# ---------- входящие local_reg ----------
|
||||||
|
def handle_local_reg(self, body):
|
||||||
|
self.reg_count += 1
|
||||||
|
d = json.loads(body)["local_reg"]
|
||||||
|
notify = d.get("notify", 0)
|
||||||
|
self.app_addr_json = d
|
||||||
|
self.app_addr = (d["ip"], d["port"])
|
||||||
|
print(f"REG {'first' if self.reg_count == 1 else 'put'} notify={notify}",
|
||||||
|
flush=True)
|
||||||
|
now = time.monotonic()
|
||||||
|
stale = (self.last_reg_time is not None and
|
||||||
|
now - self.last_reg_time >= self.args.stale_gap)
|
||||||
|
self.last_reg_time = now
|
||||||
|
if self.args.http503:
|
||||||
|
return 503
|
||||||
|
need_ke = self.crypto is None or stale or (
|
||||||
|
self.args.rekey_every and self.reg_count % self.args.rekey_every == 0)
|
||||||
|
if need_ke and not self.do_key_exchange():
|
||||||
|
return 202
|
||||||
|
if not self.args.no_poll:
|
||||||
|
self.poll_commands()
|
||||||
|
return 202
|
||||||
|
|
||||||
|
def spontaneous_loop(self):
|
||||||
|
while not self.stop.wait(self.args.push_every or 10):
|
||||||
|
if self.crypto and not self.args.no_poll:
|
||||||
|
with self.lock:
|
||||||
|
self.props["tick"] += 1
|
||||||
|
corrupt = self.fail_pushes > 0
|
||||||
|
if corrupt:
|
||||||
|
self.fail_pushes -= 1
|
||||||
|
garbage = self.garbage_pushes > 0
|
||||||
|
if garbage:
|
||||||
|
self.garbage_pushes -= 1
|
||||||
|
self.push_datapoint("tick", corrupt=corrupt, garbage=garbage)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--port", type=int, required=True)
|
||||||
|
ap.add_argument("--lanip-key", required=True)
|
||||||
|
ap.add_argument("--key-id", type=int, required=True)
|
||||||
|
ap.add_argument("--503", dest="http503", action="store_true")
|
||||||
|
ap.add_argument("--no-poll", action="store_true")
|
||||||
|
ap.add_argument("--rekey-every", type=int, default=0)
|
||||||
|
ap.add_argument("--fail-pushes", type=int, default=0)
|
||||||
|
ap.add_argument("--garbage-pushes", type=int, default=0)
|
||||||
|
ap.add_argument("--break-outbound", type=int, default=0)
|
||||||
|
ap.add_argument("--stale-gap", type=float, default=44.0)
|
||||||
|
ap.add_argument("--push-every", type=float, default=0)
|
||||||
|
ap.add_argument("--fail-first-ke", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
mock = Mock(args)
|
||||||
|
lock = mock.lock
|
||||||
|
|
||||||
|
class H(http.server.BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
def do_POST(self):
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
body = self.rfile.read(n)
|
||||||
|
if self.path.startswith("/local_reg.json"):
|
||||||
|
with lock:
|
||||||
|
code = mock.handle_local_reg(body)
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
self.send_response(404)
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
def do_PUT(self):
|
||||||
|
self.do_POST()
|
||||||
|
|
||||||
|
srv = http.server.ThreadingHTTPServer(("127.0.0.1", args.port), H)
|
||||||
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||||
|
if args.push_every:
|
||||||
|
threading.Thread(target=mock.spontaneous_loop, daemon=True).start()
|
||||||
|
print("READY", flush=True)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(0.5)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
135
tests/ayla/session_runner.cpp
Normal file
135
tests/ayla/session_runner.cpp
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Тестовый раннер сессии: поднимает fgl::ayla::Session против mock_ac.py
|
||||||
|
// (или реального модуля) и печатает события строками в stdout:
|
||||||
|
// STATE <state> <err> — смена состояния
|
||||||
|
// PROP <name> <cmd_id> <status> <kind:value> — push свойства
|
||||||
|
// DELETED — delete_session забран модулем
|
||||||
|
// STATS ... — телеметрия (перед выходом)
|
||||||
|
// Управление окружением:
|
||||||
|
// RUNNER_KEEPALIVE_MS, RUNNER_ACTIVATION_MS, RUNNER_QUIET_MS,
|
||||||
|
// RUNNER_NOSLOT_RETRY_MS, RUNNER_DELETE_WAIT_MS
|
||||||
|
// RUNNER_SET_NAME/VALUE/AT — одиночная SET через AT секунд после старта.
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
#include "ayla/session.hpp"
|
||||||
|
|
||||||
|
using fgl::ayla::PropertyEvent;
|
||||||
|
using fgl::ayla::Session;
|
||||||
|
using fgl::ayla::SessionError;
|
||||||
|
using fgl::ayla::SessionState;
|
||||||
|
|
||||||
|
static const char* state_name(SessionState st) {
|
||||||
|
switch (st) {
|
||||||
|
case SessionState::kIdle: return "idle";
|
||||||
|
case SessionState::kRegistering: return "registering";
|
||||||
|
case SessionState::kOnline: return "online";
|
||||||
|
case SessionState::kRecovering: return "recovering";
|
||||||
|
case SessionState::kOffline: return "offline";
|
||||||
|
case SessionState::kKeyError: return "key_error";
|
||||||
|
}
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t env_u32(const char* name, uint32_t def) {
|
||||||
|
const char* v = getenv(name);
|
||||||
|
return v != nullptr ? static_cast<uint32_t>(atoi(v)) : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_state(void*, SessionState st, SessionError err) {
|
||||||
|
printf("STATE %s %d\n", state_name(st), static_cast<int>(err));
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_property(void*, const PropertyEvent& ev) {
|
||||||
|
if (ev.is_int) {
|
||||||
|
printf("PROP %s %d %d i:%lld\n", ev.name, ev.cmd_id, ev.status,
|
||||||
|
static_cast<long long>(ev.int_value));
|
||||||
|
} else if (ev.is_bool) {
|
||||||
|
printf("PROP %s %d %d b:%d\n", ev.name, ev.cmd_id, ev.status,
|
||||||
|
ev.bool_value ? 1 : 0);
|
||||||
|
} else {
|
||||||
|
printf("PROP %s %d %d s:%s\n", ev.name, ev.cmd_id, ev.status, ev.str_value);
|
||||||
|
}
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
if (argc < 7) {
|
||||||
|
fprintf(stderr,
|
||||||
|
"usage: %s <host> <device_port> <listen_port> <dsn> <lanip_key> "
|
||||||
|
"<key_id> <duration_sec> [prop ...]\n",
|
||||||
|
argv[0]);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
fgl::ayla::SessionConfig cfg{};
|
||||||
|
cfg.host = argv[1];
|
||||||
|
cfg.device_port = static_cast<uint16_t>(atoi(argv[2]));
|
||||||
|
cfg.listen_port = static_cast<uint16_t>(atoi(argv[3]));
|
||||||
|
cfg.dsn = argv[4];
|
||||||
|
cfg.lanip_key = argv[5];
|
||||||
|
cfg.lanip_key_id = static_cast<uint32_t>(atoi(argv[6]));
|
||||||
|
int duration_sec = atoi(argv[7]);
|
||||||
|
|
||||||
|
fgl::ayla::SessionCallbacks cbs{};
|
||||||
|
cbs.on_state = on_state;
|
||||||
|
cbs.on_property = on_property;
|
||||||
|
|
||||||
|
Session* s = Session::create(cfg, cbs);
|
||||||
|
if (s == nullptr) {
|
||||||
|
fprintf(stderr, "create failed\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
fgl::ayla::SessionTimings timings{};
|
||||||
|
timings.keepalive_ms = env_u32("RUNNER_KEEPALIVE_MS", timings.keepalive_ms);
|
||||||
|
timings.activation_timeout_ms =
|
||||||
|
env_u32("RUNNER_ACTIVATION_MS", timings.activation_timeout_ms);
|
||||||
|
timings.recovering_quiet_ms =
|
||||||
|
env_u32("RUNNER_QUIET_MS", timings.recovering_quiet_ms);
|
||||||
|
timings.no_slot_retry_ms = env_u32("RUNNER_NOSLOT_RETRY_MS",
|
||||||
|
timings.no_slot_retry_ms);
|
||||||
|
timings.delete_wait_ms = env_u32("RUNNER_DELETE_WAIT_MS",
|
||||||
|
timings.delete_wait_ms);
|
||||||
|
s->set_timings_for_test(timings);
|
||||||
|
|
||||||
|
if (!s->start()) {
|
||||||
|
fprintf(stderr, "start failed\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начальная синхронизация: пакет GET всех свойств.
|
||||||
|
if (argc > 8) {
|
||||||
|
s->begin_batch();
|
||||||
|
for (int i = 8; i < argc; i++) {
|
||||||
|
s->get_property(argv[i]);
|
||||||
|
}
|
||||||
|
s->commit_batch();
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* set_name = getenv("RUNNER_SET_NAME");
|
||||||
|
const char* set_val = getenv("RUNNER_SET_VALUE");
|
||||||
|
uint32_t set_at = env_u32("RUNNER_SET_AT", 0);
|
||||||
|
if (set_name != nullptr && set_val != nullptr && set_at > 0) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(set_at));
|
||||||
|
s->set_property(set_name, atoll(set_val));
|
||||||
|
printf("SET_DONE %s=%s\n", set_name, set_val);
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto deadline = std::chrono::steady_clock::now() +
|
||||||
|
std::chrono::seconds(duration_sec);
|
||||||
|
while (std::chrono::steady_clock::now() < deadline) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
s->stop(); // внутри: delete_session + ожидание выдачи
|
||||||
|
printf("DELETED\n");
|
||||||
|
printf("STATS rekeys=%u pushes_ok=%u pushes_bad=%u cmds=%u state=%s\n",
|
||||||
|
s->rekey_count(), s->pushes_ok(), s->pushes_bad(),
|
||||||
|
s->commands_served(), state_name(s->state()));
|
||||||
|
fflush(stdout);
|
||||||
|
delete s;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
171
tests/ayla/test_crypto.cpp
Normal file
171
tests/ayla/test_crypto.cpp
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// Тесты криптографии Ayla LAN: KDF по векторам (эталон = APK), CBC-цепочка,
|
||||||
|
// подписи, оба варианта паддинга.
|
||||||
|
#include "kdf_vectors.hpp"
|
||||||
|
#include "doctest/doctest.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "ayla/crypto.hpp"
|
||||||
|
|
||||||
|
using fgl::ayla::test::kKdfVectorCount;
|
||||||
|
using fgl::ayla::test::kKdfVectors;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// base64 для сравнения в тестах.
|
||||||
|
std::string b64(const uint8_t* data, size_t len) {
|
||||||
|
static const char kTable[] =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
std::string out;
|
||||||
|
for (size_t i = 0; i < len; i += 3) {
|
||||||
|
uint32_t v = data[i] << 16;
|
||||||
|
if (i + 1 < len) v |= data[i + 1] << 8;
|
||||||
|
if (i + 2 < len) v |= data[i + 2];
|
||||||
|
out += kTable[(v >> 18) & 63];
|
||||||
|
out += kTable[(v >> 12) & 63];
|
||||||
|
out += (i + 1 < len) ? kTable[(v >> 6) & 63] : '=';
|
||||||
|
out += (i + 2 < len) ? kTable[v & 63] : '=';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("KDF+envelope: векторы APK (шифрование app-направления, цепочка)") {
|
||||||
|
for (size_t v = 0; v < kKdfVectorCount; v++) {
|
||||||
|
CAPTURE(v);
|
||||||
|
const auto& vec = kKdfVectors[v];
|
||||||
|
fgl::ayla::SessionCrypto sc;
|
||||||
|
REQUIRE(sc.init(vec.lanip_key, vec.random_1, vec.random_2, vec.time_1,
|
||||||
|
vec.time_2));
|
||||||
|
|
||||||
|
// Отправитель всегда использует Java-паддинг (>=1 NUL) — сверяем 0..2.
|
||||||
|
// (Вектор 3 — legacy-приём без NUL; проверяется в decrypt-тесте.)
|
||||||
|
for (int m = 0; m < 3; m++) {
|
||||||
|
CAPTURE(m);
|
||||||
|
uint8_t ct[1024];
|
||||||
|
long ct_len = sc.app.encrypt(ct, sizeof(ct),
|
||||||
|
reinterpret_cast<const uint8_t*>(vec.plaintexts[m]),
|
||||||
|
strlen(vec.plaintexts[m]));
|
||||||
|
REQUIRE(ct_len > 0);
|
||||||
|
CHECK(b64(ct, static_cast<size_t>(ct_len)) == std::string(vec.enc_base64[m]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подписи направлений.
|
||||||
|
uint8_t mac[32];
|
||||||
|
sc.app.sign(mac, reinterpret_cast<const uint8_t*>(vec.plaintexts[0]),
|
||||||
|
strlen(vec.plaintexts[0]));
|
||||||
|
CHECK(b64(mac, sizeof(mac)) == std::string(vec.app_sign_msg0_b64));
|
||||||
|
sc.dev.sign(mac, reinterpret_cast<const uint8_t*>(vec.plaintexts[1]),
|
||||||
|
strlen(vec.plaintexts[1]));
|
||||||
|
CHECK(b64(mac, sizeof(mac)) == std::string(vec.dev_sign_msg1_b64));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Расшифровка векторов: независимый приёмник, цепочка, оба паддинга") {
|
||||||
|
for (size_t v = 0; v < kKdfVectorCount; v++) {
|
||||||
|
const auto& vec = kKdfVectors[v];
|
||||||
|
// «Приёмник» — свежая сессия: цепочка стартует с iv_seed.
|
||||||
|
fgl::ayla::SessionCrypto rx;
|
||||||
|
REQUIRE(rx.init(vec.lanip_key, vec.random_1, vec.random_2, vec.time_1,
|
||||||
|
vec.time_2));
|
||||||
|
|
||||||
|
// base64-декодирование вектора (таблица для теста).
|
||||||
|
auto unb64 = [](const char* in, uint8_t* out) -> size_t {
|
||||||
|
auto val = [](char c) -> int {
|
||||||
|
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||||
|
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
||||||
|
if (c >= '0' && c <= '9') return c - '0' + 52;
|
||||||
|
if (c == '+') return 62;
|
||||||
|
if (c == '/') return 63;
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
size_t len = strlen(in), out_len = 0;
|
||||||
|
uint32_t acc = 0;
|
||||||
|
int bits = 0;
|
||||||
|
for (size_t i = 0; i < len; i++) {
|
||||||
|
if (in[i] == '=') break;
|
||||||
|
int d = val(in[i]);
|
||||||
|
if (d < 0) return 0;
|
||||||
|
acc = (acc << 6) | static_cast<uint32_t>(d);
|
||||||
|
bits += 6;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
out[out_len++] = static_cast<uint8_t>((acc >> bits) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out_len;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (int m = 0; m < 4; m++) {
|
||||||
|
uint8_t ct[1024];
|
||||||
|
size_t ct_len = unb64(vec.enc_base64[m], ct);
|
||||||
|
REQUIRE(ct_len > 0);
|
||||||
|
REQUIRE(ct_len % 16 == 0);
|
||||||
|
uint8_t pt[1024] = {};
|
||||||
|
long pt_len = rx.app.decrypt(pt, sizeof(pt), ct, ct_len);
|
||||||
|
REQUIRE(pt_len > 0);
|
||||||
|
// Открытый текст совпадает (NUL-паддинг снят; legacy-вариант — точно).
|
||||||
|
CHECK(pt_len == static_cast<long>(strlen(vec.plaintexts[m])));
|
||||||
|
CHECK(memcmp(pt, vec.plaintexts[m], pt_len) == 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("dev-направление: roundtrip цепочки encrypt→decrypt") {
|
||||||
|
const char* key = "UnitTestKey01234567890==";
|
||||||
|
const char* msgs[] = {
|
||||||
|
"{\"seq_no\":0,\"data\":{\"name\":\"a\",\"value\":1}}",
|
||||||
|
"{\"seq_no\":1,\"data\":{\"name\":\"b\",\"value\":2}}",
|
||||||
|
"{\"seq_no\":2,\"data\":{}}",
|
||||||
|
};
|
||||||
|
fgl::ayla::SessionCrypto tx, rx;
|
||||||
|
REQUIRE(tx.init(key, "aaaa1111aaaa1111", "bbbb2222bbbb2222", 111, 222));
|
||||||
|
REQUIRE(rx.init(key, "aaaa1111aaaa1111", "bbbb2222bbbb2222", 111, 222));
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
uint8_t ct[512];
|
||||||
|
long ct_len = tx.dev.encrypt(ct, sizeof(ct),
|
||||||
|
reinterpret_cast<const uint8_t*>(msgs[i]),
|
||||||
|
strlen(msgs[i]));
|
||||||
|
REQUIRE(ct_len > 0);
|
||||||
|
uint8_t pt[512];
|
||||||
|
long pt_len = rx.dev.decrypt(pt, sizeof(pt), ct, static_cast<size_t>(ct_len));
|
||||||
|
REQUIRE(pt_len == static_cast<long>(strlen(msgs[i])));
|
||||||
|
CHECK(memcmp(pt, msgs[i], pt_len) == 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("encrypt/decrypt: граничные случаи") {
|
||||||
|
fgl::ayla::SessionCrypto sc;
|
||||||
|
REQUIRE(sc.init("k", "a", "b", 1, 2));
|
||||||
|
|
||||||
|
uint8_t out[2048];
|
||||||
|
// out_cap меньше padded — ошибка.
|
||||||
|
CHECK(sc.app.encrypt(out, 1, reinterpret_cast<const uint8_t*>("xx"), 2) == -1);
|
||||||
|
// Некратная длина шифротекста — ошибка расшифровки.
|
||||||
|
uint8_t junk[17] = {};
|
||||||
|
CHECK(sc.app.decrypt(out, sizeof(out), junk, 17) == -1);
|
||||||
|
// Нулевая длина — ошибка.
|
||||||
|
CHECK(sc.app.decrypt(out, sizeof(out), junk, 0) == -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("DirectionCrypto: смешение encrypt/decrypt одного направления запрещено") {
|
||||||
|
fgl::ayla::SessionCrypto sc;
|
||||||
|
REQUIRE(sc.init("k", "a", "b", 1, 2));
|
||||||
|
uint8_t out[64];
|
||||||
|
// Фиксируем режим шифрованием.
|
||||||
|
REQUIRE(sc.app.encrypt(out, sizeof(out),
|
||||||
|
reinterpret_cast<const uint8_t*>("1234567890123456"), 16) == 32);
|
||||||
|
// Расшифровка тем же направлением — отказ (misuse-защита одной цепочки).
|
||||||
|
uint8_t pt[64];
|
||||||
|
CHECK(sc.app.decrypt(pt, sizeof(pt), out, 16) == -1);
|
||||||
|
// Обратный порядок для dev-направления.
|
||||||
|
fgl::ayla::SessionCrypto sc2;
|
||||||
|
REQUIRE(sc2.init("k", "a", "b", 1, 2));
|
||||||
|
uint8_t junk[16] = {};
|
||||||
|
REQUIRE(sc2.dev.decrypt(pt, sizeof(pt), junk, 16) >= 0);
|
||||||
|
CHECK(sc2.dev.encrypt(out, sizeof(out),
|
||||||
|
reinterpret_cast<const uint8_t*>("1234567890123456"), 16) == -1);
|
||||||
|
}
|
||||||
178
tests/ayla/test_envelope.cpp
Normal file
178
tests/ayla/test_envelope.cpp
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
// Тесты конверта {"enc","sign"}: roundtrip, цепочка, порча подписи.
|
||||||
|
#include "doctest/doctest.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "ayla/crypto.hpp"
|
||||||
|
#include "ayla/envelope.hpp"
|
||||||
|
|
||||||
|
TEST_CASE("envelope: roundtrip и CBC-цепочка (3 сообщения)") {
|
||||||
|
const char* key = "UnitTestKey01234567890==";
|
||||||
|
fgl::ayla::SessionCrypto tx, rx;
|
||||||
|
REQUIRE(tx.init(key, "rnd1111rnd1111aa", "rnd2222rnd2222bb", 42, 43));
|
||||||
|
REQUIRE(rx.init(key, "rnd1111rnd1111aa", "rnd2222rnd2222bb", 42, 43));
|
||||||
|
|
||||||
|
const char* payloads[] = {
|
||||||
|
"{}",
|
||||||
|
"{\"cmds\":[{\"cmd\":{\"cmd_id\":7,\"method\":\"GET\","
|
||||||
|
"\"resource\":\"property.json?name=x\",\"data\":\"\","
|
||||||
|
"\"uri\":\"/local_lan/property/datapoint.json\"}}]}",
|
||||||
|
"{\"properties\":[{\"property\":{\"base_type\":\"integer\","
|
||||||
|
"\"name\":\"n\",\"value\":9,\"id\":\"zz\"}}]}",
|
||||||
|
};
|
||||||
|
char envelopes[3][fgl::ayla::kEnvelopeMaxB64];
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
REQUIRE(fgl::ayla::envelope_pack(tx.app, i, payloads[i], envelopes[i],
|
||||||
|
sizeof(envelopes[i])));
|
||||||
|
}
|
||||||
|
// Разные шифротексты (цепочка движется).
|
||||||
|
CHECK(std::string(envelopes[0]) != std::string(envelopes[1]));
|
||||||
|
|
||||||
|
// Приёмник с той же цепочкой расшифровывает по порядку.
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
// Разбор конверта на enc/sign (в тесте — вручную, простым поиском).
|
||||||
|
std::string env(envelopes[i]);
|
||||||
|
size_t e1 = env.find("\"enc\":\"") + 7;
|
||||||
|
size_t e2 = env.find('"', e1);
|
||||||
|
size_t s1 = env.find("\"sign\":\"") + 8;
|
||||||
|
size_t s2 = env.find('"', s1);
|
||||||
|
REQUIRE(e2 != std::string::npos);
|
||||||
|
REQUIRE(s2 != std::string::npos);
|
||||||
|
std::string enc = env.substr(e1, e2 - e1);
|
||||||
|
std::string sign = env.substr(s1, s2 - s1);
|
||||||
|
|
||||||
|
char json[fgl::ayla::kEnvelopeMaxPlain];
|
||||||
|
int64_t seq = -1;
|
||||||
|
REQUIRE(fgl::ayla::envelope_unpack(rx.app, enc.c_str(), sign.c_str(), json,
|
||||||
|
sizeof(json), &seq));
|
||||||
|
CHECK(seq == i);
|
||||||
|
std::string expected = "{\"seq_no\":" + std::to_string(i) + ",\"data\":" +
|
||||||
|
payloads[i] + "}";
|
||||||
|
CHECK(std::string(json) == expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("envelope: порча подписи отклоняется, цепочка продолжает движение") {
|
||||||
|
const char* key = "UnitTestKey01234567890==";
|
||||||
|
fgl::ayla::SessionCrypto tx, rx;
|
||||||
|
REQUIRE(tx.init(key, "aaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbb", 1, 2));
|
||||||
|
REQUIRE(rx.init(key, "aaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbb", 1, 2));
|
||||||
|
|
||||||
|
char e0[fgl::ayla::kEnvelopeMaxB64], e1[fgl::ayla::kEnvelopeMaxB64];
|
||||||
|
REQUIRE(fgl::ayla::envelope_pack(tx.app, 0, "{}", e0, sizeof(e0)));
|
||||||
|
REQUIRE(fgl::ayla::envelope_pack(tx.app, 1, "{\"a\":1}", e1, sizeof(e1)));
|
||||||
|
|
||||||
|
auto split = [](const char* env, std::string* enc, std::string* sign) {
|
||||||
|
std::string s(env);
|
||||||
|
size_t e1 = s.find("\"enc\":\"") + 7, e2 = s.find('"', e1);
|
||||||
|
size_t g1 = s.find("\"sign\":\"") + 8, g2 = s.find('"', g1);
|
||||||
|
*enc = s.substr(e1, e2 - e1);
|
||||||
|
*sign = s.substr(g1, g2 - g1);
|
||||||
|
};
|
||||||
|
std::string enc0, sign0, enc1, sign1;
|
||||||
|
split(e0, &enc0, &sign0);
|
||||||
|
split(e1, &enc1, &sign1);
|
||||||
|
|
||||||
|
// Портим подпись первого сообщения: распаковка обязана провалиться,
|
||||||
|
// но расшифровка (и движение цепочки) уже произошла.
|
||||||
|
std::string bad = sign0;
|
||||||
|
bad[bad.size() - 2] = (bad[bad.size() - 2] == 'A') ? 'B' : 'A';
|
||||||
|
char json[fgl::ayla::kEnvelopeMaxPlain];
|
||||||
|
int64_t seq = -1;
|
||||||
|
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, enc0.c_str(), bad.c_str(),
|
||||||
|
json, sizeof(json), &seq));
|
||||||
|
// Второе сообщение — валидное, цепочка корректна.
|
||||||
|
REQUIRE(fgl::ayla::envelope_unpack(rx.app, enc1.c_str(), sign1.c_str(), json,
|
||||||
|
sizeof(json), &seq));
|
||||||
|
CHECK(seq == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("envelope: некорректный base64 и повреждённый шифротекст") {
|
||||||
|
const char* key = "UnitTestKey01234567890==";
|
||||||
|
fgl::ayla::SessionCrypto tx, rx;
|
||||||
|
REQUIRE(tx.init(key, "cccccccccccccccc", "dddddddddddddddd", 5, 6));
|
||||||
|
REQUIRE(rx.init(key, "cccccccccccccccc", "dddddddddddddddd", 5, 6));
|
||||||
|
|
||||||
|
char env[fgl::ayla::kEnvelopeMaxB64];
|
||||||
|
REQUIRE(fgl::ayla::envelope_pack(tx.app, 0, "{}", env, sizeof(env)));
|
||||||
|
std::string s(env);
|
||||||
|
size_t e1 = s.find("\"enc\":\"") + 7, e2 = s.find('"', e1);
|
||||||
|
size_t g1 = s.find("\"sign\":\"") + 8, g2 = s.find('"', g1);
|
||||||
|
std::string enc = s.substr(e1, e2 - e1);
|
||||||
|
std::string sign = s.substr(g1, g2 - g1);
|
||||||
|
|
||||||
|
char json[fgl::ayla::kEnvelopeMaxPlain];
|
||||||
|
int64_t seq = -1;
|
||||||
|
// Мусорный base64.
|
||||||
|
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, "!!!not-base64!!!", sign.c_str(),
|
||||||
|
json, sizeof(json), &seq));
|
||||||
|
// Порча шифротекста: подпись не сойдётся (подводного камня нет — просто
|
||||||
|
// проверяем, что это отклоняется).
|
||||||
|
std::string broken = enc;
|
||||||
|
if (broken[10] == 'A') {
|
||||||
|
broken[10] = 'B';
|
||||||
|
} else {
|
||||||
|
broken[10] = 'A';
|
||||||
|
}
|
||||||
|
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, broken.c_str(), sign.c_str(),
|
||||||
|
json, sizeof(json), &seq));
|
||||||
|
// Оригинал после двух провалов уже не расшифруется (цепочка ушла) —
|
||||||
|
// таково требование протокола; восстановление только re-key.
|
||||||
|
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, enc.c_str(), sign.c_str(),
|
||||||
|
json, sizeof(json), &seq));
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// base64 для ручной сборки конвертов в тестах.
|
||||||
|
std::string tb64(const uint8_t* data, size_t len) {
|
||||||
|
static const char kTable[] =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
std::string out;
|
||||||
|
for (size_t i = 0; i < len; i += 3) {
|
||||||
|
uint32_t v = data[i] << 16;
|
||||||
|
if (i + 1 < len) v |= data[i + 1] << 8;
|
||||||
|
if (i + 2 < len) v |= data[i + 2];
|
||||||
|
out += kTable[(v >> 18) & 63];
|
||||||
|
out += kTable[(v >> 12) & 63];
|
||||||
|
out += (i + 1 < len) ? kTable[(v >> 6) & 63] : '=';
|
||||||
|
out += (i + 2 < len) ? kTable[v & 63] : '=';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("envelope: extract_seq_no устойчив к обрыву после escape (OOB-регрессия)") {
|
||||||
|
// Аутентифицированный plaintext с незакрытой строкой, заканчивающейся '\'
|
||||||
|
// (раньше — чтение за границей буфера, поймано ASan в ревью круга 1).
|
||||||
|
// Конверты собираем вручную: sign + encrypt по битому plaintext.
|
||||||
|
const char* key = "UnitTestKey01234567890==";
|
||||||
|
fgl::ayla::SessionCrypto tx, rx;
|
||||||
|
REQUIRE(tx.init(key, "eeeeeeeeeeeeeeee", "ffffffffffffffff", 7, 8));
|
||||||
|
REQUIRE(rx.init(key, "eeeeeeeeeeeeeeee", "ffffffffffffffff", 7, 8));
|
||||||
|
|
||||||
|
// seq_no отсутствует/после битой части — сканер обязан ПРОПУСКАТЬ
|
||||||
|
// повреждённое значение (именно на пропуске был OOB).
|
||||||
|
const char* cases[] = {
|
||||||
|
"{\"a\":\"x\\", // обрыв в строке-значении верхнего уровня
|
||||||
|
"{\"a\":{\"b\":\"x\\", // обрыв в строке внутри вложенного объекта
|
||||||
|
"{\"a\":[1,2,\"x\\", // обрыв в строке внутри массива
|
||||||
|
};
|
||||||
|
for (const char* plain : cases) {
|
||||||
|
CAPTURE(plain);
|
||||||
|
uint8_t mac[32];
|
||||||
|
tx.app.sign(mac, reinterpret_cast<const uint8_t*>(plain), strlen(plain));
|
||||||
|
uint8_t ct[fgl::ayla::kEnvelopeMaxPlain + 16];
|
||||||
|
long ct_len = tx.app.encrypt(ct, sizeof(ct),
|
||||||
|
reinterpret_cast<const uint8_t*>(plain),
|
||||||
|
strlen(plain));
|
||||||
|
REQUIRE(ct_len > 0);
|
||||||
|
std::string enc = tb64(ct, static_cast<size_t>(ct_len));
|
||||||
|
std::string sign = tb64(mac, sizeof(mac));
|
||||||
|
char json[fgl::ayla::kEnvelopeMaxPlain];
|
||||||
|
int64_t seq = -1;
|
||||||
|
// unpack обязан вернуть false (битый JSON) и не выйти за границы.
|
||||||
|
CHECK_FALSE(fgl::ayla::envelope_unpack(rx.app, enc.c_str(), sign.c_str(),
|
||||||
|
json, sizeof(json), &seq));
|
||||||
|
}
|
||||||
|
}
|
||||||
106
tests/ayla/test_httpc.cpp
Normal file
106
tests/ayla/test_httpc.cpp
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Тесты HTTP-клиента против in-process httpd.
|
||||||
|
#include "doctest/doctest.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "ayla/httpc.hpp"
|
||||||
|
#include "ayla/httpd.hpp"
|
||||||
|
#include "ayla/platform/platform.hpp"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct Ctx {
|
||||||
|
fgl::ayla::HttpRequest last;
|
||||||
|
int calls = 0;
|
||||||
|
int status_to_return = 200;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool handler(const fgl::ayla::HttpRequest& req, fgl::ayla::HttpResponse& resp,
|
||||||
|
void* ctx) {
|
||||||
|
auto* c = static_cast<Ctx*>(ctx);
|
||||||
|
c->last = req;
|
||||||
|
c->calls++;
|
||||||
|
resp.status = c->status_to_return;
|
||||||
|
static const uint8_t kBody[] = "{\"x\":1}";
|
||||||
|
resp.body = kBody;
|
||||||
|
resp.body_len = sizeof(kBody) - 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("httpc: POST с телом и query, статус пробрасывается") {
|
||||||
|
fgl::ayla::HttpServer srv;
|
||||||
|
Ctx ctx;
|
||||||
|
ctx.status_to_return = 202;
|
||||||
|
REQUIRE(srv.start(0, handler, &ctx));
|
||||||
|
const uint32_t ip = (127u << 24) | 1u; // для лога, не используется
|
||||||
|
|
||||||
|
fgl::ayla::HttpcRequest req;
|
||||||
|
req.method = "POST";
|
||||||
|
req.host = "127.0.0.1";
|
||||||
|
req.port = srv.port();
|
||||||
|
req.path = "/local_reg.json";
|
||||||
|
req.query = "dsn=AC000W00REDACTED";
|
||||||
|
const char body[] = "{\"local_reg\":{\"notify\":1}}";
|
||||||
|
req.body = reinterpret_cast<const uint8_t*>(body);
|
||||||
|
req.body_len = strlen(body);
|
||||||
|
req.timeout_ms = 3000;
|
||||||
|
|
||||||
|
fgl::ayla::HttpcResponse resp;
|
||||||
|
REQUIRE(fgl::ayla::httpc_perform(req, &resp));
|
||||||
|
CHECK(resp.transport_ok);
|
||||||
|
CHECK(resp.status == 202);
|
||||||
|
CHECK(ctx.calls == 1);
|
||||||
|
CHECK(std::string(ctx.last.method) == "POST");
|
||||||
|
CHECK(std::string(ctx.last.target) == "/local_reg.json");
|
||||||
|
CHECK(std::string(ctx.last.query) == "dsn=AC000W00REDACTED");
|
||||||
|
CHECK(ctx.last.body_len == strlen(body));
|
||||||
|
CHECK(memcmp(ctx.last.body, body, strlen(body)) == 0);
|
||||||
|
srv.stop();
|
||||||
|
(void)ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("httpc: ошибки транспорта (соединение отвергнуто)") {
|
||||||
|
// Занимаем и освобождаем порт — соединение точно отвергнётся.
|
||||||
|
fgl::ayla::HttpServer tmp;
|
||||||
|
REQUIRE(tmp.start(0, nullptr, nullptr));
|
||||||
|
const uint16_t port = tmp.port();
|
||||||
|
tmp.stop();
|
||||||
|
fgl::plat::sleep_ms(100);
|
||||||
|
|
||||||
|
fgl::ayla::HttpcRequest req;
|
||||||
|
req.method = "PUT";
|
||||||
|
req.host = "127.0.0.1";
|
||||||
|
req.port = port;
|
||||||
|
req.timeout_ms = 2000;
|
||||||
|
fgl::ayla::HttpcResponse resp;
|
||||||
|
bool ok = fgl::ayla::httpc_perform(req, &resp);
|
||||||
|
// Либо connect сразу отклонён (false), либо таймаут — оба варианта ошибки.
|
||||||
|
if (ok) {
|
||||||
|
FAIL("ожидаля отказ соединения");
|
||||||
|
}
|
||||||
|
CHECK_FALSE(resp.transport_ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("httpc: PUT без тела, ответ 503 c телом (дренаж)") {
|
||||||
|
fgl::ayla::HttpServer srv;
|
||||||
|
Ctx ctx;
|
||||||
|
ctx.status_to_return = 503;
|
||||||
|
REQUIRE(srv.start(0, handler, &ctx));
|
||||||
|
|
||||||
|
fgl::ayla::HttpcRequest req;
|
||||||
|
req.method = "PUT";
|
||||||
|
req.host = "127.0.0.1";
|
||||||
|
req.port = srv.port();
|
||||||
|
req.path = "/local_reg.json";
|
||||||
|
req.body = nullptr;
|
||||||
|
req.body_len = 0;
|
||||||
|
req.timeout_ms = 3000;
|
||||||
|
|
||||||
|
fgl::ayla::HttpcResponse resp;
|
||||||
|
REQUIRE(fgl::ayla::httpc_perform(req, &resp));
|
||||||
|
CHECK(resp.status == 503);
|
||||||
|
CHECK(ctx.calls == 1);
|
||||||
|
CHECK(ctx.last.body_len == 0);
|
||||||
|
srv.stop();
|
||||||
|
}
|
||||||
226
tests/ayla/test_httpd.cpp
Normal file
226
tests/ayla/test_httpd.cpp
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
// Тесты мини-httpd: парсинг запросов, keep-alive, 404 по умолчанию,
|
||||||
|
// корректный stop при живом keep-alive соединении.
|
||||||
|
#include "doctest/doctest.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "ayla/httpd.hpp"
|
||||||
|
#include "ayla/platform/platform.hpp"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Простой блокирующий HTTP-клиент для тестов (ephemeral-порт сервера).
|
||||||
|
std::string build_request(const char* method, const char* target,
|
||||||
|
const char* body, bool keep_alive) {
|
||||||
|
char head[512];
|
||||||
|
int n = snprintf(head, sizeof(head),
|
||||||
|
"%s %s HTTP/1.1\r\n"
|
||||||
|
"Content-Length: %u\r\n"
|
||||||
|
"Connection: %s\r\n"
|
||||||
|
"\r\n",
|
||||||
|
method, target,
|
||||||
|
body != nullptr ? static_cast<unsigned>(strlen(body)) : 0u,
|
||||||
|
keep_alive ? "keep-alive" : "close");
|
||||||
|
std::string req(head, head + n);
|
||||||
|
if (body != nullptr) req += body;
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RawResponse {
|
||||||
|
int status = 0;
|
||||||
|
std::string body;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Один запрос на новом соединении.
|
||||||
|
RawResponse http_request(uint16_t port, const char* method, const char* target,
|
||||||
|
const char* body = nullptr, bool keep_alive = true) {
|
||||||
|
int fd = fgl::plat::tcp_connect("127.0.0.1", port, 2000);
|
||||||
|
REQUIRE(fd >= 0);
|
||||||
|
fgl::plat::tcp_set_timeout(fd, 2000, 2000);
|
||||||
|
std::string req = build_request(method, target, body, keep_alive);
|
||||||
|
REQUIRE(fgl::plat::tcp_send(fd, req.data(), req.size()) ==
|
||||||
|
static_cast<long>(req.size()));
|
||||||
|
std::string raw;
|
||||||
|
char buf[1024];
|
||||||
|
for (;;) {
|
||||||
|
long r = fgl::plat::tcp_recv(fd, buf, sizeof(buf));
|
||||||
|
if (r <= 0) break;
|
||||||
|
raw.append(buf, buf + r);
|
||||||
|
size_t hdr_end = raw.find("\r\n\r\n");
|
||||||
|
if (hdr_end != std::string::npos) {
|
||||||
|
unsigned clen = 0;
|
||||||
|
size_t cl = raw.find("Content-Length:");
|
||||||
|
if (cl != std::string::npos) {
|
||||||
|
clen = static_cast<unsigned>(atoi(raw.c_str() + cl + 15));
|
||||||
|
}
|
||||||
|
if (raw.size() >= hdr_end + 4 + clen) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fgl::plat::tcp_close(fd);
|
||||||
|
RawResponse out;
|
||||||
|
out.status = atoi(raw.c_str() + 9); // "HTTP/1.1 NNN"
|
||||||
|
size_t hdr_end = raw.find("\r\n\r\n");
|
||||||
|
if (hdr_end != std::string::npos) out.body = raw.substr(hdr_end + 4);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("httpd: ephemeral порт и 404 по умолчанию") {
|
||||||
|
fgl::ayla::HttpServer srv;
|
||||||
|
REQUIRE(srv.start(0, nullptr, nullptr));
|
||||||
|
const uint16_t port = srv.port();
|
||||||
|
REQUIRE(port != 0);
|
||||||
|
auto resp = http_request(port, "GET", "/local_lan/commands.json");
|
||||||
|
CHECK(resp.status == 404);
|
||||||
|
CHECK(resp.body.empty());
|
||||||
|
srv.stop();
|
||||||
|
CHECK_FALSE(srv.is_running());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("httpd: keep-alive — два запроса на одном соединении") {
|
||||||
|
fgl::ayla::HttpServer srv;
|
||||||
|
REQUIRE(srv.start(0, nullptr, nullptr));
|
||||||
|
const uint16_t port = srv.port();
|
||||||
|
|
||||||
|
int fd = fgl::plat::tcp_connect("127.0.0.1", port, 2000);
|
||||||
|
REQUIRE(fd >= 0);
|
||||||
|
fgl::plat::tcp_set_timeout(fd, 2000, 2000);
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
std::string req = build_request("GET", "/local_lan/commands.json", nullptr,
|
||||||
|
true);
|
||||||
|
REQUIRE(fgl::plat::tcp_send(fd, req.data(), req.size()) ==
|
||||||
|
static_cast<long>(req.size()));
|
||||||
|
std::string raw;
|
||||||
|
char buf[512];
|
||||||
|
while (raw.find("\r\n\r\n") == std::string::npos) {
|
||||||
|
long r = fgl::plat::tcp_recv(fd, buf, sizeof(buf));
|
||||||
|
if (r <= 0) break;
|
||||||
|
raw.append(buf, buf + r);
|
||||||
|
}
|
||||||
|
CHECK(atoi(raw.c_str() + 9) == 404);
|
||||||
|
}
|
||||||
|
fgl::plat::tcp_close(fd);
|
||||||
|
srv.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct HandlerCtx {
|
||||||
|
fgl::ayla::HttpRequest last_req;
|
||||||
|
int calls = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool capture_handler(const fgl::ayla::HttpRequest& req,
|
||||||
|
fgl::ayla::HttpResponse& resp, void* ctx) {
|
||||||
|
auto* h = static_cast<HandlerCtx*>(ctx);
|
||||||
|
h->last_req = req;
|
||||||
|
h->calls++;
|
||||||
|
resp.status = 200;
|
||||||
|
static const char kBody[] = "{\"ok\":true}";
|
||||||
|
resp.body = reinterpret_cast<const uint8_t*>(kBody);
|
||||||
|
resp.body_len = sizeof(kBody) - 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("httpd: обработчик, парсинг метода/пути/query/тела/пира") {
|
||||||
|
fgl::ayla::HttpServer srv;
|
||||||
|
HandlerCtx h;
|
||||||
|
REQUIRE(srv.start(0, capture_handler, &h));
|
||||||
|
auto resp = http_request(srv.port(), "POST",
|
||||||
|
"/local_lan/property/datapoint.json?cmd_id=5&status=200",
|
||||||
|
"{\"enc\":\"abc\"}");
|
||||||
|
CHECK(resp.status == 200);
|
||||||
|
CHECK(resp.body == std::string("{\"ok\":true}"));
|
||||||
|
CHECK(h.calls == 1);
|
||||||
|
CHECK(std::string(h.last_req.method) == "POST");
|
||||||
|
CHECK(std::string(h.last_req.target) == "/local_lan/property/datapoint.json");
|
||||||
|
CHECK(std::string(h.last_req.query) == "cmd_id=5&status=200");
|
||||||
|
CHECK(h.last_req.body_len == 13);
|
||||||
|
CHECK(memcmp(h.last_req.body, "{\"enc\":\"abc\"}", 13) == 0);
|
||||||
|
CHECK(h.last_req.peer_ip == 0x7f000001);
|
||||||
|
srv.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("httpd: oversized body -> 400 + Connection: close") {
|
||||||
|
fgl::ayla::HttpServer srv;
|
||||||
|
REQUIRE(srv.start(0, nullptr, nullptr));
|
||||||
|
std::string big(fgl::ayla::kHttpdMaxBody + 100, 'x');
|
||||||
|
int fd = fgl::plat::tcp_connect("127.0.0.1", srv.port(), 2000);
|
||||||
|
REQUIRE(fd >= 0);
|
||||||
|
fgl::plat::tcp_set_timeout(fd, 2000, 2000);
|
||||||
|
std::string req = build_request("POST", "/local_lan/property/datapoint.json",
|
||||||
|
big.c_str(), false);
|
||||||
|
REQUIRE(fgl::plat::tcp_send(fd, req.data(), req.size()) ==
|
||||||
|
static_cast<long>(req.size()));
|
||||||
|
std::string raw;
|
||||||
|
char buf[1024];
|
||||||
|
for (;;) {
|
||||||
|
long r = fgl::plat::tcp_recv(fd, buf, sizeof(buf));
|
||||||
|
if (r <= 0) break;
|
||||||
|
raw.append(buf, buf + r);
|
||||||
|
}
|
||||||
|
fgl::plat::tcp_close(fd);
|
||||||
|
CHECK(atoi(raw.c_str() + 9) == 400);
|
||||||
|
CHECK(raw.find("Connection: close") != std::string::npos);
|
||||||
|
srv.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("httpd: stop() при открытом keep-alive соединении (нет UAF/зависания)") {
|
||||||
|
fgl::ayla::HttpServer srv;
|
||||||
|
REQUIRE(srv.start(0, nullptr, nullptr));
|
||||||
|
const uint16_t port = srv.port();
|
||||||
|
// Соединение без запроса: поток сервера сидит в recv с 30с таймаутом.
|
||||||
|
int fd = fgl::plat::tcp_connect("127.0.0.1", port, 2000);
|
||||||
|
REQUIRE(fd >= 0);
|
||||||
|
fgl::plat::sleep_ms(200); // даём серверу принять и уйти в ожидание
|
||||||
|
uint64_t t0 = fgl::plat::now_ms();
|
||||||
|
srv.stop(); // должен прервать соединение shutdown'ом и join'нуть поток
|
||||||
|
uint64_t elapsed = fgl::plat::now_ms() - t0;
|
||||||
|
CHECK(srv.port() == port);
|
||||||
|
CHECK(elapsed < 2000); // не ждём rx-таймаут
|
||||||
|
fgl::plat::tcp_close(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("httpd: stop() сразу после start()") {
|
||||||
|
fgl::ayla::HttpServer srv;
|
||||||
|
REQUIRE(srv.start(0, nullptr, nullptr));
|
||||||
|
uint64_t t0 = fgl::plat::now_ms();
|
||||||
|
srv.stop(); // поток мог не дойти до poll — join всё равно быстрый
|
||||||
|
CHECK(fgl::plat::now_ms() - t0 < 2000);
|
||||||
|
// Сервер можно перезапустить после stop.
|
||||||
|
REQUIRE(srv.start(0, nullptr, nullptr));
|
||||||
|
auto resp = http_request(srv.port(), "GET", "/");
|
||||||
|
CHECK(resp.status == 404);
|
||||||
|
srv.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("httpd: бесконечный стрим заголовков завершается (лимит 1КБ)") {
|
||||||
|
fgl::ayla::HttpServer srv;
|
||||||
|
REQUIRE(srv.start(0, nullptr, nullptr));
|
||||||
|
int fd = fgl::plat::tcp_connect("127.0.0.1", srv.port(), 2000);
|
||||||
|
REQUIRE(fd >= 0);
|
||||||
|
fgl::plat::tcp_set_timeout(fd, 2000, 2000);
|
||||||
|
// Отправляем request line и бессрочный поток заголовков малыми кусками.
|
||||||
|
std::string req = "GET / HTTP/1.1\r\n";
|
||||||
|
REQUIRE(fgl::plat::tcp_send(fd, req.data(), req.size()) ==
|
||||||
|
static_cast<long>(req.size()));
|
||||||
|
const char* filler = "X-Pad: 0123456789012345678901234567890123456789\r\n";
|
||||||
|
size_t flen = strlen(filler);
|
||||||
|
for (int i = 0; i < 80; i++) { // ~4КБ — больше лимита
|
||||||
|
if (fgl::plat::tcp_send(fd, filler, flen) != static_cast<long>(flen)) {
|
||||||
|
break; // сервер уже закрыл соединение (лимит превышен) — RST допустим
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Сервер должен перестать читать и закрыть соединение: recv завершается
|
||||||
|
// (EOF или RST после close с непрочитанными данными), а не висит вечно.
|
||||||
|
fgl::plat::tcp_set_timeout(fd, 3000, 3000);
|
||||||
|
char buf[64];
|
||||||
|
long r;
|
||||||
|
while ((r = fgl::plat::tcp_recv(fd, buf, sizeof(buf))) > 0) {
|
||||||
|
}
|
||||||
|
CHECK(r <= 0); // соединение закрыто сервером
|
||||||
|
fgl::plat::tcp_close(fd);
|
||||||
|
srv.stop();
|
||||||
|
}
|
||||||
156
tests/ayla/test_json.cpp
Normal file
156
tests/ayla/test_json.cpp
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// Тесты JSON: writer (фиксированный буфер, вложенность, переполнение)
|
||||||
|
// и парсер (jsmn) — форматы протокола.
|
||||||
|
#include "doctest/doctest.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "ayla/json.hpp"
|
||||||
|
|
||||||
|
using fgl::ayla::json::Doc;
|
||||||
|
using fgl::ayla::json::Writer;
|
||||||
|
|
||||||
|
TEST_CASE("json writer: формы протокола") {
|
||||||
|
// datapoint-команда (properties)
|
||||||
|
char buf[512];
|
||||||
|
Writer w(buf, sizeof(buf));
|
||||||
|
w.begin_object(); // {
|
||||||
|
w.key("properties");
|
||||||
|
w.begin_array();
|
||||||
|
w.begin_object();
|
||||||
|
w.key("property");
|
||||||
|
w.begin_object();
|
||||||
|
w.key("base_type"); w.string("integer");
|
||||||
|
w.key("name"); w.string("fan_speed");
|
||||||
|
w.key("value"); w.integer(3);
|
||||||
|
w.key("id"); w.string("ab12cd34");
|
||||||
|
w.end_object();
|
||||||
|
w.end_object();
|
||||||
|
w.end_array();
|
||||||
|
w.end_object();
|
||||||
|
REQUIRE(w.ok());
|
||||||
|
CHECK(std::string(buf) ==
|
||||||
|
"{\"properties\":[{\"property\":{\"base_type\":\"integer\","
|
||||||
|
"\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}");
|
||||||
|
|
||||||
|
// GET-команда (cmds)
|
||||||
|
Writer w2(buf, sizeof(buf));
|
||||||
|
w2.begin_object();
|
||||||
|
w2.key("cmds");
|
||||||
|
w2.begin_array();
|
||||||
|
w2.begin_object();
|
||||||
|
w2.key("cmd");
|
||||||
|
w2.begin_object();
|
||||||
|
w2.key("cmd_id"); w2.integer(5);
|
||||||
|
w2.key("method"); w2.string("GET");
|
||||||
|
w2.key("resource"); w2.string("property.json?name=op");
|
||||||
|
w2.key("data"); w2.string("");
|
||||||
|
w2.key("uri"); w2.string("/local_lan/property/datapoint.json");
|
||||||
|
w2.end_object();
|
||||||
|
w2.end_object();
|
||||||
|
w2.end_array();
|
||||||
|
w2.end_object();
|
||||||
|
REQUIRE(w2.ok());
|
||||||
|
CHECK(std::string(buf) ==
|
||||||
|
"{\"cmds\":[{\"cmd\":{\"cmd_id\":5,\"method\":\"GET\","
|
||||||
|
"\"resource\":\"property.json?name=op\",\"data\":\"\","
|
||||||
|
"\"uri\":\"/local_lan/property/datapoint.json\"}}]}");
|
||||||
|
|
||||||
|
// key_exchange ответ + массив целых + bool/null
|
||||||
|
Writer w3(buf, sizeof(buf));
|
||||||
|
w3.begin_object();
|
||||||
|
w3.key("random_2"); w3.string("abcd1234");
|
||||||
|
w3.key("time_2"); w3.integer(-123);
|
||||||
|
w3.key("flags");
|
||||||
|
w3.begin_array();
|
||||||
|
w3.integer(1); w3.integer(2); w3.integer(3);
|
||||||
|
w3.end_array();
|
||||||
|
w3.key("ok"); w3.boolean(true);
|
||||||
|
w3.key("nothing"); w3.literal("null");
|
||||||
|
w3.end_object();
|
||||||
|
REQUIRE(w3.ok());
|
||||||
|
CHECK(std::string(buf) ==
|
||||||
|
"{\"random_2\":\"abcd1234\",\"time_2\":-123,\"flags\":[1,2,3],"
|
||||||
|
"\"ok\":true,\"nothing\":null}");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("json writer: экранирование и переполнение") {
|
||||||
|
char buf[64];
|
||||||
|
Writer w(buf, sizeof(buf));
|
||||||
|
w.begin_object();
|
||||||
|
w.key("a\"b\\c"); // ключ с кавычкой и обратным слешем
|
||||||
|
w.string("x\ny\"z");
|
||||||
|
w.end_object();
|
||||||
|
REQUIRE(w.ok());
|
||||||
|
CHECK(std::string(buf) == "{\"a\\\"b\\\\c\":\"x\\ny\\\"z\"}");
|
||||||
|
|
||||||
|
// Переполнение буфера — ok() = false, не выходим за границы.
|
||||||
|
Writer w2(buf, sizeof(buf));
|
||||||
|
w2.begin_object();
|
||||||
|
w2.key("data");
|
||||||
|
w2.string("0123456789012345678901234567890123456789012345678901234567890123456789");
|
||||||
|
w.end_object();
|
||||||
|
CHECK_FALSE(w2.ok());
|
||||||
|
CHECK(strlen(buf) < sizeof(buf)); // NUL-терминировано всегда
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("json parser: форматы протокола") {
|
||||||
|
// datapoint push
|
||||||
|
char buf[128];
|
||||||
|
strcpy(buf, "{\"name\":\"fan_speed\",\"value\":3,\"metadata\":{}}");
|
||||||
|
Doc doc;
|
||||||
|
REQUIRE(doc.parse(buf));
|
||||||
|
char name[32];
|
||||||
|
REQUIRE(doc.get_string("name", name, sizeof(name)));
|
||||||
|
CHECK(std::string(name) == "fan_speed");
|
||||||
|
int64_t value = -1;
|
||||||
|
REQUIRE(doc.get_int("value", &value));
|
||||||
|
CHECK(value == 3);
|
||||||
|
bool b = true;
|
||||||
|
CHECK_FALSE(doc.get_bool("value", &b)); // не bool
|
||||||
|
CHECK_FALSE(doc.get_string("absent", name, sizeof(name)));
|
||||||
|
|
||||||
|
// key_exchange
|
||||||
|
strcpy(buf, "{\"key_exchange\":{\"ver\":1,\"proto\":1,\"key_id\":62999,"
|
||||||
|
"\"random_1\":\"ab+/12\",\"time_1\":-987654321,\"sec\":\"\"}}");
|
||||||
|
REQUIRE(doc.parse(buf));
|
||||||
|
int64_t ver = -1, key_id = -1, time_1 = -1;
|
||||||
|
REQUIRE(doc.get_int("ver", &ver));
|
||||||
|
REQUIRE(doc.get_int("key_id", &key_id));
|
||||||
|
REQUIRE(doc.get_int("time_1", &time_1));
|
||||||
|
char rnd[32];
|
||||||
|
REQUIRE(doc.get_string("random_1", rnd, sizeof(rnd)));
|
||||||
|
CHECK(ver == 1);
|
||||||
|
CHECK(key_id == 62999);
|
||||||
|
CHECK(time_1 == -987654321);
|
||||||
|
CHECK(std::string(rnd) == "ab+/12");
|
||||||
|
|
||||||
|
// envelope payload
|
||||||
|
strcpy(buf, "{\"seq_no\":42,\"data\":{\"name\":\"x\",\"value\":true}}");
|
||||||
|
REQUIRE(doc.parse(buf));
|
||||||
|
int64_t seq = -1;
|
||||||
|
REQUIRE(doc.get_int("seq_no", &seq));
|
||||||
|
CHECK(seq == 42);
|
||||||
|
bool v = false;
|
||||||
|
REQUIRE(doc.get_bool("value", &v));
|
||||||
|
CHECK(v);
|
||||||
|
|
||||||
|
// Мусор
|
||||||
|
strcpy(buf, "not json");
|
||||||
|
CHECK_FALSE(doc.parse(buf));
|
||||||
|
strcpy(buf, "[1,2]");
|
||||||
|
CHECK_FALSE(doc.parse(buf)); // верхний уровень — объект
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("json parser: экранированные строки") {
|
||||||
|
char buf[96];
|
||||||
|
strcpy(buf, "{\"name\":\"a\\\"b\\\\c\\nd\"}");
|
||||||
|
Doc doc;
|
||||||
|
REQUIRE(doc.parse(buf));
|
||||||
|
char out[32];
|
||||||
|
REQUIRE(doc.get_string("name", out, sizeof(out)));
|
||||||
|
CHECK(std::string(out) == "a\"b\\c\nd");
|
||||||
|
// Не помещается в out
|
||||||
|
char small[4];
|
||||||
|
CHECK_FALSE(doc.get_string("name", small, sizeof(small)));
|
||||||
|
}
|
||||||
108
tests/ayla/test_platform.cpp
Normal file
108
tests/ayla/test_platform.cpp
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// Тесты платформенного слоя (POSIX-путь; на ESP-IDF тесты не запускаются —
|
||||||
|
// верификация IDF-слоя сборкой и on-device приёмкой).
|
||||||
|
#include "doctest/doctest.h"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "ayla/platform/platform.hpp"
|
||||||
|
|
||||||
|
TEST_CASE("now_ms монотонен") {
|
||||||
|
uint64_t a = fgl::plat::now_ms();
|
||||||
|
fgl::plat::sleep_ms(30);
|
||||||
|
uint64_t b = fgl::plat::now_ms();
|
||||||
|
CHECK(b >= a + 25);
|
||||||
|
CHECK(b - a < 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("random заполняет и не повторяется тривиально") {
|
||||||
|
uint8_t a[16] = {}, b[16] = {};
|
||||||
|
REQUIRE(fgl::plat::random(a, sizeof(a)));
|
||||||
|
REQUIRE(fgl::plat::random(b, sizeof(b)));
|
||||||
|
CHECK_FALSE(memcmp(a, b, sizeof(a)) == 0);
|
||||||
|
bool all_zero = true;
|
||||||
|
for (uint8_t x : a) all_zero = all_zero && (x == 0);
|
||||||
|
CHECK_FALSE(all_zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("thread_create + thread_join: поток завершён после join") {
|
||||||
|
struct Ctx {
|
||||||
|
std::atomic<bool> ran{false};
|
||||||
|
std::atomic<bool> finished{false};
|
||||||
|
};
|
||||||
|
Ctx c;
|
||||||
|
fgl::plat::ThreadId tid = nullptr;
|
||||||
|
REQUIRE(fgl::plat::thread_create(
|
||||||
|
[](void* p) {
|
||||||
|
auto* x = static_cast<Ctx*>(p);
|
||||||
|
x->ran.store(true);
|
||||||
|
fgl::plat::sleep_ms(100);
|
||||||
|
x->finished.store(true);
|
||||||
|
},
|
||||||
|
&c, "t_join", 0, &tid));
|
||||||
|
REQUIRE(tid != nullptr);
|
||||||
|
CHECK_FALSE(c.finished.load());
|
||||||
|
fgl::plat::thread_join(tid); // вернётся после завершения потока
|
||||||
|
CHECK(c.ran.load());
|
||||||
|
CHECK(c.finished.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("tcp loopback: ephemeral-порт, connect/send/recv/echo, shutdown") {
|
||||||
|
int listen_fd = fgl::plat::tcp_listen(0);
|
||||||
|
REQUIRE(listen_fd >= 0);
|
||||||
|
const uint16_t port = fgl::plat::tcp_local_port(listen_fd);
|
||||||
|
REQUIRE(port != 0);
|
||||||
|
|
||||||
|
struct SrvCtx {
|
||||||
|
int lfd;
|
||||||
|
int cfd = -1;
|
||||||
|
std::atomic<bool> accepted{false};
|
||||||
|
std::atomic<bool> echoed{false};
|
||||||
|
std::atomic<uint32_t> peer_ip{0};
|
||||||
|
};
|
||||||
|
SrvCtx srv;
|
||||||
|
srv.lfd = listen_fd;
|
||||||
|
fgl::plat::ThreadId tid = nullptr;
|
||||||
|
REQUIRE(fgl::plat::thread_create(
|
||||||
|
[](void* p) {
|
||||||
|
auto* s = static_cast<SrvCtx*>(p);
|
||||||
|
uint32_t ip = 0;
|
||||||
|
uint16_t peer_port = 0;
|
||||||
|
int cfd = fgl::plat::tcp_accept(s->lfd, &ip, &peer_port);
|
||||||
|
if (cfd < 0) return;
|
||||||
|
s->cfd = cfd;
|
||||||
|
s->accepted.store(true);
|
||||||
|
s->peer_ip.store(ip);
|
||||||
|
fgl::plat::tcp_set_timeout(cfd, 5000, 5000);
|
||||||
|
char buf[16];
|
||||||
|
long n = fgl::plat::tcp_recv(cfd, buf, sizeof(buf));
|
||||||
|
if (n > 0) {
|
||||||
|
fgl::plat::tcp_send(cfd, buf, static_cast<size_t>(n));
|
||||||
|
s->echoed.store(true);
|
||||||
|
// ждём возможного второго запроса (для проверки shutdown ниже)
|
||||||
|
char buf2[16];
|
||||||
|
fgl::plat::tcp_recv(cfd, buf2, sizeof(buf2));
|
||||||
|
}
|
||||||
|
fgl::plat::tcp_close(cfd);
|
||||||
|
},
|
||||||
|
&srv, "t_srv", 0, &tid));
|
||||||
|
|
||||||
|
int cfd = fgl::plat::tcp_connect("127.0.0.1", port, 2000);
|
||||||
|
REQUIRE(cfd >= 0);
|
||||||
|
REQUIRE(fgl::plat::tcp_send(cfd, "ping", 4) == 4);
|
||||||
|
fgl::plat::tcp_set_timeout(cfd, 2000, 2000);
|
||||||
|
char buf[16] = {};
|
||||||
|
REQUIRE(fgl::plat::tcp_recv(cfd, buf, sizeof(buf)) == 4);
|
||||||
|
CHECK(std::string(buf, 4) == "ping");
|
||||||
|
fgl::plat::tcp_close(cfd);
|
||||||
|
|
||||||
|
REQUIRE(srv.accepted.load());
|
||||||
|
CHECK(srv.peer_ip.load() == 0x7f000001); // 127.0.0.1 в host byte order
|
||||||
|
REQUIRE(srv.echoed.load());
|
||||||
|
|
||||||
|
// shutdown прерывает блокированный recv сервера → поток завершается
|
||||||
|
fgl::plat::tcp_shutdown(srv.cfd);
|
||||||
|
fgl::plat::thread_join(tid);
|
||||||
|
fgl::plat::tcp_close(listen_fd);
|
||||||
|
}
|
||||||
295
tests/ayla/test_session_mock.py
Normal file
295
tests/ayla/test_session_mock.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Интеграционные сценарии сессии против mock-модуля (mock_ac.py).
|
||||||
|
|
||||||
|
usage: test_session_mock.py <session_runner> <mock_ac.py>
|
||||||
|
Сценарии: normal, rekey, fail_push, slot_503, no_poll, bad_ke, set_get.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
RUNNER = sys.argv[1]
|
||||||
|
MOCK = sys.argv[2]
|
||||||
|
|
||||||
|
# Синтетический ключ (НЕ боевой).
|
||||||
|
LANIP_KEY = "TW9ja0tleUFDbkdvMTIzNDU2Nzg5MDEyMw=="
|
||||||
|
KEY_ID = 64201
|
||||||
|
DSN = "AC000W00MOCK0001"
|
||||||
|
|
||||||
|
|
||||||
|
def free_port():
|
||||||
|
s = socket.socket()
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
port = s.getsockname()[1]
|
||||||
|
s.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
class Proc:
|
||||||
|
"""Процесс с построчным логом stdout."""
|
||||||
|
|
||||||
|
def __init__(self, argv, env=None):
|
||||||
|
self.lines = []
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.ev = threading.Event()
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
argv, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
text=True)
|
||||||
|
threading.Thread(target=self._reader, daemon=True).start()
|
||||||
|
|
||||||
|
def _reader(self):
|
||||||
|
for line in self.proc.stdout:
|
||||||
|
with self.lock:
|
||||||
|
self.lines.append(line.rstrip("\n"))
|
||||||
|
self.ev.set()
|
||||||
|
|
||||||
|
def wait_line(self, prefix, timeout, exclude_prefix=None):
|
||||||
|
"""Ждёт строку, начинающуюся с prefix (или содержащую, если prefix
|
||||||
|
начинается с '~')."""
|
||||||
|
contains = prefix.startswith("~")
|
||||||
|
needle = prefix[1:] if contains else prefix
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
seen = 0
|
||||||
|
while time.time() < deadline:
|
||||||
|
with self.lock:
|
||||||
|
for i, line in enumerate(self.lines):
|
||||||
|
if i < seen:
|
||||||
|
continue
|
||||||
|
hit = (needle in line) if contains else line.startswith(prefix)
|
||||||
|
if hit:
|
||||||
|
if exclude_prefix and line.startswith(exclude_prefix):
|
||||||
|
continue
|
||||||
|
return line
|
||||||
|
seen = i + 1
|
||||||
|
self.ev.wait(0.2)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def all_lines(self):
|
||||||
|
with self.lock:
|
||||||
|
return list(self.lines)
|
||||||
|
|
||||||
|
def stop(self, timeout=10):
|
||||||
|
self.proc.terminate()
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
def run_mock(extra, port):
|
||||||
|
p = Proc([sys.executable, MOCK, "--port", str(port), "--lanip-key", LANIP_KEY,
|
||||||
|
"--key-id", str(KEY_ID)] + extra)
|
||||||
|
if p.wait_line("READY", 10) is None:
|
||||||
|
raise RuntimeError("mock не стартовал: " + "\n".join(p.all_lines()))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def run_runner(mock_port, listen_port, props, duration, env_extra=None,
|
||||||
|
timeout=None):
|
||||||
|
env = dict(os.environ)
|
||||||
|
if env_extra:
|
||||||
|
env.update(env_extra)
|
||||||
|
argv = [RUNNER, "127.0.0.1", str(mock_port), str(listen_port), DSN,
|
||||||
|
LANIP_KEY, str(KEY_ID), str(duration)] + props
|
||||||
|
return Proc(argv, env=env), (timeout or duration + 25)
|
||||||
|
|
||||||
|
|
||||||
|
FAILURES = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, what):
|
||||||
|
tag = "ok " if cond else "FAIL"
|
||||||
|
print(f" [{tag}] {what}")
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(what)
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_normal():
|
||||||
|
print("== scenario: normal (установка, батч GET, push, delete)")
|
||||||
|
mock_port, listen = free_port(), free_port()
|
||||||
|
mock = run_mock([], mock_port)
|
||||||
|
runner, timeout = run_runner(mock_port, listen,
|
||||||
|
["operation_mode", "fan_speed"], 3)
|
||||||
|
line = runner.wait_line("STATE online", 10)
|
||||||
|
check(line is not None, "переход в online")
|
||||||
|
p1 = runner.wait_line("PROP operation_mode", 10)
|
||||||
|
p2 = runner.wait_line("PROP fan_speed", 10)
|
||||||
|
check(p1 is not None and "cid=" not in p1 and " i:6" in p1, f"push #1: {p1}")
|
||||||
|
check(p2 is not None and " i:4" in p2, f"push #2: {p2}")
|
||||||
|
check(runner.wait_line("DELETED", timeout) is not None, "delete_session")
|
||||||
|
check(runner.wait_line("STATS", 5) is not None, "STATS")
|
||||||
|
check("rekeys=1" in " ".join(runner.all_lines()), "ровно 1 key exchange")
|
||||||
|
check("CMD GET operation_mode" in "\n".join(mock.all_lines()),
|
||||||
|
"mock исполнил GET")
|
||||||
|
check("DELETE" in "\n".join(mock.all_lines()), "mock получил DELETE")
|
||||||
|
runner.stop()
|
||||||
|
mock.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_rekey():
|
||||||
|
print("== scenario: rekey по local_reg (chain непрерывна)")
|
||||||
|
mock_port, listen = free_port(), free_port()
|
||||||
|
mock = run_mock(["--rekey-every", "2", "--push-every", "1"], mock_port)
|
||||||
|
runner, timeout = run_runner(mock_port, listen, [], 6,
|
||||||
|
env_extra={"RUNNER_KEEPALIVE_MS": "1000"})
|
||||||
|
check(runner.wait_line("STATE online", 10) is not None, "online")
|
||||||
|
t1 = runner.wait_line("PROP tick", 15)
|
||||||
|
t2 = runner.wait_line("PROP tick", 15)
|
||||||
|
check(t1 is not None and t2 is not None, "спонтанные push после re-key")
|
||||||
|
runner.wait_line("DELETED", timeout)
|
||||||
|
stats = "\n".join(runner.all_lines())
|
||||||
|
check("rekeys=" in stats and "pushes_bad=0" in stats,
|
||||||
|
f"без потерянных push: {stats.splitlines()[-1] if stats else '?'}")
|
||||||
|
check("CMD bad-sign" not in "\n".join(mock.all_lines()),
|
||||||
|
"mock проверял подписи наших команд")
|
||||||
|
runner.stop()
|
||||||
|
mock.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_fail_push():
|
||||||
|
print("== scenario: push с битой подписью -> recovering -> online")
|
||||||
|
mock_port, listen = free_port(), free_port()
|
||||||
|
mock = run_mock(["--push-every", "1", "--fail-pushes", "1"], mock_port)
|
||||||
|
runner, timeout = run_runner(mock_port, listen, [], 8,
|
||||||
|
env_extra={"RUNNER_KEEPALIVE_MS": "1000"})
|
||||||
|
check(runner.wait_line("STATE online", 10) is not None, "online")
|
||||||
|
rec = runner.wait_line("STATE recovering 6", 15)
|
||||||
|
check(rec is not None, f"recovering после битой подписи: {rec}")
|
||||||
|
# Следующий push расшифровывается (цепочка в синке) -> online.
|
||||||
|
check(runner.wait_line("STATE online 0", 15) is not None,
|
||||||
|
"online восстановлен следующим валидным push")
|
||||||
|
check(runner.wait_line("STATS", timeout) is not None, "STATS получены")
|
||||||
|
stats = "\n".join(runner.all_lines())
|
||||||
|
stats_line = [l for l in stats.splitlines() if l.startswith("STATS")]
|
||||||
|
check("pushes_bad=1" in stats, f"учтён 1 битый push: {stats_line}")
|
||||||
|
runner.stop()
|
||||||
|
mock.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_slot_503():
|
||||||
|
print("== scenario: 503 — нет слотов")
|
||||||
|
mock_port, listen = free_port(), free_port()
|
||||||
|
mock = run_mock(["--503"], mock_port)
|
||||||
|
runner, timeout = run_runner(mock_port, listen, [], 4,
|
||||||
|
env_extra={"RUNNER_NOSLOT_RETRY_MS": "2000"})
|
||||||
|
line = runner.wait_line("STATE offline 1", 10)
|
||||||
|
check(line is not None, "offline с ошибкой NoSlot")
|
||||||
|
regs = [l for l in mock.all_lines() if l.startswith("REG")]
|
||||||
|
check(len(regs) >= 1, "local_reg доходил до модуля")
|
||||||
|
runner.stop()
|
||||||
|
mock.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_no_poll():
|
||||||
|
print("== scenario: KE без poll — активация не наступила, тихая пауза")
|
||||||
|
mock_port, listen = free_port(), free_port()
|
||||||
|
mock = run_mock(["--no-poll"], mock_port)
|
||||||
|
runner, timeout = run_runner(mock_port, listen, [], 5,
|
||||||
|
env_extra={"RUNNER_ACTIVATION_MS": "1500",
|
||||||
|
"RUNNER_QUIET_MS": "3000",
|
||||||
|
"RUNNER_KEEPALIVE_MS": "800"})
|
||||||
|
line = runner.wait_line("STATE recovering 5", 15)
|
||||||
|
check(line is not None, "recovering с ActivationTimeout")
|
||||||
|
time.sleep(1.0)
|
||||||
|
regs = [l for l in mock.all_lines() if l.startswith("REG")]
|
||||||
|
# Первый reg + возможно второй до таймаута активации; после — тишина.
|
||||||
|
check(len(regs) <= 3, f"нет спама local_reg (получено {len(regs)})")
|
||||||
|
runner.stop()
|
||||||
|
mock.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_bad_ke():
|
||||||
|
print("== scenario: key_exchange ver=2 -> key_error")
|
||||||
|
mock_port, listen = free_port(), free_port()
|
||||||
|
mock = run_mock(["--fail-first-ke"], mock_port)
|
||||||
|
runner, timeout = run_runner(mock_port, listen, [], 3)
|
||||||
|
line = runner.wait_line("STATE key_error 4", 10)
|
||||||
|
check(line is not None, "key_error с BadKeyExchange")
|
||||||
|
check(mock.wait_line("~-> 426", 10) is not None, "mock получил 426")
|
||||||
|
runner.stop()
|
||||||
|
mock.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_chain_divergence_heals():
|
||||||
|
print("== scenario: входящая цепочка расходится на 1 сообщение и сходится")
|
||||||
|
mock_port, listen = free_port(), free_port()
|
||||||
|
mock = run_mock(["--push-every", "1", "--garbage-pushes", "1"], mock_port)
|
||||||
|
runner, timeout = run_runner(mock_port, listen, [], 8,
|
||||||
|
env_extra={"RUNNER_KEEPALIVE_MS": "1000"})
|
||||||
|
check(runner.wait_line("STATE online", 10) is not None, "online")
|
||||||
|
check(runner.wait_line("STATE recovering 6", 15) is not None,
|
||||||
|
"recovering после урезанного push")
|
||||||
|
# CBC-состояние = последний шифроблок: следующий полный push сходится.
|
||||||
|
check(runner.wait_line("STATE online 0", 15) is not None,
|
||||||
|
"online восстановлен следующим push (цепочка сошлась)")
|
||||||
|
stats = runner.wait_line("STATS", timeout)
|
||||||
|
# Урезанный push + один residual (первый блок следующего) = 2 потери,
|
||||||
|
# далее цепочка сошлась (состояние CBC = последний шифроблок).
|
||||||
|
check(stats is not None and "pushes_bad=2" in stats,
|
||||||
|
f"ровно 2 потери: {stats}")
|
||||||
|
runner.stop()
|
||||||
|
mock.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_outbound_desync_rekey():
|
||||||
|
print("== scenario: исходящий десинк -> модуль ре-кает на local_reg")
|
||||||
|
mock_port, listen = free_port(), free_port()
|
||||||
|
mock = run_mock(["--break-outbound", "1"], mock_port)
|
||||||
|
runner, timeout = run_runner(mock_port, listen, ["operation_mode"], 12,
|
||||||
|
env_extra={"RUNNER_KEEPALIVE_MS": "1000",
|
||||||
|
"RUNNER_SET_NAME": "fan_speed",
|
||||||
|
"RUNNER_SET_VALUE": "2",
|
||||||
|
"RUNNER_SET_AT": "6"})
|
||||||
|
check(runner.wait_line("STATE online", 10) is not None, "online")
|
||||||
|
check(mock.wait_line("~CMD skipped", 10) is not None,
|
||||||
|
"мок пропустил ответ (десинк)")
|
||||||
|
check(mock.wait_line("~CMD bad-sign", 15) is not None,
|
||||||
|
"подпись следующей команды не сошлась")
|
||||||
|
# Реальный модуль ре-кает на следующем local_reg (как в probe1-3);
|
||||||
|
# после re-key цепочки свежие — SET доходит.
|
||||||
|
check(mock.wait_line("~CMD SET fan_speed=2", 20) is not None,
|
||||||
|
"SET доставлена после re-key")
|
||||||
|
check(len([l for l in mock.all_lines() if l.startswith("KE ")]) >= 2,
|
||||||
|
"второй key exchange был")
|
||||||
|
runner.stop()
|
||||||
|
mock.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_set_get():
|
||||||
|
print("== scenario: SET + подтверждение GET (эха нет)")
|
||||||
|
mock_port, listen = free_port(), free_port()
|
||||||
|
mock = run_mock([], mock_port)
|
||||||
|
env = {"RUNNER_SET_NAME": "fan_speed", "RUNNER_SET_VALUE": "3",
|
||||||
|
"RUNNER_SET_AT": "1"}
|
||||||
|
runner, timeout = run_runner(mock_port, listen, [], 5, env_extra=env)
|
||||||
|
line = runner.wait_line("SET_DONE fan_speed=3", 10)
|
||||||
|
check(line is not None, "SET отправлена")
|
||||||
|
check(mock.wait_line("CMD SET fan_speed=3", 10) is not None,
|
||||||
|
"mock применил SET")
|
||||||
|
runner.wait_line("DELETED", timeout)
|
||||||
|
runner.stop()
|
||||||
|
mock.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
scenarios = [scenario_normal, scenario_rekey, scenario_fail_push,
|
||||||
|
scenario_slot_503, scenario_no_poll, scenario_bad_ke,
|
||||||
|
scenario_chain_divergence_heals,
|
||||||
|
scenario_outbound_desync_rekey, scenario_set_get]
|
||||||
|
for sc in scenarios:
|
||||||
|
sc()
|
||||||
|
print()
|
||||||
|
if FAILURES:
|
||||||
|
print("ПРОВАЛЕНО:", len(FAILURES))
|
||||||
|
for f in FAILURES:
|
||||||
|
print(" -", f)
|
||||||
|
sys.exit(1)
|
||||||
|
print("ВСЕ СЦЕНАРИИ ПРОЙДЕНЫ")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
20
third_party/jsmn/LICENSE
vendored
Normal file
20
third_party/jsmn/LICENSE
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
Copyright (c) 2010 Serge A. Zaitsev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
471
third_party/jsmn/jsmn.h
vendored
Normal file
471
third_party/jsmn/jsmn.h
vendored
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2010 Serge Zaitsev
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
#ifndef JSMN_H
|
||||||
|
#define JSMN_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef JSMN_STATIC
|
||||||
|
#define JSMN_API static
|
||||||
|
#else
|
||||||
|
#define JSMN_API extern
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON type identifier. Basic types are:
|
||||||
|
* o Object
|
||||||
|
* o Array
|
||||||
|
* o String
|
||||||
|
* o Other primitive: number, boolean (true/false) or null
|
||||||
|
*/
|
||||||
|
typedef enum {
|
||||||
|
JSMN_UNDEFINED = 0,
|
||||||
|
JSMN_OBJECT = 1 << 0,
|
||||||
|
JSMN_ARRAY = 1 << 1,
|
||||||
|
JSMN_STRING = 1 << 2,
|
||||||
|
JSMN_PRIMITIVE = 1 << 3
|
||||||
|
} jsmntype_t;
|
||||||
|
|
||||||
|
enum jsmnerr {
|
||||||
|
/* Not enough tokens were provided */
|
||||||
|
JSMN_ERROR_NOMEM = -1,
|
||||||
|
/* Invalid character inside JSON string */
|
||||||
|
JSMN_ERROR_INVAL = -2,
|
||||||
|
/* The string is not a full JSON packet, more bytes expected */
|
||||||
|
JSMN_ERROR_PART = -3
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON token description.
|
||||||
|
* type type (object, array, string etc.)
|
||||||
|
* start start position in JSON data string
|
||||||
|
* end end position in JSON data string
|
||||||
|
*/
|
||||||
|
typedef struct jsmntok {
|
||||||
|
jsmntype_t type;
|
||||||
|
int start;
|
||||||
|
int end;
|
||||||
|
int size;
|
||||||
|
#ifdef JSMN_PARENT_LINKS
|
||||||
|
int parent;
|
||||||
|
#endif
|
||||||
|
} jsmntok_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON parser. Contains an array of token blocks available. Also stores
|
||||||
|
* the string being parsed now and current position in that string.
|
||||||
|
*/
|
||||||
|
typedef struct jsmn_parser {
|
||||||
|
unsigned int pos; /* offset in the JSON string */
|
||||||
|
unsigned int toknext; /* next token to allocate */
|
||||||
|
int toksuper; /* superior token node, e.g. parent object or array */
|
||||||
|
} jsmn_parser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create JSON parser over an array of tokens
|
||||||
|
*/
|
||||||
|
JSMN_API void jsmn_init(jsmn_parser *parser);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run JSON parser. It parses a JSON data string into and array of tokens, each
|
||||||
|
* describing
|
||||||
|
* a single JSON object.
|
||||||
|
*/
|
||||||
|
JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len,
|
||||||
|
jsmntok_t *tokens, const unsigned int num_tokens);
|
||||||
|
|
||||||
|
#ifndef JSMN_HEADER
|
||||||
|
/**
|
||||||
|
* Allocates a fresh unused token from the token pool.
|
||||||
|
*/
|
||||||
|
static jsmntok_t *jsmn_alloc_token(jsmn_parser *parser, jsmntok_t *tokens,
|
||||||
|
const size_t num_tokens) {
|
||||||
|
jsmntok_t *tok;
|
||||||
|
if (parser->toknext >= num_tokens) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
tok = &tokens[parser->toknext++];
|
||||||
|
tok->start = tok->end = -1;
|
||||||
|
tok->size = 0;
|
||||||
|
#ifdef JSMN_PARENT_LINKS
|
||||||
|
tok->parent = -1;
|
||||||
|
#endif
|
||||||
|
return tok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills token type and boundaries.
|
||||||
|
*/
|
||||||
|
static void jsmn_fill_token(jsmntok_t *token, const jsmntype_t type,
|
||||||
|
const int start, const int end) {
|
||||||
|
token->type = type;
|
||||||
|
token->start = start;
|
||||||
|
token->end = end;
|
||||||
|
token->size = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills next available token with JSON primitive.
|
||||||
|
*/
|
||||||
|
static int jsmn_parse_primitive(jsmn_parser *parser, const char *js,
|
||||||
|
const size_t len, jsmntok_t *tokens,
|
||||||
|
const size_t num_tokens) {
|
||||||
|
jsmntok_t *token;
|
||||||
|
int start;
|
||||||
|
|
||||||
|
start = parser->pos;
|
||||||
|
|
||||||
|
for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) {
|
||||||
|
switch (js[parser->pos]) {
|
||||||
|
#ifndef JSMN_STRICT
|
||||||
|
/* In strict mode primitive must be followed by "," or "}" or "]" */
|
||||||
|
case ':':
|
||||||
|
#endif
|
||||||
|
case '\t':
|
||||||
|
case '\r':
|
||||||
|
case '\n':
|
||||||
|
case ' ':
|
||||||
|
case ',':
|
||||||
|
case ']':
|
||||||
|
case '}':
|
||||||
|
goto found;
|
||||||
|
default:
|
||||||
|
/* to quiet a warning from gcc*/
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (js[parser->pos] < 32 || js[parser->pos] >= 127) {
|
||||||
|
parser->pos = start;
|
||||||
|
return JSMN_ERROR_INVAL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#ifdef JSMN_STRICT
|
||||||
|
/* In strict mode primitive must be followed by a comma/object/array */
|
||||||
|
parser->pos = start;
|
||||||
|
return JSMN_ERROR_PART;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
found:
|
||||||
|
if (tokens == NULL) {
|
||||||
|
parser->pos--;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
token = jsmn_alloc_token(parser, tokens, num_tokens);
|
||||||
|
if (token == NULL) {
|
||||||
|
parser->pos = start;
|
||||||
|
return JSMN_ERROR_NOMEM;
|
||||||
|
}
|
||||||
|
jsmn_fill_token(token, JSMN_PRIMITIVE, start, parser->pos);
|
||||||
|
#ifdef JSMN_PARENT_LINKS
|
||||||
|
token->parent = parser->toksuper;
|
||||||
|
#endif
|
||||||
|
parser->pos--;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills next token with JSON string.
|
||||||
|
*/
|
||||||
|
static int jsmn_parse_string(jsmn_parser *parser, const char *js,
|
||||||
|
const size_t len, jsmntok_t *tokens,
|
||||||
|
const size_t num_tokens) {
|
||||||
|
jsmntok_t *token;
|
||||||
|
|
||||||
|
int start = parser->pos;
|
||||||
|
|
||||||
|
/* Skip starting quote */
|
||||||
|
parser->pos++;
|
||||||
|
|
||||||
|
for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) {
|
||||||
|
char c = js[parser->pos];
|
||||||
|
|
||||||
|
/* Quote: end of string */
|
||||||
|
if (c == '\"') {
|
||||||
|
if (tokens == NULL) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
token = jsmn_alloc_token(parser, tokens, num_tokens);
|
||||||
|
if (token == NULL) {
|
||||||
|
parser->pos = start;
|
||||||
|
return JSMN_ERROR_NOMEM;
|
||||||
|
}
|
||||||
|
jsmn_fill_token(token, JSMN_STRING, start + 1, parser->pos);
|
||||||
|
#ifdef JSMN_PARENT_LINKS
|
||||||
|
token->parent = parser->toksuper;
|
||||||
|
#endif
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Backslash: Quoted symbol expected */
|
||||||
|
if (c == '\\' && parser->pos + 1 < len) {
|
||||||
|
int i;
|
||||||
|
parser->pos++;
|
||||||
|
switch (js[parser->pos]) {
|
||||||
|
/* Allowed escaped symbols */
|
||||||
|
case '\"':
|
||||||
|
case '/':
|
||||||
|
case '\\':
|
||||||
|
case 'b':
|
||||||
|
case 'f':
|
||||||
|
case 'r':
|
||||||
|
case 'n':
|
||||||
|
case 't':
|
||||||
|
break;
|
||||||
|
/* Allows escaped symbol \uXXXX */
|
||||||
|
case 'u':
|
||||||
|
parser->pos++;
|
||||||
|
for (i = 0; i < 4 && parser->pos < len && js[parser->pos] != '\0';
|
||||||
|
i++) {
|
||||||
|
/* If it isn't a hex character we have an error */
|
||||||
|
if (!((js[parser->pos] >= 48 && js[parser->pos] <= 57) || /* 0-9 */
|
||||||
|
(js[parser->pos] >= 65 && js[parser->pos] <= 70) || /* A-F */
|
||||||
|
(js[parser->pos] >= 97 && js[parser->pos] <= 102))) { /* a-f */
|
||||||
|
parser->pos = start;
|
||||||
|
return JSMN_ERROR_INVAL;
|
||||||
|
}
|
||||||
|
parser->pos++;
|
||||||
|
}
|
||||||
|
parser->pos--;
|
||||||
|
break;
|
||||||
|
/* Unexpected symbol */
|
||||||
|
default:
|
||||||
|
parser->pos = start;
|
||||||
|
return JSMN_ERROR_INVAL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parser->pos = start;
|
||||||
|
return JSMN_ERROR_PART;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse JSON string and fill tokens.
|
||||||
|
*/
|
||||||
|
JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len,
|
||||||
|
jsmntok_t *tokens, const unsigned int num_tokens) {
|
||||||
|
int r;
|
||||||
|
int i;
|
||||||
|
jsmntok_t *token;
|
||||||
|
int count = parser->toknext;
|
||||||
|
|
||||||
|
for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) {
|
||||||
|
char c;
|
||||||
|
jsmntype_t type;
|
||||||
|
|
||||||
|
c = js[parser->pos];
|
||||||
|
switch (c) {
|
||||||
|
case '{':
|
||||||
|
case '[':
|
||||||
|
count++;
|
||||||
|
if (tokens == NULL) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
token = jsmn_alloc_token(parser, tokens, num_tokens);
|
||||||
|
if (token == NULL) {
|
||||||
|
return JSMN_ERROR_NOMEM;
|
||||||
|
}
|
||||||
|
if (parser->toksuper != -1) {
|
||||||
|
jsmntok_t *t = &tokens[parser->toksuper];
|
||||||
|
#ifdef JSMN_STRICT
|
||||||
|
/* In strict mode an object or array can't become a key */
|
||||||
|
if (t->type == JSMN_OBJECT) {
|
||||||
|
return JSMN_ERROR_INVAL;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
t->size++;
|
||||||
|
#ifdef JSMN_PARENT_LINKS
|
||||||
|
token->parent = parser->toksuper;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
token->type = (c == '{' ? JSMN_OBJECT : JSMN_ARRAY);
|
||||||
|
token->start = parser->pos;
|
||||||
|
parser->toksuper = parser->toknext - 1;
|
||||||
|
break;
|
||||||
|
case '}':
|
||||||
|
case ']':
|
||||||
|
if (tokens == NULL) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
type = (c == '}' ? JSMN_OBJECT : JSMN_ARRAY);
|
||||||
|
#ifdef JSMN_PARENT_LINKS
|
||||||
|
if (parser->toknext < 1) {
|
||||||
|
return JSMN_ERROR_INVAL;
|
||||||
|
}
|
||||||
|
token = &tokens[parser->toknext - 1];
|
||||||
|
for (;;) {
|
||||||
|
if (token->start != -1 && token->end == -1) {
|
||||||
|
if (token->type != type) {
|
||||||
|
return JSMN_ERROR_INVAL;
|
||||||
|
}
|
||||||
|
token->end = parser->pos + 1;
|
||||||
|
parser->toksuper = token->parent;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (token->parent == -1) {
|
||||||
|
if (token->type != type || parser->toksuper == -1) {
|
||||||
|
return JSMN_ERROR_INVAL;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
token = &tokens[token->parent];
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
for (i = parser->toknext - 1; i >= 0; i--) {
|
||||||
|
token = &tokens[i];
|
||||||
|
if (token->start != -1 && token->end == -1) {
|
||||||
|
if (token->type != type) {
|
||||||
|
return JSMN_ERROR_INVAL;
|
||||||
|
}
|
||||||
|
parser->toksuper = -1;
|
||||||
|
token->end = parser->pos + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Error if unmatched closing bracket */
|
||||||
|
if (i == -1) {
|
||||||
|
return JSMN_ERROR_INVAL;
|
||||||
|
}
|
||||||
|
for (; i >= 0; i--) {
|
||||||
|
token = &tokens[i];
|
||||||
|
if (token->start != -1 && token->end == -1) {
|
||||||
|
parser->toksuper = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
break;
|
||||||
|
case '\"':
|
||||||
|
r = jsmn_parse_string(parser, js, len, tokens, num_tokens);
|
||||||
|
if (r < 0) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
if (parser->toksuper != -1 && tokens != NULL) {
|
||||||
|
tokens[parser->toksuper].size++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '\t':
|
||||||
|
case '\r':
|
||||||
|
case '\n':
|
||||||
|
case ' ':
|
||||||
|
break;
|
||||||
|
case ':':
|
||||||
|
parser->toksuper = parser->toknext - 1;
|
||||||
|
break;
|
||||||
|
case ',':
|
||||||
|
if (tokens != NULL && parser->toksuper != -1 &&
|
||||||
|
tokens[parser->toksuper].type != JSMN_ARRAY &&
|
||||||
|
tokens[parser->toksuper].type != JSMN_OBJECT) {
|
||||||
|
#ifdef JSMN_PARENT_LINKS
|
||||||
|
parser->toksuper = tokens[parser->toksuper].parent;
|
||||||
|
#else
|
||||||
|
for (i = parser->toknext - 1; i >= 0; i--) {
|
||||||
|
if (tokens[i].type == JSMN_ARRAY || tokens[i].type == JSMN_OBJECT) {
|
||||||
|
if (tokens[i].start != -1 && tokens[i].end == -1) {
|
||||||
|
parser->toksuper = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
#ifdef JSMN_STRICT
|
||||||
|
/* In strict mode primitives are: numbers and booleans */
|
||||||
|
case '-':
|
||||||
|
case '0':
|
||||||
|
case '1':
|
||||||
|
case '2':
|
||||||
|
case '3':
|
||||||
|
case '4':
|
||||||
|
case '5':
|
||||||
|
case '6':
|
||||||
|
case '7':
|
||||||
|
case '8':
|
||||||
|
case '9':
|
||||||
|
case 't':
|
||||||
|
case 'f':
|
||||||
|
case 'n':
|
||||||
|
/* And they must not be keys of the object */
|
||||||
|
if (tokens != NULL && parser->toksuper != -1) {
|
||||||
|
const jsmntok_t *t = &tokens[parser->toksuper];
|
||||||
|
if (t->type == JSMN_OBJECT ||
|
||||||
|
(t->type == JSMN_STRING && t->size != 0)) {
|
||||||
|
return JSMN_ERROR_INVAL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
/* In non-strict mode every unquoted value is a primitive */
|
||||||
|
default:
|
||||||
|
#endif
|
||||||
|
r = jsmn_parse_primitive(parser, js, len, tokens, num_tokens);
|
||||||
|
if (r < 0) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
if (parser->toksuper != -1 && tokens != NULL) {
|
||||||
|
tokens[parser->toksuper].size++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
#ifdef JSMN_STRICT
|
||||||
|
/* Unexpected char in strict mode */
|
||||||
|
default:
|
||||||
|
return JSMN_ERROR_INVAL;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens != NULL) {
|
||||||
|
for (i = parser->toknext - 1; i >= 0; i--) {
|
||||||
|
/* Unmatched opened object or array */
|
||||||
|
if (tokens[i].start != -1 && tokens[i].end == -1) {
|
||||||
|
return JSMN_ERROR_PART;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new parser based over a given buffer with an array of tokens
|
||||||
|
* available.
|
||||||
|
*/
|
||||||
|
JSMN_API void jsmn_init(jsmn_parser *parser) {
|
||||||
|
parser->pos = 0;
|
||||||
|
parser->toknext = 0;
|
||||||
|
parser->toksuper = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif /* JSMN_HEADER */
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* JSMN_H */
|
||||||
Reference in New Issue
Block a user