Document NTP contracts and verify corrected wall time

This commit is contained in:
evgeny committed 2026-10-21 20:16:38 +02:00
1 parent 88bf65e07d
commit d6eed66926
3 files changed
+55 -24

No files matched your search

+20 -11
View File
@@ -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/неактивного/несинхронизированного модуля ничего не делает.
+20 -10
View File
@@ -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`.
+15 -3
View File
@@ -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 <stdio.h>
#include <stdlib.h>
@@ -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;
}