Compare commits

..

4 Commits

Author SHA1 Message Date
bfdf22ba64 core(M2): машина состояний сессии Ayla LAN + mock-модуль + интеграционные сценарии
- session.{hpp,cpp}: state machine (idle/registering/online/recovering/
  offline/key_error); httpd-обработчики key_exchange (200/426/412, re-key
  прозрачно), commands (одна команда, 206/200, envelope, глобальный seq_no),
  datapoint (unpack -> PropertyEvent / 401+тишина 50с для re-key-восстановления);
  сессионный поток: local_reg POST?dsn/PUT (local_ip_for), keep-alive, backoff
  x1.6->60с, 503->offline/NoSlot, activation-timeout->recovering, delete_session
  с ожиданием выдачи; очередь с coalescing + batch; телеметрия; колбэки из
  двух потоков с задокументированным контрактом; буферы datapoint-пути в Impl.
- platform: local_ip_for (UDP-connect) posix+esp-idf; стек httpd 24576
  (переполнение 16КБ поймано gdb на Release).
- mock_ac.py: мок-модуль, stdlib-only чистый python AES-256 (свёрстан с
  pycryptodome); сценарии: 503, no-poll, rekey-every, stale-gap (эмуляция
  'вернувшегося' приложения), fail-pushes (битая подпись), garbage-pushes
  (обрыв блока), break-outbound (исходящий десинк -> модуль ре-кает на
  local_reg, как probe1-3), push-every, fail-first-ke.
- session_runner + test_session_mock.py: 9 сценариев через ctest, включая
  самосинхронизацию CBC и восстановление после исходящего десинка.
- Прибор AP-WC1E: активация <=1с; re-key семантика ИСПРАВЛЕНА по живым
  тестам: re-key при зазоре local_reg >= ~44-50с (не по возрасту сессии!);
  при честном keep-alive 15с сессия стабильна без re-key; PROTOCOL/LEGACY/
  PLAN обновлены; восстановление = тишина >порога + возврат.
- CI: 7/7 x3 (gcc-Rel, gcc-ASan/UBSan, clang); ESP-IDF esp32 build complete.
Ревью под-агентом: 2 круга (стек httpd, залипание состояний, dangling cfg,
физика десинка) — APPROVED.
2026-09-27 10:53:28 +03:00
345fe19ca7 docs: M0, M1 отмечены выполненными в плане библиотеки 2026-09-22 15:46:27 +03:00
e91d4605b1 core(M1): ayla-криптография, конверт, JSON (jsmn), HTTP-клиент
- crypto: KDF Ayla (двойной HMAC, suffix 0x30/31/32; app/dev направления),
  AES-256-CBC с непрерывной цепочкой (iv обновляется mbedtls на месте),
  Java-паддинг >=1 NUL; mode-latch против misuse (encrypt|decrypt);
  zeroize ключей при повторном init; векторы из APK (4 сессии × 4 сообщения,
  включая legacy-приём без NUL) — scripts/gen_kdf_vectors.py.
- envelope: pack/unpack {"enc","sign"}; расшифровка (движение цепочки)
  ДО проверки подписи; сравнение подписи в константном времени;
  extract_seq_no — depth-1 сканер без лимита токенов (OOB после escape
  исправлен, регресс-тесты по ASan-репро ревьюера).
- json: Writer (фикс. буфер, стек глубин, escape, ok()=false при
  переполнении) + Doc на jsmn (64 токена, unescape, overflow-guard).
- httpc: блокирующий POST/PUT для local_reg (статус 200-599, дренаж,
  shutdown перед close).
- third_party/jsmn (MIT, JSMN_STATIC).
- CMake: mbedtls системный (/usr/include/mbedtls3) или FetchContent;
  IDF: PRIV_REQUIRES mbedtls.
- CI: 3 конфигурации — gcc-Release, gcc-Debug+ASan/UBSan, clang-Release;
  6/6 тестов стабильно; ESP-IDF esp32 build complete.
Ревью под-агентом: 3 круга (OOB-блокер + тестовые флаки закрыты), APPROVED.
2026-09-22 15:46:18 +03:00
b44004627b core(M0): монорепо-каркас — CMake (posix+esp-idf), платслой, лог, мини-httpd
- CMakeLists в корне: ветвление ESP_PLATFORM (idf_component_register,
  lwip/esp_timer/esp_hw_support/pthread) / POSIX (статическая библиотека
  fgl-aircon, C++20, -fno-exceptions -fno-rtti, -Werror).
- src/ayla/platform: сокеты/потоки/CSPRNG/время; posix (getrandom, poll,
  pthread_join) и esp-idf (lwip_select, esp_fill_random, pthread-слой IDF);
  tcp_shutdown/tcp_local_port/thread_join для управляемой остановки.
- src/ayla: log (sink, без printf); мини-httpd/1.1 (keep-alive, Content-Length,
  лимиты заголовков/тела, ephemeral-порт, жизненный цикл с гарантией
  завершения потока: shutdown(active)→join→close).
- tests/ayla: platform (join, loopback+shutdown) и httpd (404, keep-alive,
  обработчик/парсинг, oversize-400, stop при живом соединении, стрим
  заголовков). doctest через FetchContent.
- scripts/ci.sh: сборка+ctest. ESP-IDF v5.5.5 esp32: смоук-сборка с ядром
  как компонентом — Project build complete.
Ревью под-агентом: 3 круга, все блокеры (жизненный цикл httpd) закрыты, APPROVED.
2026-09-22 00:27:33 +03:00
38 changed files with 5483 additions and 26 deletions

4
.gitignore vendored
View File

@@ -9,3 +9,7 @@ venv/
# Локальный конфиг устройства (содержит lanip_key)
docs/legacy/config_kata.json
# Сборка
build/
build-*/

105
CMakeLists.txt Normal file
View 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()

BIN
core.37208 Normal file

Binary file not shown.

View File

@@ -30,13 +30,14 @@
### 2.1. [ГЛАВНАЯ ПРИЧИНА «РАССИНХРОНИЗАЦИИ»] Keep-alive 1200 с вместо 10–15 с
`notifier.py:_KEEP_ALIVE_INTERVAL = 1200.0`. APK: 10 с (или `lan.json:keepAlive/3`).
Проверено на приборе: **единственный механизм восстановления после расхождения
CBC-цепочек — принудительный re-key, который модуль делает при получении
`local_reg` для сессии старше ≈44 с. Ответы 400/401 модуль игнорирует.**
Следствие для legacy: любая потерянная пара запрос-ответ/обрыв соединения →
обе стороны «глохнут» на срок до 20 минут (до следующего local_reg). Наблюдаемый
симптом «перестаёт понимать кондиционер» с самопроизвольным восстановлением —
именно это.
Проверено на приборе: **модуль игнорирует 400/401 на свои POST; переkey
происходит при `local_reg` после зазора ≥ ~44–50 с от предыдущего** (при
keep-alive 10–15 с re-key вообще не происходит). Следствие для legacy: любая
потерянная пара запрос-ответ → обе стороны «глохнут» до следующего local_reg
(до 20 минут), который завершится re-key — наблюдаемый симптом «перестаёт
понимать кондиционер, потом сам чинится» — именно это. Корректная стратегия
для новой реализации: при ошибке расшифровки — пауза ~50 с, затем local_reg
(re-key гарантирован).
Дополнительно: длинные паузы между local_reg держат сессию «полуживой»
(модуль не видит keep-alive, но слот может удерживаться), и конфликт за

View File

@@ -199,9 +199,13 @@ int32_t fgl_convert_from_input(fgl_template, fgl_prop, int32_t disp,
### 5.2. Keep-alive и re-key
* Таймер `keepalive_ms` (default **15000**); по истечении — `PUT local_reg`
c `notify=(очередь непуста)`. Каждый `commands.json` перезапускает таймер.
* Re-key (очередной local_reg при возрасте сессии ≥ ~44 с) — штатное
событие: перегенерация шифров/цепочек, сессия не пересоздаётся, начальная
синхронизация не повторяется; seq_no приложения продолжает глобальный счётчик.
* Re-key — событие по инициативе модуля (при зазоре local_reg ≥ ~44–50 с,
[ПРОВЕРЕНО НА ПРИБОРЕ]; при штатном keep-alive НЕ происходит): обработать
как обычный KE (перегенерация шифров/цепочек), сессию не пересоздавать,
начальную синхронизацию не повторять; seq_no приложения продолжает
глобальный счётчик.
* Восстановление при ошибке расшифровки: тишина > порога (50 с по умолчанию)
и возврат — модуль гарантированно ре-кает [ПРОВЕРЕНО НА ПРИБОРЕ].
* Анти-спам: ≤1 local_reg/с; notify=1 — один на пакет команд.
### 5.3. Очередь команд
@@ -289,9 +293,9 @@ HA-превью (PLAN_HOME_ASSISTANT §4) и тестами.
| # | Содержимое | Критерии приёмки |
|---|------------|------------------|
| M0 | Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI | Собирается linux+esp-idf; пустой httpd отвечает 404 |
| 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 с |
| M0 ✅ | Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI | Собирается linux+esp-idf; пустой httpd отвечает 404 |
| 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: при зазоре 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 |
| M4 | fglctl-пример, `tools/fglair-discover` (в т.ч. `--format esphome-secrets`), README библиотеки (сборка IDF/POSIX, тесты) | 24 ч на приборе: 0 рассинхронов; README готов |
| M5 | (Опция) `FglHub` N устройств; mDNS-резолвер как опция host-разрешения | Два устройства одновременно |

View File

@@ -216,15 +216,21 @@ GET http://<ip приложения>:<порт>/local_lan/commands.json
один «пустой» опрос `commands.json` — это признак принятой сессии.
2. `local_reg` от endpoint'а с живой сессией **моложе ~40 с** → только
keep-alive, без key exchange.
3. `local_reg` от endpoint'а с сессией **старше ~44 с** → модуль принудительно
инициирует новый key exchange (ротация сессионных ключей). Т.е. при штатном
keep-alive каждые 10–15 с ключи ротируются примерно каждые 45–60 с.
`time_1` модуля — тикающий счётчик с шагом ≈10 нс (аптайм); порог,
вероятно, 44 с в этих единицах либо просто 4.4e9 тиков.
3. `local_reg` при **зазоре ≥ ~44–50 с** от предыдущего local_reg →
модуль принудительно инициирует новый key exchange («вернувшееся»
приложение получает свежие ключи). При штатном keep-alive каждые 10–15 с
re-key НЕ происходит — сессия живёт сколь угодно долго (проверено:
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 модуля игнорируются**: сессия продолжает работать,
re-key не вызывается. Единственный механизм восстановления после расхождения
CBC-цепочек — принудительный re-key по `local_reg` (п. 3). Поэтому интервал
keep-alive = интервал потенциального «зависания» при десинхроне.
re-key не вызывается. Восстановление после расхождения CBC-цепочек —
намеренная «тишина» приложения на > порога из п. 3 с последующим
`local_reg`: модуль сочтёт приложение вернувшимся и ре-кает. Т.е. стратегия
самовосстановления: при ошибке расшифровки — пауза keep-alive ~50–60 с,
затем возобновить (проверено на приборе). Отдельный случай — бракованная
подпись при живой цепочке (сообщение расшифровано, подпись не сошлась):
цепочка НЕ расходится, следующий push восстанавливает работу без re-key.
5. `delete_session` освобождает слот немедленно; следующий `local_reg` того же
endpoint'а создаёт новую сессию.
6. Наблюдавшийся (не воспроизведённый повторно) режим отказа: модуль отвечает
@@ -316,11 +322,12 @@ data: {"id":"<id команды>","ack_status":200,"ack_message":0,"dsn":"..."}
* **Потеря CBC-цепочки** (§3.4): модуль не может расшифровать ответ приложения /
приложение не может расшифровать push модуля. Ответы 401/400 на POST модуля
**игнорируются** — модуль продолжает слать в «сломанный» канал. Восстановление
происходит только когда очередной `local_reg` (по возрасту ≥ ~44 с или от
нового endpoint'а) вызовет новый key exchange. Следствие: **интервал
keep-alive = максимальное время «мёртвой» сессии при десинхроне**
(10–15 с — незаметно; 1200 с как в legacy-скрипте — 20 минут глухоты).
**игнорируются** — модуль продолжает слать в «сломанный» канал. Восстановление:
приложение замолкает на > ~44–50 с (порог «возврата» из п. 4.4.3) и шлёт
`local_reg` — модуль переkey'ается. Реализация ядра: при ошибке расшифровки
пауза keep-alive ~50 с, затем возобновление. В legacy-скрипте пауза получалась
«бесплатно» из-за keep-alive 1200 с: каждый цикл завершался re-key при
возврате — потому рассинхрон «сам чинился» через ~20 минут.
* **Смена lanip_key** (`key_id` не совпал): теоретический путь по APK — 412 +
`refreshLanConfig()` из облака. За 5 лет эксплуатации прибора ротации ключа
не наблюдалось ни разу; ключ, по-видимому, зашит в модуль, облако лишь хранит

View 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
View 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"

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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__)

View 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

View 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

View 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
View 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
View 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
View 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)

View 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
View 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()

View 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
View 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);
}

View 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
View 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
View 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
View 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)));
}

View 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);
}

View 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
View 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
View 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 */