From d6eed6692695c03a7f5c55664d5786fe7ae02475 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 21 Oct 2026 20:16:38 +0200 Subject: [PATCH] Document NTP contracts and verify corrected wall time --- src/ntp_node_time_doc.md | 31 ++++++++++++++++++++----------- src/ntp_time_doc.md | 30 ++++++++++++++++++++---------- tests/test_ntp.c | 18 +++++++++++++++--- 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/src/ntp_node_time_doc.md b/src/ntp_node_time_doc.md index b84c1962..3cd6c85c 100644 --- a/src/ntp_node_time_doc.md +++ b/src/ntp_node_time_doc.md @@ -1,25 +1,34 @@ # NTP Node Time ## 1. Назначение -Синхронизация времени между узлами uTun через ETCP-канал. Позволяет вычислять смещение часов относительно каждого соседа, обнаруживать дрейф и, если узел сам не синхронизирован с NTP, корректировать свои часы по времени синхронизированного соседа. Используется для точной координации событий между узлами (db_sync, timestamp-ы сообщений в чате). +Обмен временем с прямыми ETCP-пирами. Если у узла ещё нет поправки, он принимает время синхронизированного пира с оценкой сетевой задержки. Поправка хранится в `instance->ntp` и применяется getters из `ntp_time.h`; часы ОС не меняются. Отключение интернет-NTP (`ntp.enabled=0`) не отключает этот обмен. Уже синхронизированный узел повторно не принимает поправку от пиров, но интернет-NTP может её обновить. ## 2. Как пользоваться -`ntp_node_time_init()` регистрирует callback на новые ETCP-соединения и callback на приём данных через raw `etcp_bind` (packet ID `ETCP_ID_NTP_TIME = 0x04`, P2P без роутера). При установке нового соединения автоматически вызывается `ntp_node_on_conn_init` — отправка `TIME_SYNC` соседу. При получении `TIME_SYNC` вычисляется per-peer offset, проверяется дрейф, и если мы не синхронизированы а сосед синхронизирован — корректируем свои часы и рассылаем коррекцию всем остальным соседям. +В uasync-потоке после создания `instance->connections` вызвать `ntp_node_time_init()`. Он привязывает raw packet ID `ETCP_ID_NTP_TIME = 0x04` (прямой ETCP, без роутера), подписывается на новые соединения и на INIT всех существующих, в том числе pending. На INIT отправляется `TIME_SYNC`; при запуске модуля пакет также отправляется уже инициализированным соединениям. Повторный активный init возвращает 0 без дублирования подписок. При занятом ID или ошибке регистрации возвращается -1; свои частичные регистрации снимаются. -`ntp_node_sync_peers()` — широковещательная рассылка `TIME_SYNC` всем активным соседям. Вызывается автоматически после успешной NTP-синхронизации из `ntp_time`. +`TIME_SYNC` — 10 байт: ID (1), flags (1, bit 0 = synced), время отправителя (int64 Unix мкс в нативном порядке байтов). Синхронизированный отправитель передаёт скорректированное время, остальные — локальное. При первом сообщении сохраняется исходное смещение для диагностики. Если получатель не синхронизирован, а отправитель синхронизирован, применяется: + +`offset_us = local_receive_us - sender_us - mean_link_rtt_us / 2`. + +Среднее RTT — арифметическое по всем элементам `conn->links`, включая нулевые значения и down-линки; `rtt_last` переводится из 0,1 мс в микросекунды. При отсутствии линков задержка считается нулевой. Это приближённая оценка: симметричность пути и точность часов пира не гарантируются. + +После принятия поправки устанавливаются `ntp.synced` и `ntp.last_sync_tb`, затем время рассылается остальным. Синхронизированный получатель отвечает несинхронизированному отправителю. Когда оба синхронизированы, ответа нет — бесконечный обмен не возникает. Полученный entry и его dgram освобождаются при обработке, в том числе при заполненном кеше диагностики. + +`ntp_node_sync_peers()` рассылает время по соединениям с `initialized && links_up`, если модуль активен и есть поправка. Вызывается также после успешной интернет-NTP-синхронизации. **Нюансы:** -- До 32 соседей (`NTP_NODE_MAX_PEERS`), при переполнении новый не добавляется -- Дрейф >2с — WARN, >10с — ERROR. Смещение не применяется автоматически к глобальному offset_us, кроме случая когда свой узел не синхронизирован -- Offset считается как `t4 - t_sender_us` (одностороннее смещение, без учёта RTT — предполагается что задержка ETCP мала) +- Кеш содержит до 32 диагностических записей, а не ограничивает число соседей. При заполнении вытесняется запись с наименьшим `last_seen_tb` (при равенстве — первая по индексу); исходное смещение новой записи сбрасывается. +- Диагностическое смещение — `raw_local_receive_us - sender_us`, без вычитания задержки. Оно отличается от глобальной поправки, принятой с оценкой половины RTT. +- При повторном сообщении модуль сравнивает смещение с предыдущим. Модуль разности строго >2 с даёт WARN, строго >10 с — ERROR. Это изменение относительного смещения: оно может включать коррекцию часов отправителя и изменение задержки, а не только дрейф часов. +- `destroy` вызывается в uasync-потоке вне callbacks модуля/соединений. Снимает raw binding, подписку новых соединений и свои callbacks со всех текущих соединений, очищает кеш. Глобальную поправку не меняет. Повторный вызов и NULL безопасны. ## 3. API **Структуры:** -- `struct NTP_NODE_PEER` — пара `node_id` + `offset_us` для одного соседа -- `struct NTP_NODE_TIME` — фиксированный массив `peers[32]` + счётчик `peer_count` +- `struct NTP_NODE_PEER` — ID пира, предыдущее диагностическое смещение, наличие исходного значения и время последнего обновления. +- `struct NTP_NODE_TIME` — кеш, число занятых записей и флаг активности; контракты полей/функций описаны в `ntp_node_time.h`. **Функции:** -- `ntp_node_time_init(inst)` — регистрирует bind в `etcp_bind` (ID 0x04) и callback на новые соединения -- `ntp_node_time_destroy(inst)` — снимает регистрацию bind и callback, обнуляет peer_count -- `ntp_node_sync_peers(inst)` — отправляет `TIME_SYNC` всем активным/initialized соседям (вызывается после NTP-синхронизации) +- `ntp_node_time_init(inst)` — 0 при успехе/уже активном модуле, -1 при ошибке; inst и очередь connections обязательны. +- `ntp_node_time_destroy(inst)` — снимает регистрации и очищает кеш, сохраняя глобальную поправку. +- `ntp_node_sync_peers(inst)` — рассылает время готовым прямым соседям; для NULL/неактивного/несинхронизированного модуля ничего не делает. diff --git a/src/ntp_time_doc.md b/src/ntp_time_doc.md index 6a297d70..cd33ed3e 100644 --- a/src/ntp_time_doc.md +++ b/src/ntp_time_doc.md @@ -1,27 +1,37 @@ # NTP Time ## 1. Назначение -SNTP-клиент (RFC 5905) для синхронизации системного времени с внешними NTP-серверами. Модуль вычисляет смещение `offset_us = local_time - ntp_time` (положительное = локальные часы спешат) и позволяет везде в коде использовать `ntp_time_get_us()` вместо `gettimeofday()` для получения скорректированного времени. Без коррекции времени невозможна корректная работа node-to-node синхронизации (`ntp_node_time`) и оценка RTT/jitter в ETCP. +Асинхронный клиент интернет-NTP поверх UDP/IPv4. Не меняет часы ОС: хранит поправку `offset_us = local_time - reference_time`, а `ntp_time_get_us()` возвращает `local_time - offset_us`. Положительная поправка означает, что локальные часы спешат. Поправку также может установить `ntp_node_time`; RTT и таймеры ETCP используют монотонное время независимо от неё. ## 2. Как пользоваться -`ntp_time_init()` читает список NTP-серверов из конфига (`global.ntp_servers`) и запускает периодический опрос через uasync-таймер. Опрос — по одному серверу за цикл с round-robin, при неудаче перебирает остальные. Интервал ресинка задаётся через `ntp_resync_interval` (по умолчанию 3600с). Первый запрос — сразу после init. При успешной синхронизации вызывается `ntp_node_sync_peers()` для рассылки времени соседним узлам. +Вызвать `ntp_time_init()` в uasync-потоке ядра после создания `instance->config` и `instance->ua`. Исходное состояние нулевое либо после `destroy`; активный повторный `init` возвращает -1, сохраняя прежнее состояние. Обычный `init` сбрасывает прежнюю поправку и тестовые адреса, копирует список серверов и ставит таймер с задержкой 0. Первый цикл выполняется при обработке uasync, не внутри `init`. -Для тестов: `ntp_time_set_test_addr()` задаёт прямой адрес NTP-сервера (IP:port), минуя DNS-резолвинг. +Цикл начинает с `server_current`, обходит каждый сервер не более одного раза и заканчивается при первом корректном ответе. Имена разрешаются асинхронно через A-запросы DNS; используется первый полученный IPv4-адрес. После разрешения адреса — до трёх UDP-запросов с ожиданием по 2 с. Каждая отправка имеет новый timestamp; ответ должен содержать timestamp последней отправки в originate. Немедленная ошибка DNS/сокета/отправки/регистрации прекращает попытку этого сервера и переводит к следующему без рекурсии. Ошибки DNS из callback не сбрасывают остаток обхода. + +После успеха следующий цикл начинается с сервера, следующего за ответившим; после полного отказа исходный индекс сохраняется. Между завершением цикла и следующим запуском — `resync_interval_sec` (по умолчанию 3600 с). При активном опросе допустим интервал 1..`INT_MAX/10000` секунд: таймеры принимают `int` в единицах 0,1 мс. Ошибка создания первого таймера возвращается из `init`; ошибка создания очередного таймера логируется и прекращает автоматические повторы до переинициализации. + +Успех устанавливает `synced`, `offset_us`, `last_sync_tb` и вызывает `ntp_node_sync_peers()`. Полный отказ серверов сохраняет последнюю поправку. `reachable` отражает успех последнего завершённого интернет-NTP-цикла; изначально он оптимистично равен 1 даже при отключённом опросе. Его изменение обновляет адреса узла через `topo_node_update_my_addresses()`; это косвенный признак для классификации сокетов, а не гарантия доступа в интернет. + +Тестовые подмены устанавливаются после `init`, до первого `poll`: +- `ntp_time_set_test_addr(ntp, ip, port)` — IPv4-литерал и порт единственного сервера цикла, без DNS и перехода к конфигурационным серверам. `port=0` отключает подмену. Конфигурационный список должен оставаться непустым. +- `ntp_time_set_test_dns(ntp, ip, dns_port, ntp_port)` — IPv4 DNS-сервер и порт NTP для конфигурационного списка (`ntp_port=0` означает 123). `test_addr` имеет приоритет. `dns_port=0` отключает подмену DNS. **Нюансы:** -- Если `ntp_enabled=0` или серверов нет, модуль не опрашивает, `ntp_time_get_us()` возвращает локальное время -- Если синхронизации не было, то же самое — возвращается `gettimeofday()` без коррекции -- SNTP (simplified NTP) — использует только один запрос-ответ, без сложной фильтрации +- `ntp_enabled=0` или пустой список отключают только интернет-опрос; коррекция от пиров остаётся возможной. +- До первой коррекции getters возвращают локальное время; `synced` означает наличие поправки, а не её свежесть. +- Нет фильтрации серии измерений или гарантии точного времени; сетевые задержки влияют на оценку. +- `destroy` вызывается в uasync-потоке вне callbacks модуля, до освобождения ua. Отменяет DNS, сокет и таймеры, освобождает серверы и отключает опрос, сохраняя `synced`, `offset_us`, `last_sync_tb`, `reachable`. ## 3. API **Структуры:** -- `struct NTP_TIME` — состояние синхронизации: флаг synced, offset_us, last_sync_tb, список серверов, текущий индекс и таймер +- `struct NTP_TIME` — состояние коррекции и интернет-опроса; поля и контракты функций описаны в `ntp_time.h`. **Функции:** -- `ntp_time_init(instance)` — читает конфиг, выделяет массив серверов, запускает первый sync-таймер -- `ntp_time_destroy(instance)` — отменяет таймер, освобождает память серверов +- `ntp_time_init(instance)` — 0 при успехе (также при отключённом опросе), -1 при ошибке; новые ресурсы при ошибке освобождаются. +- `ntp_time_destroy(instance)` — отменяет опрос, сохраняет поправку; NULL и повторный вызов допустимы. - `ntp_time_set_test_addr(ntp, ip, port)` — задать NTP-сервер напрямую (без DNS) для тестов - `ntp_time_get_us(instance)` — скорректированное время в микросекундах (если synced), иначе `gettimeofday()` - `ntp_time_get_seconds(instance)` — скорректированное время в секундах (time_t) -- `ntp_time_is_synced(instance)` — true если была успешная синхронизация +- `ntp_time_is_synced(instance)` — ненулевое значение при наличии поправки от NTP/пира, для NULL — 0. +- Getters для NULL возвращают локальное время; секунды вычисляются целочисленным делением микросекунд с приведением к `time_t`. diff --git a/tests/test_ntp.c b/tests/test_ntp.c index 8c082683..3bd7aac4 100644 --- a/tests/test_ntp.c +++ b/tests/test_ntp.c @@ -5,7 +5,7 @@ * Сценарий 1 (direct): фейковый NTP-сервер, синхронизация по test_addr. * Сценарий 2 (hostname): фейковый DNS-сервер резолвит hostname → 127.0.0.1, * затем NTP опрашивает фейковый NTP-сервер (async_dns → неблокирующий запрос). - * Оба сценария проверяют флаг synced и точность offset. + * Оба сценария проверяют флаг synced, знак offset и итоговое скорректированное время getters. */ #include #include @@ -221,17 +221,29 @@ static int verify_synced(struct UTUN_INSTANCE* inst, struct UASYNC* ua, const ch DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "FAIL: %s did not sync within %ds", label, TEST_TIMEOUT_SEC); return -1; } - int64_t diff = inst->ntp.offset_us - TEST_KNOWN_OFFSET_US; + int64_t diff = inst->ntp.offset_us + TEST_KNOWN_OFFSET_US; if (diff < 0) diff = -diff; if (diff > OFFSET_TOLERANCE_US) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "FAIL: %s offset %lldus too far from expected %lldus (diff=%lldus)", - label, (long long)inst->ntp.offset_us, (long long)TEST_KNOWN_OFFSET_US, (long long)diff); + label, (long long)inst->ntp.offset_us, (long long)-TEST_KNOWN_OFFSET_US, (long long)diff); return -1; } if (!ntp_time_is_synced(inst)) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "FAIL: %s ntp_time_is_synced returned 0", label); return -1; } + struct timeval before, after; + utun_gettimeofday(&before, NULL); + int64_t corrected_us = ntp_time_get_us(inst); + time_t corrected_sec = ntp_time_get_seconds(inst); + utun_gettimeofday(&after, NULL); + int64_t lower = (int64_t)before.tv_sec * 1000000 + before.tv_usec + TEST_KNOWN_OFFSET_US - OFFSET_TOLERANCE_US; + int64_t upper = (int64_t)after.tv_sec * 1000000 + after.tv_usec + TEST_KNOWN_OFFSET_US + OFFSET_TOLERANCE_US; + if (corrected_us < lower || corrected_us > upper || corrected_sec < lower / 1000000 || corrected_sec > upper / 1000000) { + DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "FAIL: %s corrected time outside server clock bounds: us=%lld sec=%lld range=%lld..%lld", + label, (long long)corrected_us, (long long)corrected_sec, (long long)lower, (long long)upper); + return -1; + } DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "PASS: %s offset=%lldus", label, (long long)inst->ntp.offset_us); return 0; }