# HRNeo — техническая документация кодовой базы Исходный код HRNeo (HydraRoute Neo) v3.11.0-1: архитектура, модули, потоки данных, оптимизации. --- ## 1. Общая архитектура и принцип работы HRNeo — демон для policy routing на роутерах Keenetic (Entware). Чистый C (без CGO, без внешних библиотек кроме libc и Linux API), единый статически скомпилированный бинарник. Версия 3.11.0-1. ### Два независимых источника имён хостов - **DNS-канал** (всегда): перехват DNS-ответов dnsmasq через AF_PACKET SOCK_DGRAM + L3-BPF. Работает на интерфейсах любого типа — Ethernet, PPP, ARPHRD_NONE (WireGuard, VPN-сервер, IPsec, туннели). Ловит DNS и LAN-, и VPN-клиентов. - **L7-канал** (опционально, `l7CaptureEnabled`): перехват TLS SNI / HTTP Host исходящих соединений через NFQUEUE. Фаза 2 — TCP-реассамблеция длинных ClientHello. Подробно — раздел [18](#18-l7-перехват-tls-sni--http-host--tcp-реассамблеция). ### Принцип работы (пошагово) 1. Читается конфигурация из `/opt/etc/HydraRoute/hrneo.conf` (27 параметров; CLI-флаги поверх конфига; недостающие — встроенные дефолты). 2. Если `DirectRouteEnabled=true` — сканируется `/sys/class/net/`, строится карта системных интерфейсов (`drm_scan_interfaces`): для каждого имени читается `/sys/class/net//operstate` (`up`/`down`/`unknown`). Карта нужна, чтобы при разборе watchlist различать «политика Keenetic» и «сетевой интерфейс для DirectRoute». 3. Парсится watchlist (`domain.conf`). Каждая строка имеет формат `домен1,домен2,geosite:TAG/Цель`. Цель классифицируется через `drm_classify_target` по карте интерфейсов из шага 2: - имя совпало с интерфейсом → цель является интерфейсом (DirectRoute, маршрутизация будет через `ip rule + ip route`) - не совпало → цель является политикой Keenetic (маршрутизация будет через политику роутера с её mark) Результат: разделённые массивы `policy_names[]` и `iface_names[]`. Лог: `[INFO] domain.conf: %d policies, %d interfaces`. 4. Опционально (`CIDR=true`): из `CIDRfile` (`ip.list`) извлекаются уникальные заголовки `/Name` через `parse_cidr_policy_headers`. Имена-интерфейсы фильтруются через `drm_classify_target`, остальные добавляются в список политик. Лог на каждое новое имя: `[INFO] CIDR: added policy 'X'`. 5. Опционально (есть `GeoSiteFile`): `parse_geosite_rules` собирает `geosite:TAG/Цель` из watchlist'а; имена-цели добавляются в политики (опять же intf-цели отфильтровываются). Лог: `[INFO] GeoSite: added policy 'X'`. 6. Применяется `PolicyOrder` через `sort_policies` (см. раздел [5](#5-матчинг-доменов-srcwatchlistc)). Сортировка делается дважды: для одних только политик (для шага 7) и для объединённого массива policy + iface (для шага 9). Итог печатается: ``` [INFO] Target order (N): [0] HydraRoute (policy) [1] nwg0 (interface, fwmark=0x3001) ... ``` 7. **Создание/проверка политик Keenetic через RCI** (`rci_create_policies`): hrneo формирует `POST /rci/ HTTP/1.0` с JSON-массивом `[{"parse":"ip policy "}, {"parse":"ip policy "}, ...]` и отправляет на `127.0.0.1:79`. Команда `parse` эквивалентна вводу `ip policy ` в CLI Keenetic — существующие политики не трогает, отсутствующие создаются пустыми (без VPN-интерфейсов; их администратор присвоит через веб-интерфейс роутера Keenetic). После — безусловный `POST /rci/ {"system":{"configuration":{"save":true}}}` (сохранение в startup-config). Интерфейсы из `iface_names[]` в RCI не отправляются — для них политики Keenetic не нужны. Лог: `[INFO] Policy creation commands executed`. Подробнее — раздел [13](#13-rci-remote-configuration-interface-keenetic-srcrcic). 8. Создаются `ipset`-множества `hash:net` (IPv4 и IPv6 отдельно) для каждой цели (политика или интерфейс): по два сета на target — `` (IPv4) и `v6` (IPv6). Через netlink с `NLM_F_CREATE|NLM_F_EXCL`; существующие сеты не пересоздаются. Кэшируется `ipset_timeout`. При `clearIPSet=true` — `FLUSH` каждого сета. 9. Опционально (`CIDR=true`): загрузка статических CIDR-блоков в `ipset` (`add_cidr_to_ipsets`). Двухфазная обработка: фаза 1 — пресканирование `geoip:TAG` и автомиграция oversized тегов в disabled-секцию; фаза 2 — собственно `ipset_add_batch` для активных блоков. 10. Опционально (есть `GeoSiteFile`): `build_geosite_domain_map` загружает домены типов `Domain`/`Full` из `.dat`-файлов в хеш-таблицу с приоритетом у `domain.conf` (`ht_insert` НЕ перезаписывает существующие). 11. Если `DirectRoute=true` — `drm_setup_all_routes`: ``` ip [-6] rule add priority N fwmark 0x table ip [-6] route add default dev table # или blackhole ``` `fwmark` и `table_id` уникальные, выделяются последовательно от `InterfaceFwMarkStart` (12289) и `InterfaceTableStart` (301). 12. **Извлечение `markID` политик через RCI** + создание `CONNMARK`-правил `iptables` (`apply_unified_connmark_rules`): - `GET /rci/show/ip/policy/` возвращает JSON `{"":{"mark":"0xNN", ...}, ...}` - hrneo вручную парсит ответ (`find_matching_brace` + `strstr "mark"`), извлекает hex-значение `markID` для каждой политики, снимает префикс `0x`. Лог при `log=console/file`: `[DEBUG] RCI policy: HydraRoute mark=0x21` - Двухуровневая retry-защита: `rci_get_policies_with_retry` (до 5 попыток × 3с) от сетевых ошибок; внутренний loop (до 5 попыток × 4с) от свежесозданных политик без `markID` (роутер назначает его не сразу после `parse`) - Для целей-интерфейсов `markID` не запрашивается — используется назначенный `fwmark` - Для каждой цели в порядке `g_all_sorted[]` формируется пара `CONNMARK`-правил в `mangle/PREROUTING`, через `iptables-restore --noflush` (один вызов на весь батч). Если у политики `mark` пустой после retry — `LOG_WARN "Policy %s has no mark ID, skipping"`, цель пропускается (`ipset` продолжит заполняться, но трафик не маркируется) 13. Инициализируется `AF_PACKET` захват DNS-ответов: два `SOCK_DGRAM/ETH_P_ALL` сокета с L3-BPF (`fd4` для IPv4, `fd6` для IPv6). 14. Опционально (`l7CaptureEnabled=true`): резолв WAN (config или `/proc/net/route`), `init_module(xt_connbytes)`, `nfq_capture_init`, `NFQUEUE`-правила `iptables`/`ip6tables` для TCP 443/80; при `l7TcpReasmEnabled` — `tcp_reasm_init` + `timerfd` GC (1с). 15. Основной epoll-цикл перехватывает DNS-ответы (`AF_PACKET`) и L7-пакеты (`NFQUEUE`), добавляет IP в `ipset` через netlink. 16. Если `ConntrackFlush=true` И IP добавлен в `ipset` впервые (`NLM_F_EXCL` вернул `err==0`, а не `IPSET_ERR_EXIST`), для каждого такого IP делается `conntrack`-DUMP с DELETE по совпадению dst-IP. Реальное удаление происходит только при наличии активной `conntrack`-записи к этому IP; если соединения к IP ещё нет — DUMP проходит вхолостую, DELETE не отправляется. 17. Обрабатываются сигналы: - `SIGUSR1` — обновление состояния интерфейсов + пересоздание `CONNMARK`-правил (включая повторный `GET /rci/show/ip/policy/` для возможно изменившихся `markID`) + реинсталл L7-правил (idempotent через `iptables -C`). Debounce 5с через `timerfd` - `SIGINT`/`SIGTERM` — штатная остановка: снятие L7 `NFQUEUE`-правил, удаление `CONNMARK`, удаление `ip rule` + flush таблиц DirectRoute, закрытие netlink-сокетов, удаление PID-файла ### Архитектурная схема (DNS-канал) ``` DNS-ответ dnsmasq → клиент (любой интерфейс: br0/WG/VPN/PPP/IPsec/туннель) | v [dev_queue_xmit_nit() → ptype_all] ← ETH_P_ALL обязателен | v [AF_PACKET SOCK_DGRAM/ETH_P_ALL fd4/fd6 ядро отдаёт L3-пакет без канального заголовка] | v [L3-BPF: версия IP (ниббл) + proto + sport==53] | v [process_dns_packet: парсинг DNS] | v [Добавление новых IP в ipset через netlink] | (ConntrackFlush=true && новые IP) | v [netlink: удаление conntrack-записей для новых IP (DUMP + DELETE if present)] [Исходящий трафик клиента] → iptables/mangle PREROUTING → CONNMARK set-xmark по ipset dst match → CONNMARK restore-mark → ip rule fwmark → table X → ip route default dev ``` ### Файловая структура (23 файла `.c`) | Файл | Назначение | |------|------------| | `src/main.c` | Точка входа, event loop (epoll), обработка DNS | | `src/packet_capture.c` | AF_PACKET SOCK_DGRAM захват DNS (L3-BPF) | | `src/iptables.c` | CONNMARK-правила, unified targets | | `src/routing.c` | DirectRoute: ip rule, ip route, интерфейсы | | `src/watchlist.c` | Парсинг domain.conf, матчинг доменов | | `src/config.c` | Парсинг hrneo.conf + config_generate | | `src/args.c` | Парсинг CLI, наложение на config | | `src/params.c` | PARAMS[] — таблица описания параметров (single source of truth для config/args/help/genconfig) | | `src/ipset_nl.c` | Низкоуровневая работа с ipset через netlink | | `src/conntrack.c` | Сброс conntrack-записей через netlink | | `src/dns.c` | Парсинг DNS-ответов (A, AAAA, CNAME) | | `src/log.c` | Логирование (console/file/syslog/off) | | `src/util.c` | Хеш-таблица доменов, chunked pool, fork/exec | | `src/rci.c` | HTTP/JSON взаимодействие с API Keenetic | | `src/signal_handler.c` | `signal_mgr_t` (signalfd + timerfd manager) | | `src/geodat.c` | Парсинг GeoIP/GeoSite .dat файлов (protobuf) | | `src/probe_tls.c` | Stateless парсер TLS ClientHello → SNI | | `src/probe_http.c` | Stateless парсер HTTP request → Host | | `src/bogon.c` | Фильтр служебных IPv4/IPv6 диапазонов | | `src/nfq_capture.c` | NFQUEUE через raw NETLINK_NETFILTER (без libnfq) | | `src/l7_dispatch.c` | Fail-fast диспетчер пакетов → probe → reasm | | `src/l7_firewall.c` | WAN-резолв, init_module, iptables NFQUEUE-правила | | `src/tcp_reasm.c` | 5-tuple TCP-реассамблеция длинных ClientHello | | `include/hrneo.h` | Основные структуры, константы, inline `fnv1a_hash` | | `include/*.h` | Заголовочные файлы для каждого модуля | | `Makefile` | Сборка для mipsel, mips, aarch64, native | --- ## 2. Точка входа: `src/main.c` ### Константы (`include/hrneo.h`) | Константа | Значение | Назначение | |-----------|----------|------------| | `DEFAULT_CONFIG_PATH` | `"/opt/etc/HydraRoute/hrneo.conf"` | путь к конфигу | | `DEFAULT_PID_FILE` | `"/var/run/hrneo.pid"` | путь к PID-файлу | | `DEFAULT_API_PORT` | `79` | порт RCI | | `IPSET_HASH_TYPE` | `"hash:net"` | тип создаваемых ipset | | `MANGLE_TABLE` | `"mangle"` | таблица iptables | | `SOCKET_READ_BUFFER` | 1 МБ | `SO_RCVBUF` для AF_PACKET | | `SIGUSR1_DEBOUNCE_SEC` | `5` | debounce SIGUSR1 | | `RCI_TIMEOUT_SEC` | `10` | таймаут RCI-запроса | | `POLICY_API_MAX_RETRIES` | `5` | попыток на `GET /rci/show/ip/policy/` | | `POLICY_API_RETRY_DELAY` | `3` | секунды между попытками | | `IPSET_CHUNK_SIZE` | `256` | размер батча ipset | | `IPSET_DEFAULT_MAXELEM` | `262144` | fallback при `IpsetMaxElem=0` в `add_cidr_to_ipsets` | | `POOL_CHUNK_SIZE` | `256 * 1024` | размер одного чанка string pool | | `IPSET_ERR_EXIST` | `4103` | netlink-код «запись уже есть» | | `IPSET_ERR_HASH_FULL` | `4101` | netlink-код «лимит maxelem» | | `MAX_CNAME_CHAIN` | `16` | глубина BFS по CNAME | | `DOMAIN_HT_BUCKETS` | `8192` | бакетов в хеш-таблице доменов | | `MAX_GEO_FILES` | `16` | максимум `GeoIPFile`/`GeoSiteFile` | | `MAX_POLICY_ORDER` | `64` | максимум целей в `PolicyOrder` | | `MAX_INTERFACES` | `64` | максимум интерфейсов DirectRoute | > В `hrneo.h` **НЕТ** `arena_t` / `ARENA_SIZE` — все временные буферы статические/на стеке. ### `config_t` (27 полей) См. `src/params.c` и `docs/HRNEO.CONF.md`. Поля: `auto_start`, `watchlist_path`, `clear_ipset`, `cidr_enabled`, `cidr_file_path`, `ipset_enable_timeout`, `ipset_timeout`, `log_level`, `log_file_path`, `direct_route_enabled` (default 1), `interface_fwmark_start` (12289), `interface_table_start` (301), `global_routing`, `conntrack_flush` (1), `ipset_maxelem` (262144), `geo_ip_files[16][512]`+counter, `geo_site_files[16][512]`+counter, `policy_order[64][64]`+counter, `l7_capture_enabled` (1), `l7_queue_num` (210), `l7_enable_tls` (1), `l7_enable_http` (1), `l7_connbytes_max` (8), `l7_wan_interface[32]`, `l7_tcp_reasm_enabled` (1), `l7_tcp_reasm_max_entries` (256), `l7_tcp_reasm_ttl_sec` (5). ### Глобальные переменные `main.c` ```c config_t g_config; domain_hashtable_t *g_all_targets; ipset_manager_t g_ipset_mgr; volatile int g_shutdown; direct_route_manager_t g_drm; int g_drm_active; unified_target_t g_all_sorted[MAX_POLICY_ORDER + MAX_INTERFACES]; int g_all_sorted_count; conntrack_mgr_t g_conntrack = { .fd = -1 }; rci_client_t g_rci; nfq_capture_t g_nfq; int g_l7_active; char g_l7_wan[MAX_INTERFACE_NAME]; tcp_reasm_t g_reasm; int g_reasm_active; ``` ### `main()` — последовательность старта 1. `args_parse(argc, argv)` — парсинг CLI - `--version`/`-v`: `return 1` (`main → 0`) - `--help`/`-h`: `return 2 → 0` - `--genconfig [path]`: `return 3 → main` вызывает `config_generate(args.genconfig_target)` - ошибка: `return -1 → 1` 2. `sigprocmask(SIG_BLOCK)` для `SIGINT`/`SIGTERM`/`SIGUSR1` 3. `config_read()` — путь из `args.config_path` или `DEFAULT_CONFIG_PATH`; явный `--config` при недоступном файле → выход 1 4. `args_apply()` — наложение CLI-флагов (только `set_mask`-биты) 5. Если `!auto_start` → `return 0` 6. `log_setup()` + `LOG_INFO "HRNeo v%s starting"` + `create_pid_file()` 7. `ht_create()` — создание хеш-таблицы доменов 8. Если DirectRoute: `drm_init()`, `drm_scan_interfaces()`, `parse_watchlist_classified()` → для каждого iface: `drm_allocate_fwmark()`, `drm_allocate_table_id()`, `drm_register_route()`. Иначе: `parse_watchlist()`, `get_unique_names()` 9. `CIDR=true`: `parse_cidr_policy_headers()` — добавляет политики из заголовков `/Name` CIDR-файла; intf-цели отфильтровываются через `drm_classify_target`; `LOG_INFO "CIDR: added policy 'X'"` для каждой новой 10. GeoSite файлы заданы: `parse_geosite_rules()` — добавляет политики из `geosite:`-правил watchlist; intf-цели отфильтровываются; `LOG_INFO "GeoSite: added policy 'X'"` для каждой новой 11. `sort_policies()` для `policy_names` с учётом `PolicyOrder` 12. `g_all_sorted[]`: `all_names = policy_names + iface_names`, `sort_policies()` на объединении; `unified_target_t = {pair (ipv4/ipv6 имена), is_interface, fwmark}` 13. `LOG_INFO "Target order (%d):"` — вывод порядка целей 14. `rci_client_init(&g_rci)` — выделение `raw_buf` + `response_buf` (~1 МБ + 1 МБ) 15. `rci_create_policies()` — только для `policy_names` (POST + save) 16. `ipset_manager_init()` + `initialize_ipsets()` — создание/очистка ipset-пар для всех `g_all_sorted`, кэширование `timeout` 17. `add_cidr_to_ipsets()` — если `CIDR=true` и `cidr_file_path` задан 18. `build_geosite_domain_map()` — если `geo_site_file_count > 0` 19. `drm_setup_all_routes()` — `ip rule` + `ip route` для DirectRoute 20. `apply_unified_connmark_rules()` 21. Если `conntrack_flush` — `conntrack_mgr_init()` (при ошибке flush отключается) 22. `pkt_capture_init()` — два `AF_PACKET SOCK_DGRAM/ETH_P_ALL` сокета (`fd4`, `fd6`) 23. Если `l7_capture_enabled`: `l7_firewall_resolve_wan`; при неудаче — L7 отключается с `LOG_WARN`. Иначе: `l7_firewall_load_kmod("xt_connbytes")` через `init_module(2)`; `l7_dispatch_set_enable`; при `l7_tcp_reasm_enabled` — `tcp_reasm_init` + `l7_dispatch_set_reasm` (`g_reasm_active=1`); `nfq_capture_init`; `l7_firewall_install` (NFQUEUE для TCP 443/80). `g_l7_active=1` при успехе 24. `signal_mgr_init()` — `sigprocmask` + `signalfd` + `timerfd` 25. `epoll_create1()` — регистрация `cap.fd4`, `cap.fd6`, `signals.sig_fd`, `signals.timer_fd`; при `g_l7_active` — `nfq_fd`; при `g_reasm_active` — `reasm_gc_fd` (`timerfd` 1s) 26. Основной цикл `epoll_wait` (`events[8]`) 27. **Cleanup:** `signal_mgr_close` → `l7_firewall_remove` + `nfq_capture_close` → `tcp_reasm_close` (если `g_reasm_active`) → `pkt_capture_close` → `conntrack_mgr_close` → `drm_cleanup_all_routes` → `cleanup_connmark_rules` → `ipset_manager_close` → `rci_client_close` → `ht_destroy` → `remove_pid_file` → `log_close` --- ## 3. DNS-детекция: AF_PACKET захват **Файл:** `src/packet_capture.c`, `include/packet_capture.h` ### `pkt_capture_t` (структура) | Поле | Тип | Назначение | |------|-----|------------| | `fd4` | `int` | AF_PACKET сокет с L3-BPF для IPv4 DNS | | `fd6` | `int` | AF_PACKET сокет с L3-BPF для IPv6 DNS | | `callback` | `pkt_capture_cb` | колбэк `(const uint8_t *pkt, int pkt_len, void *user_data)` | | `user_data` | `void *` | произвольный контекст | | `recv_buf` | `uint8_t[65536]` | буфер приёма | ### `pkt_capture_init(cap, cb, user_data)` 1. Создаёт два сокета через `open_capture_socket()`: ```c socket(AF_PACKET, SOCK_DGRAM | SOCK_CLOEXEC, htons(ETH_P_ALL)) ``` 2. `SO_RCVBUF = SOCKET_READ_BUFFER` (1 МБ) 3. `SO_ATTACH_FILTER` с классической BPF-программой ### Почему `SOCK_DGRAM` (а не `SOCK_RAW`) Ядро снимает канальный (L2) заголовок и отдаёт пакет с сетевого (IP) уровня единообразно для интерфейсов любого типа — Ethernet (`br0`), PPP, `ARPHRD_NONE` (WireGuard `nwg0`, VPN-сервер `t2s*`, IPsec `xfrms*`), туннели. Поэтому DNS-ответы VPN-клиентам (не-Ethernet интерфейсы, нет 14-байтного Ethernet-заголовка) больше не теряются. Фильтры работают от смещения 0 (IP-заголовок), а не от Ethernet. ### BPF-фильтры (L3, данные начинаются с IP-заголовка) - **`bpf_v4_dns`:** версия IP по верхнему нибблу байта 0 == 4, proto (байт 9) == UDP(17) или TCP(6), `src_port == 53` (индексированная загрузка halfword по `X=IHL×4`) - **`bpf_v6_dns`:** версия IP == 6, `next_header` (байт 6) == UDP(17) или TCP(6), `src_port` читается из halfword по фиксированному смещению 40 (IPv6-заголовок) == 53 (extension-заголовки не разбираются — поведение как у прежнего фильтра) ### Почему `ETH_P_ALL` Ядро Linux доставляет исходящие пакеты через `dev_queue_xmit_nit()` только обработчикам `ptype_all`. `ETH_P_IP`/`ETH_P_IPV6` регистрируются в `ptype_base` и не получают исходящие пакеты физических интерфейсов. `ETH_P_ALL` регистрируется в `ptype_all` → перехватывает DNS-ответы dnsmasq → клиентам. ### `pkt_capture_process(cap, fd)` - `recvfrom()` с `sockaddr_ll` (адресный буфер; `sll_hatype` не анализируется) - При `SOCK_DGRAM` канального заголовка нет ни для одного типа интерфейса → смещение не вычисляется, callback вызывается с `recv_buf` (IP-пакет со смещения 0) ### `pkt_capture_close(cap)` Закрывает `fd4` и `fd6`. --- ## 4. Обработка DNS-пакетов: `src/dns.c` + `src/main.c` ### Структуры DNS (`include/dns.h`) ```c dns_answer_t { domain[256]; ip[16]; family; } // один A/AAAA-ответ dns_cname_t { source[256]; target[256]; } // одна CNAME-запись dns_result_t { answers[DNS_MAX_ANSWERS]; answer_count; cnames[DNS_MAX_CNAMES]; cname_count; } #define DNS_MAX_ANSWERS 128 #define DNS_MAX_CNAMES 32 ``` ### `extract_dns_payload(pkt, pkt_len, dns_len)` 1. Определяет версию IP (4/6), вычисляет `ip_hdr_len` и `transport_offset` 2. Проверяет `src_port == 53` 3. Определяет протокол (UDP/TCP) 4. TCP: пропускает 2-байтовый length prefix, `dns_len = MIN(prefix, pkt_remaining)` 5. Возвращает указатель на DNS-данные ### `dns_parse_response(dns_data, dns_len, result)` 1. Проверяет `DNS_FLAG_QR` (response) 2. Пропускает Question-секцию 3. Итерирует Answer-секцию: `TypeA` → `answers[]`, `TypeAAAA` → `answers[]`, `TypeCNAME` → `cnames[]` 4. `dns_decode_name`: декодирует DNS-имена с поддержкой compression pointers (до 128 hop защиты) ### `process_dns_packet(pkt, pkt_len, user_data)` (`main.c`) 1. `extract_dns_payload` + `dns_parse_response` → `dns_result_t` (статическая переменная в функции) 2. Сборка `cname_entry_t[]` (`from`/`to`) из `result.cnames[].source/target` (статический массив) 3. Итерирует уникальные домены (`processed[64][256]` на стеке) — для каждого: - Сбор IPv4/IPv6 батчей из `answers` (до 32 каждого семейства) - `process_hostname_event(domain, cnames, ..., "DNS")` — общий хелпер с L7-каналом ### `process_hostname_event(domain, cnames, ipv4_batch, ipv4_count, ipv6_batch, ipv6_count, source_tag)` 1. `match_domain_with_cname` → `ipset_name` (или `NULL` — пропуск) 2. `LOG_MATCH "[] -> "` (или `" via -> "` при CNAME) 3. `ipset_add_batch` для IPv4 (`setname`, `with_timeout=1`) 4. `ipset_add_batch` для IPv6 (`setname + "v6"`, `with_timeout=1`) 5. `LOG_PROCESSED` для каждого реально добавленного (нового) IP 6. `conntrack_flush_for_ips()` если `conntrack_flush=1` и есть новые IP (для каждого нового IP DUMP+DELETE по dst; DELETE отправляется только при существующих conntrack-записях к этому IP, иначе DUMP вхолостую) ### `process_hostname_event_l7(host, proto, daddr, family)` Обёртка из L7-канала (вызывается из `l7_dispatch.c`): заполняет `parsed_cidr_t` из `daddr` и вызывает `process_hostname_event` с тегом `"TLS-SNI"` / `"HTTP-Host"`. --- ## 5. Матчинг доменов: `src/watchlist.c` ### `match_domain(ht, policy_order, order_count, domain, domain_len)` 1. Точное совпадение через `ht_lookup()` 2. Суффиксный поиск: для каждой точки в домене проверяет parent-домен 3. Приоритет: `policy_order` (меньше индекс = важнее); тай-брейкер по специфичности (длиннее = специфичнее) 4. Точное: `specificity = domain_len + 1`; суффиксное: `specificity = suffix_len` ### `match_domain_with_cname(ht, policy_order, order_count, domain, cnames, cname_count, matched_domain)` BFS-обход CNAME-цепочки (до `MAX_CNAME_CHAIN=16` шагов). Поиск двунаправленный: для каждого текущего домена проверяются как `cnames[i].from == current` (forward), так и `cnames[i].to == current` (backward). Защита от циклов через `visited_hashes` (FNV-1a). Возвращает первый совпавший `ipset_name` и `matched_domain` (через out-параметр). ### `parse_watchlist_lines(path, on_target, on_domain, user)` Единый построчный разборщик `domain.conf`: - Строки читаются через `getline()` (динамический буфер) — длина строки не ограничена - Цель (после последнего `/`) копируется в `target_buf[64]`; домены-часть режется in-place и разбивается `strtok_r` по запятой - `on_target` вызывается один раз на строку; `on_domain` — для каждого не-`geosite` домена (уже lowercase) - `geosite:`-записи пропускаются на этом этапе ### `parse_watchlist(path, ht)` Обёртка над `parse_watchlist_lines`; `ht_insert(match_subs=1)` для каждого домена. ### `parse_watchlist_classified` (`routing.c`) `on_target` классифицирует через `drm_classify_target` и сортирует в `policy_names[]`/`iface_names[]`; `on_domain` делает `ht_insert`. Итог: `LOG_INFO "domain.conf: %d policies, %d interfaces"`. ### `parse_cidr_policy_headers(path, names, max_names)` - Читает CIDR-файл через `getline()`, собирает уникальные имена из активных заголовков `/ИмяПолитики` - Пропускает `##...` и `#/...` строки ### `sort_policies(names, count, order, order_count)` - `order` пустой → `qsort` алфавитно - иначе: сначала элементы из `order` (по порядку), затем остальные алфавитно - Элементы `order`, отсутствующие в `names` → `LOG_WARN` ### PolicyOrder — единственный механизм приоритезации целей Работает на **ДВУХ уровнях независимо**: **1) Порядок CONNMARK-правил в `iptables/mangle/PREROUTING`** `main` вызывает `sort_policies` дважды: для `policy_names` (для `rci_create_policies`) и для объединённого `all_names` (policy + iface вместе) — результат пишется в `g_all_sorted[]`. `apply_unified_connmark_rules` итерирует этот массив последовательно и добавляет правила `CONNMARK` в этом порядке через `iptables-restore --noflush`. Поскольку `iptables` проверяет правила сверху вниз и берёт первое совпадение, при попадании пакета в несколько `ipset` одновременно (например, IP принадлежит сразу `/HydraRoute` и `/RU` в `ip.list`, или один IP пришёл в DNS-ответах разных доменов разных политик) выигрывает цель, стоящая раньше в `PolicyOrder`. **2) Выбор политики при матчинге домена (`match_domain` через `get_policy_priority`)** Если домен зарегистрирован сразу в нескольких целях (например, в watchlist прописано `google.com/HydraRoute` и `mail.google.com/RU`, или CNAME-цепочка проходит через домены разных политик), `match_domain` среди всех совпадений выбирает с минимальным индексом в `policy_order`; при равных priority — с большей `specificity` (длина совпавшего суффикса; точное совпадение = `len+1`, выигрывает над любым суффиксным). Цели не из `policy_order` получают `priority=order_count` («последние»). Имена политик Keenetic и имена сетевых интерфейсов смешиваются в одном `PolicyOrder`; hrneo автоматически различает их через `drm_classify_target` по `/sys/class/net`. `SIGUSR1` не перечитывает `hrneo.conf` и сам `PolicyOrder`; `apply_unified_connmark_rules` пересоздаёт правила в **уже** загруженном порядке `g_all_sorted[]`. Для применения нового `PolicyOrder` требуется `neo restart`. --- ## 6. Хеш-таблица доменов: `src/util.c` ### `pool_chunk_t` ```c struct pool_chunk { struct pool_chunk *next; // следующий чанк size_t used; // байт занято в чанке char data[POOL_CHUNK_SIZE]; // 256 КБ данных }; ``` ### `domain_hashtable_t` | Поле | Назначение | |------|------------| | `buckets[8192]` | цепочки `domain_node_t` | | `count` | количество записей | | `pool_head`, `pool_tail` | linked list чанков (аллокатор строк и нод) | | `ipset_name_cache[MAX_POLICY_ORDER][64]` | кэш строк-имён политик | | `ipset_name_ptrs[MAX_POLICY_ORDER]` | соответствующие указатели в pool | | `ipset_name_count` | размер кэша | ### Функции **`ht_create()`** — создаёт таблицу + первый `pool_chunk_t`. **`ht_insert(ht, domain, domain_len, ipset_name, match_subs)`:** - FNV-1a хеш → индекс бакета - Проверка дубликата домена (возврат 0 без изменений) - Дедупликация `ipset_name`: поиск в `ipset_name_cache[]` (`O(MAX_POLICY_ORDER)`), переиспользует ptr - Нода и строки хранятся в `pool_chunk_t` через `ht_pool_alloc()` - Возвращает `1` при вставке, `0` при дубле, `-1` при ошибке аллокации **`ht_lookup(ht, domain, domain_len)`** — `O(1)` средний. **`ht_destroy(ht)`** — освобождает чанки linked list + сам `ht`. ### Вспомогательные функции - `to_lower_inplace()` — ASCII lowercase in-place - `trim_whitespace()` — обрезка пробелов - `fnv1a_hash()` — inline в `hrneo.h` - `mkdir_p()` — рекурсивное создание каталогов - `run_command_output()` — `fork`/`execvp` с захватом `stdout`+`stderr` - `run_command_stdin()` — `fork`/`execvp` с подачей `stdin` --- ## 7. Управление ipset: `src/ipset_nl.c` ### `ipset_manager_t` | Поле | Назначение | |------|------------| | `fd` `int` | netlink-сокет (`NETLINK_NETFILTER`, long-lived) | | `seq`, `pid` | для netlink-сообщений | | `set_has_timeout[256]` | кэш timeout-режима (индекс: `FNV-1a & 0xFF`) | | `timeout_value[256]` | кэш значений timeout | | `set_names[IPSET_MAX_SETS=512][64]` | кэш имён существующих ipset | | `set_count` | количество кэшированных имён | ### `ipset_create(mgr, name, type, family, timeout, maxelem)` - `ipset_query_revision()` — `IPSET_CMD_TYPE` через netlink: отправляет TYPE-запрос, парсит ответ, извлекает `IPSET_ATTR_REVISION` (при ошибке → 0) - `IPSET_CMD_CREATE` через netlink с флагами `NLM_F_CREATE | NLM_F_EXCL`; атрибуты: `PROTOCOL`, `SETNAME`, `TYPENAME`, `REVISION`, `FAMILY` - DATA-атрибут добавляется если `timeout > 0` OR `maxelem > 0` - `errno=17` (`EEXIST`) → `LOG_DEBUG "Set %s already exists"`, добавляет в кэш, возвращает 0 - Прочие ошибки: `LOG_ERROR` + возврат `errno` - При успехе: `LOG_DEBUG "Set %s created"`, добавляет в `set_names` кэш ### `ipset_flush(mgr, name)` `IPSET_CMD_FLUSH` через netlink. ### `ipset_add_batch(mgr, set_name, entries, count, with_timeout, new_count, new_indices)` 1. Читает кэш `timeout` через FNV-1a индекс 2. Фильтрует service IP через `is_service_ip()` (`LOG_FILTERED`) 3. Формирует netlink-сообщения через `build_ipset_add_msg()` с `TIMEOUT`-атрибутом, когда кэш `has_timeout=1` и `with_timeout=true` 4. Чанки по `IPSET_CHUNK_SIZE=256`: send все сообщения чанка, затем recv все ответы 5. `with_timeout=true`: `NLM_F_EXCL` (повторное добавление → `IPSET_ERR_EXIST` без обновления `timeout`); `new_indices` заполняется только при `with_timeout=true` 6. `with_timeout=false`: `NLM_F_CREATE` без `NLM_F_EXCL`; `new_indices` не заполняется 7. Обработка ошибок netlink ack: - `err->error == 0` → запись добавлена, индекс пишется в `new_indices` при `with_timeout=1` - `IPSET_ERR_HASH_FULL` (4101) → `LOG_WARN "ipset '%s' full"` - `IPSET_ERR_EXIST` (4103) → молча игнорируется - Прочие коды → `LOG_DEBUG "Netlink ADD error: errno=%d"` ### Прочие функции - `ipset_cache_timeout_for_set()` — записывает `timeout` в кэш по индексу `FNV-1a & 0xFF` - `ipset_refresh_set_list()` — `ipset list -n` → заполняет `set_names` - `ipset_set_exists()` — линейный поиск по `set_names` - `ipset_add_to_cache()` — добавляет имя в `set_names` ### `is_service_ip()` - IPv4: первый октет == 0 или 127 - IPv6: `::` (unspecified), `::1` (loopback) --- ## 8. Маршрутизация и маркировка ### A) Policy-Based (через политики Keenetic) **Файл:** `src/iptables.c`, функция `apply_unified_connmark_rules()`. 1. `get_br0_networks()`: `ip addr show br0` — получает IPv4-сеть (обязательно) и IPv6-сеть `scope global` 2. Внутренний retry loop (до 5 попыток, sleep 4s): `rci_get_policies_with_retry()` + проверка наличия `mark` для каждой не-interface цели. Если хотя бы одна политика без `mark` — повтор через 4 секунды 3. Получает текущие правила через `iptables/ip6tables -w -t mangle -S PREROUTING` 4. Для каждого `unified_target`: - **Интерфейс:** `mark = fwmark` (hex) - **Политика:** `mark` из RCI-ответа; если `mark` пуст — цель пропускается с `[WARN]` 5. `find_mark_in_rules()` — проверяет существующий `mark` в кэшированных правилах 6. Если правила нет или `mark` изменился — удаляет старые, добавляет в batch 7. IPv4 batch → `iptables-restore --noflush` 8. IPv6 batch → `ip6tables-restore --noflush`, но только если `is_interface=true` или `br0` имеет IPv6-адрес #### Правила CONNMARK (`GlobalRouting=false`) ``` -A PREROUTING -m mark ! --mark 0xffffaa0/0xffffff0 -m connmark --mark 0x0/0xffff0000 -m set --match-set dst -j CONNMARK --set-xmark 0x/0xffffffff -A PREROUTING -m set --match-set dst -j CONNMARK --restore-mark --nfmask 0xffffffff --ctmask 0xffffffff ``` `GlobalRouting=true`: условие `! --mark 0xffffaa0/0xffffff0` убирается. #### `unified_target_t` (`include/iptables.h`) | Поле | Тип | Назначение | |------|-----|------------| | `pair` | `ipset_pair_t` | ipv4/ipv6 имена | | `is_interface` | `int` | флаг интерфейса DirectRoute | | `fwmark` | `int` | назначенный fwmark (для интерфейсов) | `g_all_sorted[]` объединяет политики и интерфейсы в единый отсортированный массив. `cleanup_connmark_rules(pairs, count)`: удаляет CONNMARK-правила из `mangle/PREROUTING`. ### Б) DirectRoute (прямая маршрутизация на интерфейс) **Файл:** `src/routing.c`. #### `direct_route_manager_t` | Поле | Назначение | |------|------------| | `config` | `*config_t` | | `interfaces[MAX_INTERFACES=64]` | `interface_info_t (name, state)` | | `interface_count` | счётчик интерфейсов | | `routes[MAX_INTERFACES]` | `interface_route_t (interface_name, ipset_pair, fwmark, table_id)` | | `route_count` | счётчик маршрутов | | `next_fwmark`, `next_table_id` | следующий свободный fwmark/table_id | #### Инициализация 1. `parse_watchlist_classified()` → раздельные `policy_names[]`, `iface_names[]` 2. Для каждого `iface`: `drm_allocate_fwmark()` + `drm_allocate_table_id()` + `drm_register_route()` (создаёт `ipset_pair`: `ipv4 = iface_name`, `ipv6 = iface_name + "v6"`) #### Активность интерфейса `state == "up" || state == "unknown"` — оба активные. DOWN → `blackhole`-маршрут в таблице. #### Настройка маршрутов (`drm_setup_all_routes`) ``` ip [-6] rule add priority N fwmark 0x table ip [-6] route add default dev table # или blackhole если DOWN ``` `"can't find device"` → `blackhole` вместо ошибки. **IP Rule Priority:** `9 - (table_id - table_start)`, минимум 1. #### Прочие функции - `drm_scan_interfaces()` — читает `/sys/class/net/`, для каждого только `operstate` - `drm_classify_target()` — линейный поиск по имени в `interfaces[]` - `drm_update_used_states()` — обновляет `operstate` только для `routes[]` - `drm_get_states(drm, states, count)` — снимок текущих состояний `routes[]` перед обновлением - `drm_handle_state_changes(drm, old_states, old_count)` — сравнивает старые и новые; при изменении — `drm_update_route_on_state_change()` (flush + новый маршрут) --- ## 9. Обработчик сигналов: `src/signal_handler.c` `signal_mgr_t { sig_fd, timer_fd }` — один контейнер для `signalfd` и debounce-`timerfd`. ### `signal_mgr_init(m)` - `sigprocmask(SIG_BLOCK, ...)` повторно (`main` уже блокирует) — защита - `signalfd(SFD_CLOEXEC)` → `m->sig_fd` - `timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC)` → `m->timer_fd` ### Прочие функции - `signal_mgr_close(m)` — close обоих `fd` - `signal_mgr_arm_timer(m, seconds)` — one-shot через `timerfd_settime` - `signal_mgr_read_timer(m)` — `read()` expirations ### Логика обработки (`main.c`, epoll loop) **`SIGUSR1`:** - `timer_active=false`: `perform_update()` (при `g_drm_active` — `drm_get_states` + `drm_update_used_states` + `drm_handle_state_changes`; всегда — `apply_unified_connmark_rules`; при `g_l7_active && g_l7_wan[0]` — `l7_firewall_install`); `signal_mgr_arm_timer(5)`; `timer_active=1` - `timer_active=true`: `pending_update=1` **Debounce timer expired:** - `signal_mgr_read_timer`; если `pending_update=1` → повторный `perform_update()`; сброс `timer_active=0` и `pending_update=0` **`SIGINT`/`SIGTERM`:** `g_shutdown=1` → выход из epoll loop → cleanup. --- ## 10. Файл конфигурации: `src/config.c` + `src/params.c` Формат: `key=value`, комментарии `#`, пустые строки игнорируются. `GeoIPFile` и `GeoSiteFile` могут повторяться (до `MAX_GEO_FILES=16`). `PolicyOrder` — через запятую, до `MAX_POLICY_ORDER=64`. Описание параметров — таблица `PARAMS[]` в `src/params.c` (`param_def_t`: `config_key`, `cli_flag`, `type`, offset-ы в `config_t` и `cli_args_t`, `set_bit`, `default_int`, `help_arg`, `help_text`, `help_default`). Один ряд на параметр, драйвит `config_read`, `args_parse`, `args_apply`, `print_help`, `config_generate`. Типы: `PT_BOOL`, `PT_INT`, `PT_INT_POS`, `PT_STRING`, `PT_PATH`, `PT_REPEAT_PATH`, `PT_POLICY_ORDER`. ### `config_read(path, cfg)` - `memset(cfg, 0)` + применение `default_int` / `help_default` для каждого `PARAMS[i]` - `fopen(path)`; при ошибке возвращает `-1` (но `main` продолжит, если путь дефолтный) - `getline` по строкам, `key=value` через `apply_kv(cfg, p, val)` ### `config_generate(target)` - `target` пустой → `hrneo.conf` рядом с бинарём (`readlink /proc/self/exe → dirname`) - `target`-каталог (или со слешем) → `/hrneo.conf` - `target`-файл → записывается ровно по пути - Записывает все 27 ключей с дефолтами; пустые multi-value ключи как `Key=` > Полное описание ключей и поведения — в `docs/HRNEO.CONF.md`. ### Формат watchlist (`domain.conf`) ``` домен1,домен2,.суффикс,geosite:TAG/ПолитикаИлиИнтерфейс ``` Пример: `googlevideo.com,youtube.com,geosite:google/HydraRoute` ### Формат CIDR (`ip.list`) ``` ##Описание блока (необязательно) /ПолитикаИлиИнтерфейс 103.224.0.2/32 104.16.0.0/12 geoip:ru ##Отключённый блок #/ПолитикаИлиИнтерфейс 45.67.123.19/32 ``` #### Синтаксис блоков - Активный блок начинается с `/ИмяПолитики` и завершается: пустой строкой, строкой `##...`, новым `/...` или новым `#/...` - `##...` — одновременно комментарий и терминатор текущего блока - `/...` — заголовок нового активного блока и терминатор предыдущего - `#/...` — заголовок нового отключённого блока; записи внутри игнорируются - Пустая строка — терминатор блока #### Автоматически добавляемый раздел при превышении лимита ipset ``` ##impossible to use #/Too-big-geoip-tag geoip:ru ``` --- ## 11. CLI аргументы: `src/args.c` ### `cli_args_t` (`include/args.h`) | Поле | Назначение | |------|------------| | `config_path` `char[512]` | путь к конфигу (`--config`); пусто = использовать `DEFAULT_CONFIG_PATH` | | `genconfig_target` `char[512]` | путь для `--genconfig` | | `set_mask` `uint32_t` | битовая маска: по одному биту на каждый параметр | | зеркало всех полей `config_t` | для каждого `PARAMS[i]` | ### `args_parse(argc, argv, out)` - `memset(out, 0)` в начале - `--version`/`-v`: `printf "hrneo vVERSION"`, возвращает 1 - `--help`/`-h`: выводит справку, возвращает 2 - `--config `: парсит путь, продолжает - `--genconfig [path]`: возвращает 3 (`main → config_generate`) - Для всех остальных флагов — поиск по `PARAMS[]`; неизвестный → `"unknown option"`, `return -1` - `apply_cli_value()` по типу параметра: `PT_BOOL` (`"true"`/`"false"`), `PT_INT/POS` (`strtol`), `PT_STRING/PATH` (`strncpy`), `PT_REPEAT_PATH` (накапливает в массив), `PT_POLICY_ORDER` (`strtok_r` по запятой) - При успехе `set_mask |= p->set_bit` - Возвращает `0` (успех), `1` (`--version`), `2` (`--help`), `3` (`--genconfig`), `-1` (ошибка) ### `args_apply(args, cfg)` - Для каждого `PARAMS[i]` с `set_mask & p->set_bit` — копирует поле из `args` в `cfg` - `PT_REPEAT_PATH` / `PT_POLICY_ORDER` заменяют массив полностью > Полный список флагов — `docs/HRNEO.CONF.md`. --- ## 12. GeoSite и GeoIP: `src/geodat.c` ### Типы данных ```c geoip_entry_t { ip[16], prefix uint32, ip_len uint8 } geosite_domain_t { type uint32, value char* } ``` ### `geosite_domain_t.type` | Значение | Тип | Поведение | |----------|-----|-----------| | `0` | Plain (keyword) | пропускается с `[WARN]` | | `1` | Regex | пропускается с `[WARN]` | | `2` | Domain (домен + поддомены) | `ht_insert` с `match_subs=1` | | `3` | Full (только точное имя) | `ht_insert` с `match_subs=0` | ### Парсинг `.dat`-файлов - Формат: v2ray/xray protobuf, потоковое чтение (`setvbuf 64КБ`) - `read_varint()` / `read_varint_stream()` для streaming - `scan_dat_file(file, target_upper, visitor, ctx)` — generic stream-сканер (visitor-pattern: `count_geoip_visitor` / `extract_geoip_visitor` / `extract_geosite_visitor`) - `extract_geoip_cidrs(file, country)` — полный скан до EOF - `extract_geosite_domains(file, tag)` — полный скан до EOF - `parse_geoip_body()`, `parse_cidr_body()`, `parse_geosite_body()`, `parse_geosite_domain()` — ручной protobuf-парсинг ### `deduplicate_domains(domains, count)` 1. Фильтрует только `Type=2` и `Type=3` 2. Сортирует по количеству точек (от меньшего к большему — root → leaf) 3. Удаляет поддомены через временную хеш-таблицу: если домен суффикс уже принятого — отбрасывается ### `parse_geosite_rules(watchlist_path, rules, max_rules)` - Читает `domain.conf` через `getline()`, собирает все `geosite:`-записи - Возвращает `geosite_rule_t[]` (`tag + policy_name`) ### `build_geosite_domain_map(filePaths, fileCount, rules, ruleCount, ht)` - Для каждого `rule.tag` обходит все `filePaths`, объединяет домены, `deduplicate` - `Type=2`: `ht_insert(val, match_subs=1)` - `Type=3`: `ht_insert(val, match_subs=0)` - `ht_insert` не перезаписывает существующие → приоритет у `domain.conf` ### `add_cidr_to_ipsets(mgr, cidr_path, pairs, pair_count, enable_timeout, timeout, geoip_files, geoip_count, maxelem)` `effective_limit = (maxelem > 0) ? maxelem : IPSET_DEFAULT_MAXELEM (262144)`. #### Фаза 1 (пресканирование CIDRfile при `geoip_count > 0`) - `scan_cidrfile_blocks(verbose=0)` + `phase1_on_entry` - Собирает все уникальные `geoip:TAG` из активных блоков - Для каждого нового тега → `count_geoip_cidrs_all_files()` → `geoip_tag_count_t` кэш - **Oversized:** `tag.ipv4 > effective_limit || tag.ipv6 > effective_limit` - При oversized → `LOG_WARN` + `cidrfile_migrate_oversized()` → `LOG_INFO` #### `cidrfile_migrate_oversized(path, oversized[], count)` - Читает строки через `getline()` (до `CIDR_MIGRATE_MAX_LINES=16384`), сохраняет `strdup` в массив `cidr_line_t` - **Pass 1:** присваивает `block_id`, помечает oversized geoip-строки `keep=0`, считает активные записи на блок - **Pass 2:** блоки без активных записей → заголовок `keep=0`, предшествующие `##` и пустые строки `keep=0` - Записывает `keep=1` в `.tmp`, дописывает секцию: ``` ##impossible to use #/Too-big-geoip-tag geoip: ... ``` - `rename(.tmp → path)` атомарно #### Фаза 2 (основная) - `ipset_refresh_set_list()` + cache `timeout` для всех пар - `scan_cidrfile_blocks(verbose=1)` + `phase2_on_entry` - Блок активен если `ipset_set_exists()` для ipv4 ИЛИ ipv6 имени - Oversized теги пропускаются - Для каждого `geoip:TAG`: cumulative check `usage[v4_target].count + cached_ipv4 > effective_limit` → `LOG_WARN` + `allow_v4=0`; аналогично IPv6 - GeoIP-записи и статические CIDR группируются в `batch_t` по `target_set` (`batch_find_or_add` через open-addressed FNV-1a индекс, `NAME_INDEX_SLOTS=256`) - Статические CIDR: `usage[target_set].count + 1 > effective_limit` → одно `LOG_WARN` (`warned` флаг), пропуск - Все батчи отправляются через `ipset_add_batch(with_timeout=0)` → постоянные записи `usage[]`/`batches[]` адресуются через open-addressed FNV-1a индексы (`NAME_INDEX_SLOTS=256`) — заменяет `O(n)` линейный скан при большом числе целей. --- ## 13. RCI (Remote Configuration Interface) Keenetic: `src/rci.c` ### Архитектурное решение hrneo взаимодействует с роутером Keenetic **исключительно через RCI HTTP/JSON API** на `127.0.0.1:79`. **НЕ используются:** `ndmc`, `ndmq`, `curl`, `wget`, `jq`, `python` и любые другие userspace-утилиты роутера. Весь HTTP-клиент и JSON-парсер — самописные, целиком в `src/rci.c` (~262 строки). Это: - убирает зависимость от наличия и версий системных утилит на роутере - устраняет `fork`/`exec` на каждое обращение (важно на `SIGUSR1`, где `apply_unified_connmark_rules` может делать до 5 запросов подряд) - сохраняет совместимость со статической сборкой (никаких `libcurl`/`cJSON` в `LIBS`) - даёт предсказуемые таймауты через `SO_RCVTIMEO`/`SO_SNDTIMEO` ### Константы (`include/rci.h`) | Константа | Значение | Назначение | |-----------|----------|------------| | `RCI_PORT` | `DEFAULT_API_PORT` (79) | захардкожен, параметра конфига нет | | `RCI_MAX_RESPONSE` | `1024 * 1024` (1 МБ) | буфер выделяется через `malloc` при `rci_client_init` | | `POLICY_API_MAX_RETRIES` | `5` | попыток на `GET /rci/show/ip/policy/` | | `POLICY_API_RETRY_DELAY` | `3` (секунды) | интервал между попытками | | `RCI_TIMEOUT_SEC` | `10` | `SO_RCVTIMEO` и `SO_SNDTIMEO` | ### `rci_client_t { raw_buf, response_buf }` - `raw_buf` — сырой HTTP-приём (`RCI_MAX_RESPONSE + 4096`; +4096 для заголовков) - `response_buf` — буфер тела ответа после парсинга (`RCI_MAX_RESPONSE`) - Оба выделяются в `rci_client_init()` и переиспользуются весь lifetime демона. Никаких `malloc` при `SIGUSR1` → нет фрагментации heap, нет latency `rci_client_close`: `free` обоих буферов. ### Сетевой клиент #### `rci_connect()` - `socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0)` - `SO_RCVTIMEO` / `SO_SNDTIMEO` = 10 секунд (отдельно для send и recv) - `connect` к `127.0.0.1:79` (`INADDR_LOOPBACK` через `htonl`) - HTTP/1.0 `Connection: close` (по умолчанию) — каждый запрос = новое TCP-соединение, ответ читается до EOF (закрытия сокета сервером) #### `rci_request(c, method, path, body, body_len, response, response_max)` 1. `rci_connect`; при неудаче — `LOG_ERROR` + `return -1` 2. Формирование HTTP-заголовка через `snprintf` (не `fork+printf`): ```http HTTP/1.0 Host: localhost Content-Type: application/json # только при body Content-Length: # только при body ``` 3. `send` заголовка; при `body` — отдельный `send` цикла `body_len` байт 4. `recv` цикл в `c->raw_buf` (до `RCI_MAX_RESPONSE+4096-1` байт или EOF/0) 5. Парсинг ответа: - `strstr("\r\n\r\n")` — граница заголовков и тела - `strncmp(raw, "HTTP/", 5)` — sanity-check - `strchr(raw, ' ') + atoi` — код статуса; не 200 → `LOG_ERROR` + `return -1` 6. `memcpy` тела в `response` (обрезка до `response_max-1`) ### Парсер JSON ответа `/rci/show/ip/policy/` Ответ имеет стабильный формат (политики Keenetic — простая структура, безопасно парсить вручную): ```json { "HydraRoute": {"description":"...", "mark":"0x21", ...}, "RU": {"description":"...", "mark":"0x22", ...} } ``` #### `find_matching_brace(open_brace)` — сканер с подсчётом фигурных скобок - `depth++` на `{`, `depth--` на `}`; возврат при `depth==0` - `in_string`-флаг переключается на не-экранированной `"`; внутри строки скобки игнорируются (защита от литералов вида `"value with {}"`) - Не полноценный JSON-парсер (нет обработки escape-последовательностей внутри значений, нет валидации синтаксиса); достаточно для формата RCI #### `rci_get_policies(c, policies, max_policies)` 1. `rci_request GET /rci/show/ip/policy/` 2. Поиск первого `{` (root) 3. Цикл по парам `{key: {...}}`: - `strchr('"')` / `strchr('"')` — извлечение имени политики (`key`) - `strchr('{')` + `find_matching_brace` — границы объекта - В объекте: `strstr("\"mark\"")` → `strchr(':')` → `strchr('"')` → `strchr('"')` — извлечение значения `mark` - Префикс `"0x"`/`"0X"` в начале значения удаляется (для прямой подстановки в `--set-xmark`) - `policy_mark_t { name[MAX_POLICY_NAME=64], mark[16] }` заполняется - `LOG_DEBUG "RCI policy: %s mark=0x%s"` 4. Возврат `count` политик Политики **БЕЗ** ключа `"mark"` (например, только что созданные роутером, у которых ещё не назначен идентификатор маркировки) пропускаются — будут дозаявлены на следующей итерации retry-loop. #### `rci_get_policies_with_retry(c, policies, max_policies)` - До `POLICY_API_MAX_RETRIES=5` попыток с интервалом `POLICY_API_RETRY_DELAY=3` секунды; `LOG_WARN` при неудаче, `LOG_ERROR` при исчерпании - Этот wrapper защищает от транзитных проблем сети/перегрузки `ndmsv`; если roundtrip <10s — все 5 попыток успеют до общего timeout ### Создание политик #### `rci_create_policies(c, names, count)` 1. Формирование тела `POST` вручную: ```json [{"parse":"ip policy "}, {"parse":"ip policy "}, ...] ``` (массив до 4 КБ; ~50 байт на политику, до ~80 политик в одном запросе). Команда `parse` эквивалентна вводу строки в CLI Keenetic — `ip policy ` создаёт пустую политику если её нет, no-op если есть. 2. `POST /rci/` с этим body 3. `LOG_INFO "Policy creation commands executed"` 4. Безусловный вызов `rci_save_config()` — иначе изменения не сохранятся в startup-config роутера и пропадут при перезагрузке #### `rci_save_config(c)` - `POST /rci/` с `{"system":{"configuration":{"save":true}}}` - Эквивалент `system configuration save` в CLI ### Интеграция с остальным кодом #### `main.c` (порядок старта) - 14. `rci_client_init(&g_rci)` — однократное выделение буферов - 15. `rci_create_policies(&g_rci, policy_names, policy_count)` — создание политик для всех целей-политик (интерфейсы DirectRoute сюда не попадают) - 20. `apply_unified_connmark_rules(&g_rci, ...)` — первое применение правил #### `iptables.c::apply_unified_connmark_rules` — вызывается при старте и на `SIGUSR1` Шаг 2: retry loop (до 5, sleep 4s) — внутри `rci_get_policies_with_retry` + проверка наличия `mark` для каждой не-interface цели. Если хотя бы у одной политики `mark` пустой (только что создана, роутер ещё не назначил `markID`) → повтор всего блока через 4 секунды. Двухуровневая защита: `rci_get_policies_with_retry` ловит сетевые ошибки/таймауты, apply-loop ловит «политика создана, но без `markID`». Если после всех попыток `markID` не появился — `LOG_WARN "Policy %s has no mark ID, skipping"`. Цель пропускается в этой итерации правил, но `ipset` продолжает заполняться DNS/L7-каналами. На следующем `SIGUSR1` цикл повторяется. > **Главное правило:** hrneo не молчит при проблемах с RCI, но и не валится — при недоступности роутера демон продолжает работать в degraded-режиме (`ipset` заполняется, конкретные политики временно без CONNMARK-правил). ### Системные требования - Демон запущен от root (доступ к `loopback:79` + локальная аутентификация NDM, для root прозрачна) - RCI на роутере включён (на всех современных прошивках Keenetic v3+ — да, по умолчанию) --- ## 14. Netlink Conntrack: `src/conntrack.c` `conntrack_mgr_t { fd }` — long-lived `NETLINK_NETFILTER` сокет (init однократно в `main`, закрывается при выходе). ### `conntrack_flush_for_ips(m, new_ips, count)` 1. Разбивает на `ipv4_set[64][16]` и `ipv6_set[64][16]` по семейству 2. Для непустых множеств вызывает `ct_flush_family(m->fd, ...)` ### `ct_flush_family(fd, family, targets, target_count, ip_len)` 1. `IPCTNL_MSG_CT_GET + NLM_F_DUMP` — все conntrack-записи семейства 2. Для каждой записи: `ct_extract_orig_tuple()` → dst IP → `memcmp` с `targets` 3. `ct_delete_entry()` при совпадении (`IPCTNL_MSG_CT_DELETE` с тем же `orig tuple`); счётчик удалений в `LOG_DEBUG` ### Прочие функции - `ct_extract_orig_tuple()` — ищет `CTA_TUPLE_ORIG` (маска `NLA_TYPE_MASK=0x7FFF`) - `ct_extract_dst_ip()` — из `CTA_TUPLE_IP` → `CTA_IPV4_DST`/`CTA_IPV6_DST` --- ## 15. Логирование: `src/log.c` Глобально: `int log_enabled`, `static int log_syslog`, `static FILE *log_fp`. Сборщика статистики (`monitor_stats_t`) нет. ### Уровни логирования (макросы в `include/log.h`) - `[DEBUG]` / `[INFO]` / `[MATCH]` / `[PROCESSED]` / `[FILTERED]` — выводятся только при `log_enabled=1` - `[WARN]` / `[ERROR]` — выводятся всегда; если `log_fp=NULL` → `stderr`; при `log_syslog=1` → `vsyslog(LOG_INFO)` ### `log_setup(cfg)` | Значение `log` | Поведение | |----------------|-----------| | `console` | `log_fp=stdout`, `log_enabled=1` | | `file` + `log_file_path[0]!=0` | `mkdir_p` + `fopen(append)`, `log_enabled=1`; пустой путь → `log_enabled=0`, `return 0` | | `syslog` | `openlog("hrneo", LOG_PID|LOG_NDELAY, LOG_DAEMON)`; `log_syslog=1`, `log_enabled=1` | | default (`off` и любое другое) | `log_fp=NULL`, `log_enabled=0` | `log_close()`: `fclose()` если `log_fp` не `stdout`/`stderr`; `closelog()` если `log_syslog=1`. ### О счётчиках L7 L7-диспетчер содержит `static uint64_t`-счётчики (`stat_too_short`, `malformed`, `not_tcp`, `wrong_flags`, `empty`, `wrong_port`, `not_tls`, `tls_no_sni`, `tls_matched`, `not_http`, `http_no_host`, `http_matched`, `bogon`, `disabled`, `reasm_started`, `reasm_completed`) в `src/l7_dispatch.c` — инкрементируются на hot path. Поля `stat_started`/`completed`/`evicted`/`expired`/`drop_gap`/`too_big`/`retransmit` есть и в структуре `tcp_reasm_t` (`src/tcp_reasm.c`) — также инкрементируются. Однако функция `l7_dispatch_dump_stats()` (единственное место, где какие-либо счётчики читаются и выводятся в `LOG_INFO`) **НИГДЕ** не вызывается из основного кода — объявлена в API заголовка, не интегрирована ни в loop, ни в обработчики сигналов. Поля `tcp_reasm_t.stat_*` не читаются вообще нигде (`dump_stats` работает только со static-счётчиками `l7_dispatch.c`, не с `g_reasm`). > В текущей версии счётчики — мёртвый код, запас под будущую диагностику. --- ## 16. Система сборки: Makefile **Версия:** 3.11.0-1 **Язык:** C (без CGO, без внешних библиотек) ### Кросс-компиляция | Цель | Компилятор | Флаги | |------|------------|-------| | `mipsel` | `mipsel-linux-gnu-gcc` | `-march=mips32r2 -mtune=1004kc -EL -mno-check-zero-division -mno-shared -mno-plt`, static | | `mips` | `mips-linux-gnu-gcc` | `-march=mips32r2 -mtune=1004kc -mno-check-zero-division -mno-shared -mno-plt`, static | | `aarch64` | `aarch64-linux-gnu-gcc` | `-march=armv8-a -mno-outline-atomics -fno-exceptions`, static + `-Wl,--strip-all -Wl,-z,norelro` | | `native` | `gcc` | dynamic linking | ### Общие флаги ``` -Os -Wall -Wextra -Wno-unused-parameter -ffunction-sections -fdata-sections -fno-unwind-tables -fno-asynchronous-unwind-tables -fomit-frame-pointer -fno-strict-aliasing ``` **Линковка:** `-Wl,--gc-sections -s`; static: `-static -static-libgcc`. Макрос `VERSION` передаётся через `-DVERSION`. 23 исходных файла (`src/*.c`), заголовочные в `include/`. Никаких `LIBS`/`LDFLAGS` для L7 — `NFQUEUE` через стандартные kernel-заголовки ``. ### Целевые платформы - `mipsel-3.4` (linux/mipsle) - `mips-3.4` (linux/mips) - `aarch64-3.10` (linux/arm64) --- ## 17. Интеграция с Keenetic (сборка IPK) - **Init-скрипт:** `/opt/etc/init.d/S99hrneo` — стандартный Entware init (`rc.func`), `ENABLED=yes`, `PROCS=hrneo`, `PIDFILE=/var/run/hrneo.pid` - **Netfilter hook:** `/opt/etc/ndm/netfilter.d/015-hrneo.sh` — тонкий хук: читает `/var/run/hrneo.pid`; если процесс живёт в `/proc` — `kill -USR1` - **Symlink:** `/opt/bin/neo` → `/opt/etc/init.d/S99hrneo` (создаётся в `postinst`) - **postinst:** вставляет `[ $ACTION = start ] && sleep 10` в `rc.unslung` перед запуском, чтобы дать Keenetic поднять интерфейсы (извините, но это решает кучу проблем в т.ч. для другого софта...) - **aarch64-3.10:** бинарь дополнительно сжимается через `upx --best` - **Зависимости ipk:** `libc`, `ipset`, `iptables`, `ip-full` - **conffiles:** `/opt/etc/HydraRoute/{hrneo.conf, domain.conf, ip.list}` - В пакете: минимальный `hrneo.conf` (`log=off`, `logfile=...`, `PolicyOrder=HydraRoute`; остальные ключи возьмут встроенные дефолты), стартовый `domain.conf` (Youtube/Google/Telegram/AI/Other/2ip), `ip.list` с CIDR Telegram --- ## 18. L7-перехват: TLS SNI / HTTP Host / TCP-реассамблеция Второй источник имён хостов параллельно DNS-каналу. Активируется `l7CaptureEnabled=true`. Закрывает слепые зоны DNS-only схемы: клиенты с DoH/DoT/DoQ, hardcoded-IP TLS, легаси-HTTP, тёплый DNS-кэш устройства. ### 18.1 Цепочка ``` [Клиент LAN→WAN TCP 443/80] → iptables/ip6tables mangle/POSTROUTING -o WAN -p tcp --dport 443|80 --tcp-flags SYN,ACK ACK -m connbytes 2:N -m length 60: -j NFQUEUE → nfq_capture (raw NETLINK_NETFILTER, ACCEPT verdict) → l7_dispatch_packet (fail-fast IP/TCP/dport) → probe_tls/probe_http (stateless парсеры) + tcp_reasm (фаза 2) → bogon_check → process_hostname_event_l7 → общий путь DNS-канала (match_domain_with_cname → ipset_add_batch → conntrack_flush) ``` Логи различают источник тегом: `[DNS]` / `[TLS-SNI]` / `[HTTP-Host]`. ### 18.2 Модули - **`src/probe_tls.c`:** `tls_quick_check` (`d[0]=0x16, d[1]=0x03, d[2]<=0x03, d[5]=0x01`), `tls_extract_sni` (record→handshake→ext→SNI type 0, partial-OK, lowercase). - **`src/probe_http.c`:** case-insensitive `"\nHost:"`, порт обрезается, IPv6-литерал `[::1]` поддержан. - **`src/bogon.c`:** служебные IPv4 (`0/8, 10/8, 127/8, 169.254/16, 172.16/12, 192.168/16, >=224`) и IPv6 (`ff00::/8, fc00::/7, fe80::/10, ::, ::1, ::ffff:0:0/96`). - **`src/nfq_capture.c`:** свой NFQUEUE-клиент без `libnetfilter_queue` (`NFNL_SUBSYS_QUEUE=3`, `PF_BIND`→`CMD_BIND`→`PARAMS`→`FLAGS`, `recv MSG_DONTWAIT`, `NFQNL_MSG_VERDICT=NF_ACCEPT`). `nfq_capture_t { fd, qnum, seq, portid, callback, user_data, stat_recv/pass/err, recv_buf[NFQ_RECV_BUF_SIZE=128KB] }`. Защита от `ENOBUFS` (`stat_err++` + `LOG_WARN`). - **`src/l7_firewall.c`:** `l7_firewall_resolve_wan` (config + `stat /sys/class/net`, иначе `/proc/net/route Destination==00000000`), `l7_firewall_load_kmod` (`init_module(2)`, нет `modprobe` на Keenetic), `install/remove` (`fork+exec iptables`, `-C ... || -A` для идемпотентности; `-D` в цикле). Для `dport 80` `connbytes_max` ужимается до `min(N, 4)`. - **`src/l7_dispatch.c`:** каскад + `try_tls_extract` (fast-path / reasm), `l7_dispatch_set_enable` / `set_reasm`, `dump_stats`. - **`src/tcp_reasm.c`** (фаза 2): 5-tuple хеш (`TCP_REASM_BUCKETS=64`), пул `calloc-on-init` (`l7TcpReasmMaxEntries × TCP_REASM_BUF_SIZE=16KB`), `start/feed/complete/get/destroy/gc`, seq-упорядочивание (gap→drop, retransmit→no-op), LRU-eviction, `timerfd` GC (TTL `l7TcpReasmTtlSec`). Поля счётчиков (`stat_started`/`completed`/`evicted`/`expired`/`drop_gap`/`too_big`/`retransmit`) есть в структуре `tcp_reasm_t` и инкрементируются на hot path, но в текущей версии нигде не читаются и не выводятся (`l7_dispatch_dump_stats` оперирует своими статическими счётчиками, не полями `g_reasm`; сама `l7_dispatch_dump_stats` нигде не вызывается из основного кода — функция объявлена в API заголовка, но не интегрирована в loop/сигналы). Фактически это запас под будущую диагностику; на работу демона не влияет. ### 18.3 Архитектурные решения - Вся системная логика в C-демоне; shell-хук `015-hrneo.sh` «тонкий» (только `kill -USR1`). Нет «зомби-скриптов» при остановленном демоне. `S99hrneo` минимальный. - Один NFQ-сокет; диспетчер разводит по `dport`. - Идемпотентность к DNS: `ipset_add_batch` с `NLM_F_EXCL` → двойное добавление IP no-op. Два источника (DNS + L7) безопасно пересекаются. - **GRO coalescing:** на роутере с GRO ядро склеивает TCP-сегменты до netfilter → NFQUEUE видит CH целиком даже Kyber-размера → fast-path. Реассамблеция фазы 2 — страховочная сетка (GRO off / разные CPU / MSS-clamp / PMTU-дробление). ### 18.4 Известные gaps (НЕ реализовано) - **QUIC / HTTP-3 (UDP/443)** — значимо для Apple-устройств (Safari/iCloud/Push активно используют h3) - **ECH (Encrypted ClientHello)** — нерешаемо без MITM - **iCloud Private Relay** — зашифрованный туннель, SNI релея (by design не наш) --- ## 19. Реализованные оптимизации > Бо́льшая часть сведений потеряна т.к. не документировалась. - **Однопоточная event-driven архитектура.** epoll-цикл: `cap.fd4` + `cap.fd6` + `signals.sig_fd` + `signals.timer_fd` + (опц.) `nfq_fd` + `reasm_gc_fd`. Без GC, без потоков, без каналов. - **Netlink вместо `fork`/`exec` для ipset.** Все `CREATE`, `FLUSH`, `ADD` через прямой netlink-сокет (`NETLINK_NETFILTER`, long-lived). - **Батчевая отправка ipset через netlink.** `ipset_add_batch()`: чанки по 256 — send N сообщений, затем recv N ответов. Kernel обрабатывает очередь параллельно с чтением ответов. - **`iptables-restore` для batch-правил.** `apply_unified_connmark_rules()`: все `CONNMARK`-правила одним вызовом `iptables-restore --noflush`. - **Хеш-таблица доменов с chunked pool.** 8192 бакетов, FNV-1a, цепочки. Chunked pool 256КБ с автоматическим расширением — ноды и строки в одном аллокаторе. Дедупликация `ipset_name` через `ipset_name_cache[]`. - **Суффиксный матчинг через хеш-таблицу.** Для каждой точки в домене проверяется parent-домен — `O(количество точек)`, каждая `O(1)` средний. - **Кэш ipset-списков и timeout.** `set_names[]` (cache `ipset list -n` при старте) + `set_has_timeout[256]` / `timeout_value[256]` по FNV-1a индексу. - **Open-addressed FNV-1a индексы.** В `geodat` для `batches[]` и `usage[]` (`NAME_INDEX_SLOTS=256`) — заменяет `O(n)` линейный поиск при большом числе целей. - **Debounce SIGUSR1.** `timerfd`: повторный `SIGUSR1` во время обработки откладывается на 5 секунд. - **Conntrack flush через netlink.** Long-lived netlink-сокет (init однократно). Один DUMP на семейство + DELETE по совпадению. Без `fork`/`exec`. - **Стриминговый парсинг .dat-файлов.** Потоковое чтение через `setvbuf(64KB)`. В памяти хранятся только извлечённые записи. Visitor-pattern (`scan_dat_file`). - **Статическая аллокация в hot path.** `dns_result_t` (static в `process_dns_packet`), `cname_entry_t` (static), `processed[]`, `ipv4_batch[]`, `ipv6_batch[]`, `all_new[]` — на стеке, без `malloc`. - **Unified targets.** `g_all_sorted[]` объединяет политики и интерфейсы в единый отсортированный массив. `apply_unified_connmark_rules()` обрабатывает все цели одним проходом. - **Дедупликация указателей `ipset_name`.** `ht_insert()` ищет существующий указатель через `ipset_name_cache[]` перед аллокацией нового. - **AF_PACKET SOCK_DGRAM + L3-BPF в ядре.** BPF-фильтры на сокетах через `SO_ATTACH_FILTER`. Ядро отбрасывает нерелевантные пакеты до копирования в userspace — только DNS-ответы достигают `process_dns_packet`. `SOCK_DGRAM` отдаёт пакет с IP-уровня единообразно для всех типов интерфейсов, поэтому фильтр работает по IP-версии/протоколу/порту без привязки к Ethernet-кадру. - **Контроль maxelem.** Предварительный подсчёт geoip-записей (`count_geoip_body` без аллокаций) + автомиграция oversized `geoip:TAG` в disabled-секцию `CIDRfile` (атомарно через `.tmp + rename`). - **Table-driven config.** `PARAMS[]` (`src/params.c`) — одно описание параметра обслуживает `config_read`, `args_parse`/`args_apply`, `print_help`, `config_generate`. - **L7 fail-fast каскад.** Длина → IP-версия → TCP → флаги → dport → 1-байтовая сигнатура TLS/HTTP → парсер. Реассамблеция запускается только для фрагментированных CH (fast-path для коротких). Один NFQ-сокет. --- ## Резюме **HRNeo v3.11.0-1** — компактный однопоточный policy routing демон для роутеров Keenetic, написанный на чистом C. Два источника имён хостов: - **DNS-канал** — перехват DNS-ответов через AF_PACKET SOCK_DGRAM + L3-BPF, два fd; работает на интерфейсах любого типа (Ethernet, PPP, ARPHRD_NONE, туннели), поэтому ловит DNS LAN- и VPN-клиентов одним кодом - **L7-канал** — TLS SNI / HTTP Host исходящих соединений через собственный NFQUEUE-клиент на raw netlink, при `l7CaptureEnabled`; фаза 2 — TCP-реассамблеция фрагментированных ClientHello Извлекает IP-адреса и добавляет в `ipset` через netlink, маркирует трафик в `iptables/mangle` для policy routing. Поддерживает маршрутизацию через политики Keenetic (mark через RCI API) и прямую на интерфейсы (`fwmark` + `ip rule` + `ip route`). GeoIP/GeoSite из `.dat` v2ray/xray с потоковым protobuf-парсингом. Event-driven архитектура на `epoll` (`cap.fd4` + `cap.fd6` + `signalfd` + `timerfd` + `nfq_fd` + `reasm_gc_fd`). QUIC/HTTP-3 — НЕ реализовано (gap для Apple/h3-трафика). **27 параметров конфига**, все доступны через CLI-флаги (`--flag value`) + `--config `, `--version`/`-v`, `--help`/`-h`, `--genconfig [path]`; приоритет: CLI > конфиг > дефолты. Описание параметров — единая таблица `PARAMS[]` в `src/params.c`, драйвит `config_read`, args, `--help`, `--genconfig`. **Оптимизирован:** батчевый netlink (send N / recv N), хеш-таблица доменов 8192 бакетов с chunked pool (256КБ чанки), unified targets, batch `iptables-restore`, debounce сигналов, conntrack flush через netlink с long-lived сокетом, статическая аллокация в hot path, двунаправленный CNAME BFS, BPF-фильтрация в ядре, `ipset CREATE` с автоматическим запросом kernel-revision, контроль `maxelem` с автомиграцией oversized `geoip:TAG` в disabled-секцию `CIDRfile`.