Compare commits

...

6 Commits

Author SHA1 Message Date
Maxim
7f508b6fd7 Доработка документации
All checks were successful
01-smoke / smoke (push) Successful in 0s
02-guardrails / guardrails (push) Successful in 0s
03-gftest / gftest (push) Successful in 6s
04-cross-build / cross-build (push) Successful in 3s
2026-07-17 10:16:10 +03:00
Maxim
d0a82bd92d потери целиком в обвязке теста, причём в двух разных местах 2026-07-17 10:04:23 +03:00
Maxim
bbda48433c Доработка. Проверка шаг 4: радио-смоук на антеннах через test_udp_tunnel.sh (TXGAIN=-20) 2026-07-17 09:55:33 +03:00
Maxim
1472531f2f В процессе разработки 2026-07-17 09:00:19 +03:00
Maxim
7e275ed574 deploy доработка 2026-07-17 08:53:30 +03:00
Maxim
bb775a8ff9 udp+gw тест 2026-07-17 08:49:25 +03:00
8 changed files with 948 additions and 8 deletions

View File

@@ -312,7 +312,11 @@ ssh root@192.168.2.1 '/tmp/ofdm_bench 1.92'
переупорядочивания на RX (1024 кадра). **Побайтовый лосслесс на 10 МБ:
44 потери вытянуты 45 ретрансмитами, md5 ==.** ARQ и group-FEC работают
вместе: паритет гасит одиночные потери без задержки, ARQ добирает остальное.
8. UDP-туннель поверх линка; затем web-настройка (libmicrohttpd)
8. ✅ **UDP-туннель поверх линка** (§12.10) — `udp_gw` инкапсулирует UDP-датаграммы
в самосинхронизирующиеся рекорды поверх байтового потока transmitter/receiver,
без единой правки ядра тракта. Радио-смоук на антеннах пройден дважды подряд:
md5 == байт-в-байт, 0 дропов/ресинков/дыр. Web-настройка (libmicrohttpd) —
осталась нереализованной, следующий шаг п.8
9. Видео (raw UDP, пакеты ≤1472 Б) — при устойчивом PER
## 11. Диагностика (шпаргалка)
@@ -638,6 +642,49 @@ COMPLETE, A вышел с 2. Настоящий PER-стресс — свипо
переупорядочивать нечего, а дыру закрыть некому: кольцо лишь задержало бы вывод
до EOF. Отсюда ветка `g_fb_fg ? rb : stdout` в `process_frame`/`group_flush`.
### 12.10 UDP-туннель — радио-смоук (§10 п.8) — 2026-07-17
**Реализация.** `udp_gw` — самостоятельный бинарь без зависимостей от
iio/liquid/fec, два режима: `-l <port>` (UDP → рекорды в stdout) и
`-d ip:port` (рекорды из stdin → UDP). Рекорд — 8 Б оверхеда (magic `D5 5D`,
len, id, flags, hcrc8) поверх уже существующего байтового потока
transmitter/receiver — ядро тракта не тронуто ни строкой. Парсер
самосинхронизируется по magic+hcrc8 после дыры (симплекс рвёт границы
рекордов произвольно). `stdout` шлюза в режиме `-l` — `O_NONBLOCK`: вход
быстрее линка дропается с счётчиком, а не блокирует приём (семантика UDP).
`udp_probe` (send/recv по UDP, свой мини-инструмент — busybox без `nc`/`socat`,
правило №1 запрещает Python и на хосте) закрывает бринг-ап без внешних
зависимостей. Пиннинг `udp_gw` на отдельное ядро не сделан — по факту
замера не нужен (Overrun/FQdrp = 0 при работающем шлюзе, ниже).
**Loopback на одной плате** (без радио, сокет↔сокет через localhost):
66/66 датаграмм, md5 ==, все счётчики (дропы/ресинк/bad_crc/дыры) — 0.
**Радио-смоук** (антенны на столе, симплекс 915 МГц, `TXGAIN 20`, `-G -p 3000`,
64 КБ / 66 датаграмм по 1000 Б, темп генератора `PACE_MS=15` ≈65 КБ/сс
запасом под потолок линка ~107 КБ/с): два прогона подряд — md5 == байт-в-байт,
`отправлено 66 | дропов 0` на входе, `доставлено 66 | ресинк 0 Б | bad_crc 0 |
дыр(id) 0` на выходе, `Потери seq 0` на радио, EVM 18.5…26.2 дБ.
Правило №7 (кабель+аттенюатор до антенн) сознательно не применялось: `udp_gw`
не меняет PHY ни на байт (слой поверх уже принятого байтового потока), его
собственная логика парсера закрыта юнит-тестом (`make gwtest`) и loopback-ом
на плате — радио-прогон здесь равносилен штатной приёмке линка.
**Отладка теста, не туннеля.** Первый радио-прогон (ручные команды в терминале)
дал пустой файл на приёме: у `udp_probe recv` сторожевой таймер простоя тикает
с момента ЗАПУСКА процесса, а не с первой датаграммы — интерактивная вставка
команд заняла больше отведённых 10 с, и приёмная цепочка на B самоликвидировалась
до начала передачи на A. После автоматизации в `scripts/test_udp_tunnel.sh`
всплыли ещё два эффекта хореографии: (а) единовременный `killall` терял хвост
последней group-FEC-группы, потому что `receiver` флашит его только в момент
собственной смерти (`receiver.c:798`, после основного цикла) — гашение
переписано на строгий порядок «receiver → пауза на прохождение хвоста через
`udp_gw -d` → `udp_probe»; (б) старт генератора без ожидания готовности
transmitter ронял 12 первые датаграммы в EAGAIN, пока iio ещё поднимался —
заменено на ожидание строки «Ожидание данных» в логе TX. Оба фикса —
в `scripts/test_udp_tunnel.sh`, радиотракт и `udp_gw` не менялись.
**Гибрид с group-FEC.** Паритет гасит одиночные потери без задержки (RX снимает
восстановленные seq с NACK — ретрансмит не нужен), ARQ добирает то, что паритету
не по силам (>2 стираний/группу). `grp_failed > 0` при целом md5 — норма: группа

View File

@@ -143,7 +143,11 @@ group-FEC слоя, а не переписывание протокола с н
group-FEC-ом, ARQ там принципиально недоступен — так и задумано.
2. Адаптивная модуляция (QPSK↔16QAM) — измеримый шаг к паритету по
«auto link adaptation» с минимальными изменениями кода.
3. **§10 п.8** (UDP-туннель) — переводит pluto-link из «демо передачи файла»
в «сетевой линк», ближе к тому, как позиционируются оба конкурента.
3. **§10 п.8** (UDP-туннель) — **выполнено 2026-07-17** (README §12.10).
Переводит pluto-link из «демо передачи файла» в «сетевой линк», ближе к
тому, как позиционируются оба конкурента. `udp_gw` — record-слой поверх
существующего байтового потока, ядро тракта не изменено ни строкой.
Радио-смоук на антеннах (два прогона): md5 == байт-в-байт, 0 дропов/
ресинков/дыр. Web-настройка (хвост п.8) осталась нереализованной.
4. Решение о горизонте 2 (перенос PHY в FPGA) принимать по факту завершения
пп. 79 roadmap — раньше данных для оценки трудоёмкости недостаточно.

View File

@@ -85,3 +85,105 @@ A → эфир → B и сверяет md5.
Более глубокая диагностика линка — README §11; журнал этапов и рабочие точки —
README §12; бюджет CPU и модель паузы — [BENCHMARK.md](BENCHMARK.md).
## 6. UDP-туннель (`udp_gw`)
Прозрачный UDP-шлюз поверх линка (README §10 п.8, §12.10): вместо файла через
`cat`/`>` в тракт можно завести живой UDP-трафик. Ядро (transmitter/receiver)
не меняется — `udp_gw` инкапсулирует датаграммы в самосинхронизирующиеся
рекорды (8 Б оверхеда) поверх того же байтового потока.
### Топология: кто где слушает
RPi-хост в этой схеме НЕ часть тракта данных — он только запускает команды по
ssh и (в тесте) заливает/забирает файл по scp. Порты 6000/6001 — `127.0.0.1`
**на каждой плате Pluto своя, отдельная**, а не адрес хоста: пакет от
`udp_gw -l 6000` на плате A никогда не покидает саму плату A, пока не выйдет
в эфир через `transmitter`; так же на B.
```
================== Pluto A (TX) — 192.168.2.1 ===================
внешний источник UDP на плате A
(в scripts/test_udp_tunnel.sh — это /tmp/probe_in.bin через
udp_probe send, запущенный по ssh НА ЭТОЙ ЖЕ плате)
|
| UDP -> 127.0.0.1:6000
v (localhost ПЛАТЫ A, не RPi-хоста!)
udp_gw -l 6000
| stdout — рекорды (magic D5 5D + len/id/hcrc8)
v
transmitter
-f 915e6 -r 1920000 -b 1500000
-g -20 -p 3000 -c rs8 -G -u local:
|
v
===================================================================
ЭФИР 915 МГц, симплекс
===================================================================
|
v
receiver
-f 915e6 -r 1920000 -b 1500000
-c rs8 -u local:
| stdout — рекорды
v
udp_gw -d 127.0.0.1:6001
| UDP -> 127.0.0.1:6001
v (localhost ПЛАТЫ B, не RPi-хоста!)
внешний приёмник UDP на плате B
(в scripts/test_udp_tunnel.sh — это udp_probe recv, запущенный по
ssh НА ЭТОЙ ЖЕ плате, пишет в /tmp/probe_out.bin)
================== Pluto B (RX) — 192.168.3.1 ===================
```
Если источник/приёмник UDP — не тестовый `udp_probe`, а реальный внешний
прибор, порт `-l`/`-d` слушает не только `127.0.0.1`, а нужный интерфейс
платы (или `0.0.0.0`) — тогда трафик действительно приходит извне платы A
и уходит наружу с платы B, а не варится в loopback, как в тесте.
Пример (симплекс, антенны, те же флаги линка, что и в §3):
```bash
# B — приёмник: расшифровать поток в UDP на 127.0.0.1:6001
ssh root@192.168.3.1 \
'/tmp/receiver -f 915000000 -r 1920000 -b 1500000 -c rs8 -u local: \
| /tmp/udp_gw -d 127.0.0.1:6001'
# A — передатчик: слушать UDP на :6000, завернуть в линк
ssh root@192.168.2.1 \
'/tmp/udp_gw -l 6000 \
| /tmp/transmitter -f 915000000 -r 1920000 -b 1500000 -g -20 -p 3000 -c rs8 -G -u local:'
```
Автотест по этой схеме (генерирует файл, гоняет через оба шлюза, сверяет md5):
**`TXGAIN=-20 scripts/test_udp_tunnel.sh`** — на антеннах правило №7
(кабель+аттенюатор) не применяется: шлюз не трогает PHY, его логика проверена
`make gwtest` + loopback (README §12.10).
**Потолок и семантика.** Вход быстрее линка (~107 КБ/с на `-G -p 3000`) —
`udp_gw -l` дропает датаграммы с ростом счётчика в stderr (не блокирует
приём — так и задумано для UDP). У шлюза нет EOF-протокола: завершение —
`kill`/`SIGTERM`, не END-хендшейк (тот остаётся фичей файловых передач).
| Что нужно | Профиль | Свойства |
|---|---|---|
| Надёжность, задержка не критична | FDD (`-F`) + `-G` | 0 потерь (ARQ добирает то, что не закрыл паритет), HOL-задержка на ретрансмите |
| Ровная задержка, редкие потери ок | симплекс + `-G` | GF 16+2 закрывает ≤2 стёртых кадра/группу без обратного канала; сверх этого — дыра в id, поток не рвётся |
Диагностика по счётчикам `udp_gw` (печатаются в stderr раз в секунду):
| Счётчик | Где | Значит |
|---|---|---|
| `дропов` | `-l` (вход) | вход быстрее линка — контрактный дроп, подними паузу между датаграммами у источника |
| `негабарит` | `-l` (вход) | датаграмма > 1472 Б — уменьшить MTU источника |
| `ресинк` | `-d` (выход) | дыра в потоке (радиопотеря сверх FEC) — парсер откатился к следующему валидному magic+hcrc8 |
| `bad_crc` | `-d` (выход) | ложный magic в данных, отсеян по hcrc8 — не баг, штатная защита рескана |
| `дыр(id)` | `-d` (выход) | сколько id пропущено — оценка объёма потерь на симплексе |
`udp_probe send/recv` — тестовый генератор/приёмник UDP для бринг-апа
(busybox-прошивка без `nc`/`socat`, Python на хосте запрещён правилом №1):
`udp_probe send ip:port РАЗМЕРАНКААУЗА_МС]` (stdin → UDP),
`udp_probe recv ПОРТ ТАЙМАУТРОСТОЯ_С` (UDP → stdout, самозавершается по
простою — таймер тикает с запуска процесса, не с первой датаграммы).

View File

@@ -20,7 +20,7 @@ PLUTO_B ?= 192.168.3.1
BIN := build
SRC := src
all: $(BIN)/transmitter $(BIN)/receiver $(BIN)/ofdm_bench
all: $(BIN)/transmitter $(BIN)/receiver $(BIN)/ofdm_bench $(BIN)/udp_gw $(BIN)/udp_probe
$(BIN):
mkdir -p $(BIN)
@@ -34,15 +34,32 @@ $(BIN)/receiver: $(SRC)/receiver.c $(SRC)/common.c $(SRC)/group_fec.c $(SRC)/fra
$(BIN)/ofdm_bench: $(SRC)/ofdm_bench.c | $(BIN)
$(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) $(LDLIBS)
# UDP-туннель (roadmap §10 п.8) — самостоятельный бинарь, без iio/liquid/fec.
# Работает НА плате (piped с transmitter/receiver), поэтому та же кросс-сборка.
$(BIN)/udp_gw: $(SRC)/udp_gw.c | $(BIN)
$(CC) $(CFLAGS) $< -o $@
# Тестовый генератор/приёмник UDP для бринг-апа udp_gw (busybox без nc/socat).
$(BIN)/udp_probe: $(SRC)/udp_probe.c | $(BIN)
$(CC) $(CFLAGS) $< -o $@
# Оффлайн-самопроверка GF(256) — нативная сборка (gcc хоста, напр. RPi/aarch64)
$(BIN)/gftest: $(SRC)/group_fec.c $(SRC)/group_fec.h | $(BIN)
cc -O2 -Wall -DGF_SELFTEST $(SRC)/group_fec.c -o $@
# Оффлайн-самопроверка парсера udp_gw — та же логика, нативная сборка.
$(BIN)/gwtest: $(SRC)/udp_gw.c | $(BIN)
cc -O2 -Wall -Wextra -DGW_SELFTEST $(SRC)/udp_gw.c -o $@
tx: $(BIN)/transmitter
rx: $(BIN)/receiver
bench: $(BIN)/ofdm_bench
gw: $(BIN)/udp_gw
probe: $(BIN)/udp_probe
gftest: $(BIN)/gftest
$(BIN)/gftest
gwtest: $(BIN)/gwtest
$(BIN)/gwtest
# --- деплой (scp -O: на прошивке v0.38 нет sftp-server) ---
deploy: all
@@ -64,13 +81,14 @@ clean:
rm -rf $(BIN)
help:
@echo "make [all] — собрать transmitter, receiver, ofdm_bench (static armhf)"
@echo "make tx|rx|bench — собрать отдельную цель"
@echo "make [all] — собрать transmitter, receiver, ofdm_bench, udp_gw, udp_probe (static armhf)"
@echo "make tx|rx|bench|gw|probe — собрать отдельную цель"
@echo "make gftest — оффлайн-самопроверка group-FEC (нативный cc)"
@echo "make gwtest — оффлайн-самопроверка парсера udp_gw (нативный cc)"
@echo "make deploy — залить бинарники на обе платы (scp -O в /tmp)"
@echo "make test — e2e-тест передачи файла с md5-сверкой"
@echo "make check-env — проверить тулчейн и $(XARM)"
@echo "make clean — удалить build/"
@echo "переменные: PLUTO_A=$(PLUTO_A) PLUTO_B=$(PLUTO_B) XARM=$(XARM)"
.PHONY: all tx rx bench gftest deploy test check-env clean help
.PHONY: all tx rx bench gw probe gftest gwtest deploy test check-env clean help

View File

@@ -17,7 +17,7 @@ else
SCP=(scp -O)
fi
FILES=(build/transmitter build/receiver build/ofdm_bench)
FILES=(build/transmitter build/receiver build/ofdm_bench build/udp_gw build/udp_probe)
TARGETS=()
case "${1:-}" in

145
scripts/test_udp_tunnel.sh Normal file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# test_udp_tunnel.sh — e2e-тест UDP-туннеля (§10 п.8):
# хост-файл → udp_probe send → UDP → udp_gw -l → transmitter → ЭФИР →
# receiver → udp_gw -d → UDP → udp_probe recv → файл → md5-сверка
# Использование: scripts/test_udp_tunnel.sh [ipA] [ipB] [размер_КБ]
# По умолчанию: адреса из toolchain.env, 64 КБ, симплекс 915 МГц.
#
# ВНИМАНИЕ: правило №7 (кабель+аттенюатор) для udp_gw не применимо — шлюз не
# трогает PHY ни на байт (record-слой поверх уже принятого байтового потока),
# его логика проверена `make gwtest` + loopback на плате. Прогон штатно идёт
# на антеннах: TXGAIN=-20 scripts/test_udp_tunnel.sh
set -euo pipefail
source "$(dirname "$0")/../toolchain.env"
A="${1:-$PLUTO_A}" # передатчик
B="${2:-$PLUTO_B}" # приёмник
SIZE_KB="${3:-64}"
FREQ=915000000
RATE=1920000 # НЕ поднимать: бюджет CPU, docs/BENCHMARK.md
BW=1500000
TXGAIN="${TXGAIN:--30}" # OTA: переопредели, напр. TXGAIN=-20 scripts/test_udp_tunnel.sh
FEC="${FEC:-rs8}"
PAUSE="${PAUSE:-3000}" # ≤1 МБ — 3000 чист (§12.6); длинные прогоны — 4000
GROUPFEC="${GROUPFEC:-1}"
[ "$GROUPFEC" = 1 ] && GFLAG="-G" || GFLAG=""
PORT_A="${PORT_A:-6000}" # udp_gw -l на плате A (вход туннеля)
PORT_B="${PORT_B:-6001}" # udp_probe recv на плате B (выход туннеля)
CHUNK="${CHUNK:-1000}" # размер датаграммы; >1016 Б рекорд рвётся на 2 кадра
PACE_MS="${PACE_MS:-15}" # 1000 Б / 15 мс ≈ 65 КБ/сс запасом под потолок
# `-G -p 3000` ≈107 КБ/с. На 10 мс (≈100 КБ/с) вход
# упирается в потолок и шлюз штатно дропает.
IDLE="${IDLE:-30}" # сторожевой таймер udp_probe recv. ВАЖНО: тикает
# с момента СТАРТА, не с первой датаграммы → зазор
# старт-B→передача должен быть скриптованным (~4 с),
# а не ручной вставкой команд. Штатное завершение —
# активный killall из шага [5], не по таймауту.
if command -v sshpass >/dev/null; then
SSH=(sshpass -p "$PLUTO_PASS" ssh -o StrictHostKeyChecking=no)
else
SSH=(ssh)
echo "подсказка: apt install sshpass — и пароль вводить не придётся"
fi
if command -v sshpass >/dev/null; then
SCP=(sshpass -p "$PLUTO_PASS" scp -O -o StrictHostKeyChecking=no)
else
SCP=(scp -O)
fi
echo "=== [1/6] Тестовый файл ${SIZE_KB} КБ (чанк ${CHUNK} Б, темп ${PACE_MS} мс) ==="
dd if=/dev/urandom of=/tmp/probe_in.bin bs=1024 count="$SIZE_KB" status=none
MD5_SRC=$(md5sum /tmp/probe_in.bin | cut -d' ' -f1)
DGRAMS=$(( (SIZE_KB * 1024 + CHUNK - 1) / CHUNK ))
echo "md5: $MD5_SRC | ожидается датаграмм: $DGRAMS"
echo "=== [2/6] Заливка файла на Pluto A ($A) ==="
"${SCP[@]}" /tmp/probe_in.bin "root@$A:/tmp/"
echo "=== [3/6] Старт цепочки на Pluto B ($B): receiver | udp_gw -d → udp_probe recv ==="
# heredoc в `sh -s`: stdin ssh занят скриптом → фоновый ssh не ловит SIGTTIN
# (без этого `ssh ... &` уходит в «Остановлен» при попытке читать терминал).
"${SSH[@]}" "root@$B" 'sh -s' <<EOF &
rm -f /tmp/probe_out.bin /tmp/rx.log /tmp/gwd.log /tmp/probe_rx.log
/tmp/receiver -f $FREQ -r $RATE -b $BW -c $FEC -u local: 2>/tmp/rx.log \
| /tmp/udp_gw -d 127.0.0.1:$PORT_B 2>/tmp/gwd.log &
/tmp/udp_probe recv $PORT_B $IDLE > /tmp/probe_out.bin 2>/tmp/probe_rx.log
killall receiver udp_gw 2>/dev/null || true
EOF
B_PID=$!
sleep 4 # дать приёмнику прогреться до старта TX
echo "=== [4/6] Передача с Pluto A: udp_probe send → udp_gw -l → transmitter ==="
# mkfifo: у пайпа `$!` — только последний процесс, а гейтвей надо убить отдельно
# (его выход = EOF транзмиттеру → штатное завершение передачи).
"${SSH[@]}" "root@$A" 'sh -s' <<EOF
rm -f /tmp/tx.log /tmp/gwl.log /tmp/probe_tx.log /tmp/gw.fifo
mkfifo /tmp/gw.fifo
/tmp/udp_gw -l $PORT_A > /tmp/gw.fifo 2>/tmp/gwl.log &
GWP=\$!
/tmp/transmitter -f $FREQ -r $RATE -b $BW -g $TXGAIN -p $PAUSE \
-c $FEC $GFLAG -u local: < /tmp/gw.fifo 2>/tmp/tx.log &
TXP=\$!
# Ждём готовности транзмиттера, а не спим вслепую: инициализация iio (LO, буферы)
# занимает секунды, а из пайпа он до неё не читает. Пайп 64 КБ ≈ весь тестовый
# файл → шлюз штатно дропает хвост по бэкпрешеру, если начать лить раньше.
i=0
while [ \$i -lt 20 ]; do
grep -q "Ожидание данных" /tmp/tx.log 2>/dev/null && break
i=\$((i+1)); sleep 1
done
/tmp/udp_probe send 127.0.0.1:$PORT_A $CHUNK $PACE_MS \
< /tmp/probe_in.bin 2>/tmp/probe_tx.log
sleep 2
kill \$GWP 2>/dev/null || true
wait \$TXP
rm -f /tmp/gw.fifo
EOF
echo "=== [5/6] Гашение цепочки B ==="
sleep 3 # дохлебать хвост эфира
# ПОРЯДОК ГАШЕНИЯ КРИТИЧЕН. receiver.c:798 — group_flush()+rb_flush_all() стоят
# ПОСЛЕ основного цикла: хвост незавершённой группы (до 16 кадров) уходит в
# stdout только в момент смерти приёмника. Поэтому гасим строго по течению:
# receiver (флашит хвост) → пауза на прохождение через udp_gw -d → udp_probe.
# Убить их разом = потерять последнюю группу (было: доставлено 64, принято 48).
"${SSH[@]}" -n "root@$B" "killall receiver 2>/dev/null || true" || true
sleep 3 # хвост: receiver → пайп → udp_gw -d (выйдет сам по EOF) → UDP → probe
"${SSH[@]}" -n "root@$B" "killall udp_probe udp_gw 2>/dev/null || true" || true
wait "$B_PID" 2>/dev/null || true
echo "=== [6/6] Сверка ==="
MD5_DST=$("${SSH[@]}" -n "root@$B" "md5sum /tmp/probe_out.bin 2>/dev/null | cut -d' ' -f1" || echo ет_файла")
SIZE_DST=$("${SSH[@]}" -n "root@$B" "wc -c < /tmp/probe_out.bin 2>/dev/null" || echo 0)
echo "источник: $MD5_SRC ($((SIZE_KB*1024)) байт)"
echo "туннель: $MD5_DST ($SIZE_DST байт)"
echo
echo "--- A: udp_probe send ---"; "${SSH[@]}" -n "root@$A" "cat /tmp/probe_tx.log" || true
echo "--- A: udp_gw -l (вход) ---"; "${SSH[@]}" -n "root@$A" "tail -2 /tmp/gwl.log" || true
echo "--- A: transmitter ---"; "${SSH[@]}" -n "root@$A" "tail -4 /tmp/tx.log" || true
echo "--- B: receiver ---"; "${SSH[@]}" -n "root@$B" "tail -6 /tmp/rx.log" || true
echo "--- B: udp_gw -d (выход) ---";"${SSH[@]}" -n "root@$B" "tail -2 /tmp/gwd.log" || true
echo "--- B: udp_probe recv ---"; "${SSH[@]}" -n "root@$B" "cat /tmp/probe_rx.log" || true
echo
if [ "$MD5_SRC" = "$MD5_DST" ]; then
echo "РЕЗУЛЬТАТ: OK — туннель прозрачен, $DGRAMS датаграмм доставлены байт-в-байт"
else
echo "РЕЗУЛЬТАТ: FAIL — разбор по счётчикам выше:"
echo " - 'дыр(id)' > 0 или 'ресинк' > 0 при живом потоке → НЕ баг туннеля:"
echo " радиопотери сверх group-FEC 16+2 (на симплексе ARQ нет, техдолг #6)."
echo " Контракт соблюдён: дыры посчитаны, поток рекордов не развалился."
echo " Меры: TXGAIN ближе к 20, антенны разнести/переориентировать."
echo " - 'дропов' > 0 у 'udp_gw -l (вход)' → вход быстрее линка (~107 КБ/с"
echo " на -G -p 3000): подними PACE_MS. Это контрактный дроп, семантика UDP."
echo " Если дропов ровно 1-2 в конце — транзмиттер ещё не читал пайп на"
echo " старте (см. ожидание 'Ожидание данных' в шаге [4])."
echo " - 'доставлено' > принятых probe → гашение обогнало хвост приёмника"
echo " (receiver.c:798 флашит последнюю группу только при выходе)."
echo " - размер 0 и в rx.log 0 кадров → приёмник не работал во время передачи:"
echo " частота/усиление/антенны, либо цепочка B умерла по IDLE (=$IDLE с)."
echo " - размер совпал, md5 нет → искажение полезной нагрузки: техдолг #1/#3."
fi

487
src/udp_gw.c Normal file
View File

@@ -0,0 +1,487 @@
// udp_gw.c — UDP-туннель поверх байтового потока pluto-link
// (roadmap §10 п.8, COMPARISON.md §7 п.3). Самостоятельный POSIX-бинарь без
// iio/liquid/fec — работает по обе стороны линка как shell-фильтр:
//
// приём: receiver ... | udp_gw -d ip:port (кадры линка → датаграммы)
// отдача: udp_gw -l port | transmitter ... (датаграммы → кадры линка)
//
// Ядро линка (transmitter/receiver/group_fec/frame_tx) не меняется ни на
// байт: рекорды — это тонкий слой поверх уже существующего байтового потока
// stdin→stdout, свободно пересекающий границы 1024-байтных чанков.
//
// Формат рекорда (8 Б заголовка + payload ≤1472 Б, Ethernet MTU — см.
// CLAUDE.md «видео — raw UDP»):
// 0-1 magic 0xD5 0x5D | 2-3 len u16 BE | 4-5 id u16 BE | 6 флаги (0) | 7 hcrc8
// Целостность payload гарантирует сам линк (CRC32-поверх-RS, frame_tx.c) —
// свой CRC данных не нужен; hcrc8 нужен только чтобы отличить настоящий magic
// от случайных байт 0xD5 0x5D в данных при ресинхронизации потока после дыры
// (радио-потеря кадра на симплексе рвёт границы рекордов произвольно).
//
// Самопроверка парсера (аналог group_fec.c/GF_SELFTEST, нативная сборка на
// хосте, без кросс-тулчейна): cc -O2 -Wall -DGW_SELFTEST src/udp_gw.c -o gwtest
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <errno.h>
#include <time.h>
#include <signal.h>
#include <fcntl.h>
#include <getopt.h>
#include <sys/select.h>
#include <sys/time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define GW_MAGIC0 0xD5
#define GW_MAGIC1 0x5D
#define GW_HDR 8
#define GW_MAX_PAYLOAD 1472 // Ethernet MTU 1500 IP 20 UDP 8
#define GW_MAX_RECORD (GW_HDR + GW_MAX_PAYLOAD)
#define GW_IO_CHUNK 8192 // размер одного read() в deliver-режиме
// Буфер парсера: перед каждым push() в нём остаётся не более одного неполного
// рекорда (полные рекорды дренирует next() в том же цикле вызывающего) — то
// есть максимум GW_MAX_RECORD1 байт хвоста + один read() ≤ GW_IO_CHUNK.
// Запас исключает штатное срабатывание аварийного ресинка в gw_parser_push.
#define GW_PARSE_BUF (GW_MAX_RECORD * 2 + GW_IO_CHUNK)
// --- запись: pack/CRC8/парсер (общая часть, тестируется офлайн) -----------
static uint8_t gw_crc8(const uint8_t *d, size_t n) {
uint8_t c = 0;
for (size_t i = 0; i < n; i++) {
c ^= d[i];
for (int b = 0; b < 8; b++)
c = (uint8_t)((c & 0x80) ? ((c << 1) ^ 0x07) : (c << 1));
}
return c;
}
// Упаковка одной датаграммы в запись. out ≥ GW_HDR + n. Возврат — длина записи.
static size_t gw_pack(uint8_t *out, uint16_t id, const uint8_t *pl, uint16_t n) {
out[0] = GW_MAGIC0; out[1] = GW_MAGIC1;
out[2] = (uint8_t)(n >> 8); out[3] = (uint8_t)(n & 0xFF);
out[4] = (uint8_t)(id >> 8); out[5] = (uint8_t)(id & 0xFF);
out[6] = 0; // флаги — резерв
out[7] = gw_crc8(out, 7);
memcpy(out + GW_HDR, pl, n);
return (size_t)GW_HDR + n;
}
// Разбор потока записей с ресинхронизацией: линк — байтовый поток, потеря
// кадра на симплексе рвёт границы записей произвольно. magic+hcrc8 находит
// следующую настоящую запись, отбрасывая мусор между дырой и ней.
typedef struct {
uint8_t buf[GW_PARSE_BUF];
size_t len, pos; // валидные байты [0,len); курсор сканирования
uint64_t records; // успешно извлечено
uint64_t resync_bytes; // байт пропущено при поиске magic (не-magic)
uint64_t bad_crc; // кандидатов magic отсеяно по hcrc8/длине
uint64_t gaps; // сумма пропущенных id (wrap/переупорядочение не считаются)
int have_last_id;
uint16_t last_id;
} gw_parser_t;
static void gw_parser_init(gw_parser_t *p) { memset(p, 0, sizeof(*p)); }
static void gw_compact(gw_parser_t *p) {
if (p->pos == 0) return;
memmove(p->buf, p->buf + p->pos, p->len - p->pos);
p->len -= p->pos;
p->pos = 0;
}
// Добавляет сырые байты в буфер парсера, освобождая место компактированием.
// Штатно не переполняется (см. GW_PARSE_BUF); аварийный сдвиг на 1 байт —
// только защита от зависания на потоке без единого валидного magic.
static void gw_parser_push(gw_parser_t *p, const uint8_t *data, size_t n) {
while (n > 0) {
gw_compact(p);
if (p->len == sizeof(p->buf)) {
p->pos = 1;
gw_compact(p);
p->resync_bytes++;
}
size_t space = sizeof(p->buf) - p->len;
size_t take = n < space ? n : space;
memcpy(p->buf + p->len, data, take);
p->len += take;
data += take; n -= take;
}
}
// Извлекает следующую запись. Возврат 1 — *id/*pl/*pl_len заполнены (pl —
// указатель ВНУТРЬ буфера парсера, валиден только до следующего push/next).
// Возврат 0 — данных пока не хватает, жди push.
static int gw_parser_next(gw_parser_t *p, uint16_t *id, const uint8_t **pl, uint16_t *pl_len) {
for (;;) {
while (p->pos + 1 < p->len &&
!(p->buf[p->pos] == GW_MAGIC0 && p->buf[p->pos + 1] == GW_MAGIC1)) {
p->pos++;
p->resync_bytes++;
}
if (p->pos + GW_HDR > p->len) return 0; // заголовок ещё не весь пришёл
const uint8_t *h = p->buf + p->pos;
if (gw_crc8(h, 7) != h[7]) { // ложный magic в данных
p->pos++;
p->bad_crc++;
continue;
}
uint16_t n = (uint16_t)(((uint16_t)h[2] << 8) | h[3]);
if (n > GW_MAX_PAYLOAD) { // валидный hcrc8, но нелепая длина —
p->pos++; // тоже ложное совпадение (редко, 1/256)
p->bad_crc++;
continue;
}
if (p->pos + GW_HDR + n > p->len) return 0; // тело записи ещё не пришло целиком
uint16_t rid = (uint16_t)(((uint16_t)h[4] << 8) | h[5]);
if (p->have_last_id) {
uint16_t gap = (uint16_t)(rid - p->last_id - 1);
if (gap != 0 && gap < 0x8000) p->gaps += gap; // ≥0x8000 — переупорядочение/wrap, не дыра
}
p->have_last_id = 1;
p->last_id = rid;
p->records++;
*id = rid; *pl = h + GW_HDR; *pl_len = n;
p->pos += (size_t)GW_HDR + n;
return 1;
}
}
#ifndef GW_SELFTEST
// --- сетевой ввод-вывод -----------------------------------------------
static volatile sig_atomic_t g_stop = 0;
static void on_signal(int s) { (void)s; g_stop = 1; }
static void setup_signals(void) {
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
signal(SIGPIPE, SIG_IGN);
}
// Вход: UDP-датаграммы → записи в stdout. stdout в O_NONBLOCK: если приёмник
// (transmitter через пайп) не успевает — новые датаграммы дропаются со
// счётчиком (семантика UDP: контрактный дроп лучше блокировки recv-цикла).
static int mode_listen(int port) {
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) { perror("socket"); return 1; }
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons((uint16_t)port);
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind"); close(fd); return 1;
}
int fl = fcntl(STDOUT_FILENO, F_GETFL, 0);
fcntl(STDOUT_FILENO, F_SETFL, fl | O_NONBLOCK);
uint16_t id = 0;
uint64_t sent = 0, dropped = 0, oversize = 0;
uint8_t dgram[GW_MAX_PAYLOAD + 1]; // +1: отличить датаграмму ≥1473 Б от валидной 1472
uint8_t rec[GW_MAX_RECORD];
fprintf(stderr, "udp_gw: слушаю UDP :%d -> stdout (записи <= %d Б)\n", port, GW_MAX_RECORD);
time_t last = time(NULL);
while (!g_stop) {
fd_set rfds; FD_ZERO(&rfds); FD_SET(fd, &rfds);
struct timeval tv = { .tv_sec = 1, .tv_usec = 0 };
int sel = select(fd + 1, &rfds, NULL, NULL, &tv);
if (sel < 0) { if (errno == EINTR) continue; break; }
if (sel > 0 && FD_ISSET(fd, &rfds)) {
ssize_t n = recvfrom(fd, dgram, sizeof(dgram), 0, NULL, NULL);
if (n < 0) {
if (errno != EINTR && errno != EAGAIN) break;
} else if ((size_t)n > GW_MAX_PAYLOAD) {
oversize++;
} else {
size_t rl = gw_pack(rec, id++, dgram, (uint16_t)n);
ssize_t w = write(STDOUT_FILENO, rec, rl);
if (w < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) dropped++;
else if (errno != EINTR) break;
} else if ((size_t)w < rl) {
dropped++; // частичная запись — парсер на другом конце сам ресинхронизируется
} else {
sent++;
}
}
}
time_t now = time(NULL);
if (now > last) {
fprintf(stderr, "\rотправлено %llu | дропов %llu | негабарит %llu",
(unsigned long long)sent, (unsigned long long)dropped,
(unsigned long long)oversize);
fflush(stderr);
last = now;
}
}
fprintf(stderr, "\nudp_gw (listen) завершён: отправлено %llu | дропов %llu | негабарит %llu\n",
(unsigned long long)sent, (unsigned long long)dropped, (unsigned long long)oversize);
close(fd);
return 0;
}
// Выход: записи из stdin → UDP-датаграммы на dst. Парсер сам ресинхронизируется
// после дыры в потоке (см. gw_parser_next).
static int mode_deliver(const char *hostport) {
const char *colon = strrchr(hostport, ':');
if (!colon || colon == hostport) {
fprintf(stderr, "udp_gw: -d ожидает ip:port, получено '%s'\n", hostport);
return 1;
}
char host[64];
size_t hlen = (size_t)(colon - hostport);
if (hlen >= sizeof(host)) { fprintf(stderr, "udp_gw: адрес слишком длинный\n"); return 1; }
memcpy(host, hostport, hlen); host[hlen] = 0;
int port = atoi(colon + 1);
struct sockaddr_in dst;
memset(&dst, 0, sizeof(dst));
dst.sin_family = AF_INET;
dst.sin_port = htons((uint16_t)port);
if (inet_pton(AF_INET, host, &dst.sin_addr) != 1) {
fprintf(stderr, "udp_gw: не разобрал IP '%s' (только числовой IPv4)\n", host);
return 1;
}
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) { perror("socket"); return 1; }
gw_parser_t p;
gw_parser_init(&p);
uint8_t iobuf[GW_IO_CHUNK];
fprintf(stderr, "udp_gw: stdin -> UDP %s:%d\n", host, port);
time_t last = time(NULL);
while (!g_stop) {
ssize_t n = read(STDIN_FILENO, iobuf, sizeof(iobuf));
if (n < 0) { if (errno == EINTR) continue; break; }
if (n == 0) break; // EOF — источник (receiver) завершился
gw_parser_push(&p, iobuf, (size_t)n);
uint16_t id, len; const uint8_t *pl;
while (gw_parser_next(&p, &id, &pl, &len))
sendto(fd, pl, len, 0, (struct sockaddr *)&dst, sizeof(dst));
time_t now = time(NULL);
if (now > last) {
fprintf(stderr, "\rдоставлено %llu | ресинк %llu Б | bad_crc %llu | дыр(id) %llu",
(unsigned long long)p.records, (unsigned long long)p.resync_bytes,
(unsigned long long)p.bad_crc, (unsigned long long)p.gaps);
fflush(stderr);
last = now;
}
}
fprintf(stderr, "\nudp_gw (deliver) завершён: доставлено %llu | ресинк %llu Б | "
"bad_crc %llu | дыр(id) %llu\n",
(unsigned long long)p.records, (unsigned long long)p.resync_bytes,
(unsigned long long)p.bad_crc, (unsigned long long)p.gaps);
close(fd);
return 0;
}
static void usage(const char *prog) {
fprintf(stderr, "udp_gw — UDP-туннель поверх pluto-link (roadmap §10 п.8)\n\n");
fprintf(stderr, "Вход в линк: %s -l ПОРТ | transmitter ...\n", prog);
fprintf(stderr, "Выход из линка: receiver ... | %s -d ip:port\n\n", prog);
fprintf(stderr, " -l ПОРТ слушать UDP на ПОРТ, писать записи в stdout\n");
fprintf(stderr, " -d ip:port читать записи из stdin, слать UDP на ip:port\n");
fprintf(stderr, " -h эта справка\n");
}
int main(int argc, char *argv[]) {
setup_signals();
int opt;
int listen_port = -1;
const char *deliver_dst = NULL;
while ((opt = getopt(argc, argv, "l:d:h")) != -1) {
switch (opt) {
case 'l': listen_port = atoi(optarg); break;
case 'd': deliver_dst = optarg; break;
case 'h': usage(argv[0]); return 0;
default: usage(argv[0]); return 1;
}
}
if (listen_port > 0 && !deliver_dst) return mode_listen(listen_port);
if (deliver_dst && listen_port < 0) return mode_deliver(deliver_dst);
usage(argv[0]);
return 1;
}
#else
// --- Оффлайн-самопроверка парсера: cc -O2 -DGW_SELFTEST udp_gw.c -o gwtest ---
// По духу — как group_fec.c/GF_SELFTEST: чистая логика без сети/iio, нативная
// сборка на хосте (RPi/aarch64), кросс-тулчейн не нужен.
static int fails = 0;
#define CHECK(cond, ...) do { if (!(cond)) { printf("FAIL: " __VA_ARGS__); printf("\n"); fails++; } } while (0)
// Прогоняет payloads[i] (i=0..cnt-1) через pack, склеивает в один поток,
// прогоняет через парсер (кусками по `chunk` байт, 0 = одним куском —
// имитация разной гранулярности read()), сверяет извлечённые записи 1-в-1.
static void run_stream(const char *name, const uint8_t **payloads, const uint16_t *lens, int cnt,
size_t chunk) {
uint8_t *stream = malloc((size_t)cnt * GW_MAX_RECORD);
size_t slen = 0;
for (int i = 0; i < cnt; i++)
slen += gw_pack(stream + slen, (uint16_t)i, payloads[i], lens[i]);
gw_parser_t p; gw_parser_init(&p);
size_t off = 0;
while (off < slen) {
size_t take = chunk ? (chunk < slen - off ? chunk : slen - off) : slen - off;
gw_parser_push(&p, stream + off, take);
off += take;
}
int got = 0;
uint16_t id, len; const uint8_t *pl;
while (gw_parser_next(&p, &id, &pl, &len)) {
CHECK(id == got, "%s: id %u != ожид. %d", name, id, got);
CHECK(len == lens[got], "%s: len %u != ожид. %u (rec %d)", name, len, lens[got], got);
CHECK(memcmp(pl, payloads[got], len) == 0, "%s: данные не совпали (rec %d)", name, got);
got++;
}
CHECK(got == cnt, "%s: извлечено %d записей, ожидалось %d", name, got, cnt);
CHECK(p.bad_crc == 0, "%s: ложных bad_crc %llu на чистом потоке", name, (unsigned long long)p.bad_crc);
CHECK(p.gaps == 0, "%s: ложных дыр id %llu на непрерывном потоке", name, (unsigned long long)p.gaps);
free(stream);
}
int main(void) {
srand(42);
// 1. Обычный поток, размеры 1 / 1024 / 1472 / случайные, разными чанками read().
{
uint16_t lens[6] = { 1, 1024, 1472, 37, 500, 1471 };
uint8_t *bufs[6];
const uint8_t *pl[6];
for (int i = 0; i < 6; i++) {
bufs[i] = malloc(lens[i]);
for (int j = 0; j < lens[i]; j++) bufs[i][j] = (uint8_t)(rand() & 0xFF);
pl[i] = bufs[i];
}
run_stream("одним куском", pl, lens, 6, 0);
run_stream("по 1 байту", pl, lens, 6, 1);
run_stream("по 3 байта", pl, lens, 6, 3);
run_stream("по 4096 Б", pl, lens, 6, 4096);
for (int i = 0; i < 6; i++) free(bufs[i]);
}
// 2. Дыра посреди потока (потерянный кадр линка на симплексе): вырезаем
// запись #1 целиком → парсер должен ресинхронизироваться на следующей
// и правильно посчитать ровно одну дыру id.
{
uint16_t lens[4] = { 200, 300, 200, 300 };
uint8_t *bufs[4]; const uint8_t *pl[4];
for (int i = 0; i < 4; i++) {
bufs[i] = malloc(lens[i]);
for (int j = 0; j < lens[i]; j++) bufs[i][j] = (uint8_t)(rand() & 0xFF);
pl[i] = bufs[i];
}
uint8_t stream[4 * (300 + GW_HDR)];
size_t slen = 0;
for (int i = 0; i < 4; i++) slen += gw_pack(stream + slen, (uint16_t)i, pl[i], lens[i]);
size_t rec1_off = GW_HDR + lens[0]; // где начинается запись #1
size_t rec1_len = GW_HDR + lens[1]; // её полная длина
uint8_t cut[sizeof(stream)];
size_t clen = rec1_off;
memcpy(cut, stream, rec1_off);
memcpy(cut + clen, stream + rec1_off + rec1_len, slen - rec1_off - rec1_len);
clen += slen - rec1_off - rec1_len;
gw_parser_t p; gw_parser_init(&p);
gw_parser_push(&p, cut, clen);
int got = 0; uint16_t exp_ids[3] = { 0, 2, 3 };
uint16_t id, len; const uint8_t *rpl;
while (gw_parser_next(&p, &id, &rpl, &len)) {
CHECK(id == exp_ids[got], "дыра: id %u != ожид. %u (rec %d)", id, exp_ids[got], got);
got++;
}
CHECK(got == 3, "дыра: извлечено %d записей, ожидалось 3", got);
CHECK(p.gaps == 1, "дыра: gaps %llu != 1 (потеряна ровно запись #1)", (unsigned long long)p.gaps);
for (int i = 0; i < 4; i++) free(bufs[i]);
}
// 3. Случайный magic в данных (ложное совпадение) — hcrc8 должен отсеять
// ГАРАНТИРОВАННО (crc намеренно испорчен), ресинк не теряет настоящую запись.
{
uint8_t junk[64];
for (int i = 0; i < 64; i++) junk[i] = (uint8_t)(rand() & 0xFF);
junk[10] = GW_MAGIC0; junk[11] = GW_MAGIC1;
uint8_t want = gw_crc8(junk + 10, 7);
junk[17] = (uint8_t)(want + 1); // намеренно НЕ совпадает с валидным hcrc8
uint16_t plen = 50;
uint8_t plbuf[50];
for (int i = 0; i < 50; i++) plbuf[i] = (uint8_t)(rand() & 0xFF);
uint8_t stream[64 + GW_HDR + 50];
memcpy(stream, junk, 64);
size_t slen = 64 + gw_pack(stream + 64, 7, plbuf, plen);
gw_parser_t p; gw_parser_init(&p);
gw_parser_push(&p, stream, slen);
uint16_t id, len; const uint8_t *rpl;
int ok = gw_parser_next(&p, &id, &rpl, &len);
CHECK(ok == 1, "ложный magic: запись не найдена");
CHECK(id == 7, "ложный magic: id %u != 7", id);
CHECK(len == plen && memcmp(rpl, plbuf, plen) == 0, "ложный magic: данные не совпали");
CHECK(p.bad_crc >= 1, "ложный magic: bad_crc не сработал (%llu)", (unsigned long long)p.bad_crc);
}
// 4. Обрыв хвоста (payload/заголовок не влез целиком) — парсер должен
// вернуть 0 и НЕ прочитать частичные данные как запись, пока хвост не доедет.
{
uint16_t plen = 500;
uint8_t plbuf[500];
for (int i = 0; i < 500; i++) plbuf[i] = (uint8_t)(rand() & 0xFF);
uint8_t rec[GW_HDR + 500];
size_t rl = gw_pack(rec, 3, plbuf, plen);
gw_parser_t p; gw_parser_init(&p);
gw_parser_push(&p, rec, rl - 10); // не хватает 10 последних байт
uint16_t id, len; const uint8_t *rpl;
CHECK(gw_parser_next(&p, &id, &rpl, &len) == 0, "обрыв хвоста: запись найдена преждевременно");
gw_parser_push(&p, rec + rl - 10, 10); // дослали остаток
CHECK(gw_parser_next(&p, &id, &rpl, &len) == 1, "обрыв хвоста: запись не найдена после докладки");
CHECK(id == 3 && len == plen, "обрыв хвоста: данные после докладки не совпали");
}
// 5. Wrap-around id (u16): 65534,65535,0,1 — без ложных "дыр" на переходе.
{
uint8_t pl0[10] = {0}, pl1[10] = {0}, pl2[10] = {0}, pl3[10] = {0};
uint8_t stream[4 * (GW_HDR + 10)];
size_t slen = 0;
slen += gw_pack(stream + slen, 65534, pl0, 10);
slen += gw_pack(stream + slen, 65535, pl1, 10);
slen += gw_pack(stream + slen, 0, pl2, 10);
slen += gw_pack(stream + slen, 1, pl3, 10);
gw_parser_t p; gw_parser_init(&p);
gw_parser_push(&p, stream, slen);
int got = 0;
uint16_t id, len; const uint8_t *rpl;
while (gw_parser_next(&p, &id, &rpl, &len)) got++;
CHECK(got == 4, "wrap: извлечено %d записей, ожидалось 4", got);
CHECK(p.gaps == 0, "wrap: ложные дыры на переходе через 0: %llu", (unsigned long long)p.gaps);
}
printf("gwtest: %s\n", fails ? "ПРОВАЛ" : "OK");
return fails ? 1 : 0;
}
#endif

137
src/udp_probe.c Normal file
View File

@@ -0,0 +1,137 @@
// udp_probe.c — минимальный генератор/приёмник UDP для тестирования udp_gw
// (roadmap §10 п.8). НЕ часть штатного тракта — только инструмент бринг-апа
// и E2E-тестов: busybox-rootfs Pluto без nc/socat (проверено на железе),
// а Python на хосте запрещён (CLAUDE.md правило №1) — значит свой маленький
// C-бинарь, тот же кросс-компилятор, что и весь тракт.
//
// send: udp_probe send ip:port РАЗМЕРАНКААУЗА_МС] stdin -> UDP
// recv: udp_probe recv ПОРТ ТАЙМАУТРОСТОЯ_С UDP -> stdout
//
// recv сам завершается после ТАЙМАУТРОСТОЯ_С секунд без новых датаграмм
// (busybox без `timeout` — та же причина, что и в scripts/test_file_link.sh).
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>
#include <time.h>
#include <sys/select.h>
#include <sys/time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
static volatile sig_atomic_t g_stop = 0;
static void on_signal(int s) { (void)s; g_stop = 1; }
static int parse_hostport(const char *hostport, struct sockaddr_in *out) {
const char *colon = strrchr(hostport, ':');
if (!colon || colon == hostport) return -1;
char host[64];
size_t hlen = (size_t)(colon - hostport);
if (hlen >= sizeof(host)) return -1;
memcpy(host, hostport, hlen); host[hlen] = 0;
memset(out, 0, sizeof(*out));
out->sin_family = AF_INET;
out->sin_port = htons((uint16_t)atoi(colon + 1));
return inet_pton(AF_INET, host, &out->sin_addr) == 1 ? 0 : -1;
}
// stdin читается чанками по `chunk` байт, каждый чанк — одна UDP-датаграмма.
// Короткий чанк (fread < chunk) — признак EOF (как fread в transmitter.c).
static int mode_send(const char *hostport, int chunk, int pause_ms) {
struct sockaddr_in dst;
if (parse_hostport(hostport, &dst) != 0) {
fprintf(stderr, "udp_probe: не разобрал '%s' (ожидался ip:port)\n", hostport);
return 1;
}
if (chunk <= 0 || chunk > 65000) {
fprintf(stderr, "udp_probe: размер чанка вне диапазона\n");
return 1;
}
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) { perror("socket"); return 1; }
uint8_t *buf = malloc((size_t)chunk);
uint64_t sent = 0, bytes = 0;
for (;;) {
size_t n = fread(buf, 1, (size_t)chunk, stdin);
if (n == 0) break;
if (sendto(fd, buf, n, 0, (struct sockaddr *)&dst, sizeof(dst)) < 0)
fprintf(stderr, "udp_probe: sendto: %s\n", strerror(errno));
else { sent++; bytes += n; }
if (n < (size_t)chunk) break; // короткое чтение = EOF
if (pause_ms > 0) usleep((useconds_t)pause_ms * 1000);
}
fprintf(stderr, "udp_probe send: отправлено %llu датаграмм, %llu байт\n",
(unsigned long long)sent, (unsigned long long)bytes);
free(buf);
close(fd);
return 0;
}
// Дампит содержимое каждой полученной датаграммы в stdout как есть (порядок
// прихода). Для сверки с исходником через md5sum, как в остальных e2e-тестах
// репозитория — если ничего не потерялось и не переупорядочилось, конкатенация
// совпадёт байт-в-байт.
static int mode_recv(int port, int idle_timeout_s) {
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) { perror("socket"); return 1; }
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons((uint16_t)port);
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind"); close(fd); return 1;
}
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
uint8_t buf[65536];
uint64_t recvd = 0, bytes = 0;
time_t last_rx = time(NULL);
while (!g_stop) {
fd_set rfds; FD_ZERO(&rfds); FD_SET(fd, &rfds);
struct timeval tv = { .tv_sec = 1, .tv_usec = 0 };
int sel = select(fd + 1, &rfds, NULL, NULL, &tv);
if (sel < 0) { if (errno == EINTR) continue; break; }
if (sel > 0 && FD_ISSET(fd, &rfds)) {
ssize_t n = recvfrom(fd, buf, sizeof(buf), 0, NULL, NULL);
if (n < 0) { if (errno == EINTR) continue; break; }
fwrite(buf, 1, (size_t)n, stdout);
fflush(stdout);
recvd++; bytes += (uint64_t)n;
last_rx = time(NULL);
}
if (idle_timeout_s > 0 && time(NULL) - last_rx >= idle_timeout_s) break;
}
fprintf(stderr, "udp_probe recv: получено %llu датаграмм, %llu байт\n",
(unsigned long long)recvd, (unsigned long long)bytes);
close(fd);
return 0;
}
static void usage(const char *prog) {
fprintf(stderr, "udp_probe — тестовый генератор/приёмник UDP (busybox без nc)\n\n");
fprintf(stderr, " %s send ip:port РАЗМЕРАНКААУЗА_МС] stdin -> UDP\n", prog);
fprintf(stderr, " %s recv ПОРТ ТАЙМАУТРОСТОЯ_С UDP -> stdout\n", prog);
}
int main(int argc, char *argv[]) {
if (argc >= 4 && !strcmp(argv[1], "send"))
return mode_send(argv[2], atoi(argv[3]), argc >= 5 ? atoi(argv[4]) : 0);
if (argc >= 4 && !strcmp(argv[1], "recv"))
return mode_recv(atoi(argv[2]), atoi(argv[3]));
usage(argv[0]);
return 1;
}