# Watchlist API hrneo hrneo публикует загруженный в память watchlist (`domain.conf` + geosite) через Unix-сокет. Ответ берётся из той же хеш-таблицы, по которой hrneo маршрутизирует, поэтому потребителю не нужно разбирать `domain.conf`, geosite и `PolicyOrder` самому: ответы совпадают с поведением демона по построению. Реализация — `source/src/watchlist_api.c`, устройство — `docs/CORE.md`, раздел 20. План и решения — `docs/plans/WATCHLIST_API_PLAN.md`. --- ## 1. Сокет | Свойство | Значение | |---|---| | Путь | `/var/run/hrneo.sock` (`WATCHLIST_SOCKET`, без параметра конфига) | | Тип | `AF_UNIX`, `SOCK_STREAM` | | Права | `0600`, владелец — пользователь hrneo (root) | | Клиентов одновременно | 4; пятый получает `ERR busy`, соединение закрывается | | Длина строки запроса | до 511 байт вместе с `\n`; длиннее — соединение закрывается | - **Когда появляется.** Сокет создаётся последним шагом старта, когда `domain.conf`, geosite и ранги уже загружены. Успешный `connect` означает, что watchlist загружен полностью. Если на старте не отвечает RCI роутера, это может занять десятки секунд. - **Когда исчезает.** При штатной остановке файл удаляется. После аварийного завершения он может остаться — тогда `connect` вернёт `ECONNREFUSED`, а следующий запуск hrneo пересоздаст файл. - **Отзывчивость.** Сокет обслуживает отдельный поток hrneo. Ответы не ждут ни коммита netfilter (`iptables-restore`, запросы RCI с таймаутом до 10 с), ни обработки DNS-пакетов: MATCH отвечает за микросекунды в любой момент работы демона. --- ## 2. Протокол - Запрос — одна строка ASCII, завершённая `\n` (допускается `\r\n`). - Ответы приходят строго в порядке запросов. Запросы можно отправлять конвейером, не дожидаясь ответа на предыдущий. - Полузакрытие (`shutdown(SHUT_WR)`) допустимо: hrneo ответит на все полученные запросы и закроет соединение. Строка без `\n` в конце потока игнорируется. - Неизвестная команда — `ERR command`, соединение остаётся открытым. ### 2.1. `MATCH <имя>` — к какой цели относится имя | Ответ | Значение | |---|---| | `OK <цель> <ранг> <ключ>` | имя в watchlist: hrneo направит его адреса в цель `<цель>` | | `NO` | имя не в watchlist | | `ERR name` | пустое имя или длиннее 253 символов | ``` → MATCH Mail.Google.RU. ← OK RU 0 ru → MATCH api.telegram.org ← OK HydraRoute 1 telegram.org → MATCH example.org ← NO ``` Правила: - Имя приводится к нижнему регистру, одна конечная точка отбрасывается. - Проверяются само имя и все его родители: `a.b.c` → `a.b.c`, `b.c`, `c`. Из совпавших ключей выигрывает цель с **меньшим рангом**; длина совпавшего суффикса значения не имеет. В примере `google.ru` принадлежит `HydraRoute`, но выигрывает `ru` цели `RU` с рангом 0. - `<ключ>` — запись watchlist, через которую совпала выигравшая цель: само имя или его родитель. - CNAME не учитывается. Для DNS-ответа hrneo проверяет ещё и узлы цепочки CNAME, а MATCH знает только имя. Поэтому имя вне watchlist, у которого CNAME ведёт на домен из watchlist, MATCH вернёт как `NO`, хотя адреса такого ответа hrneo добавит в ipset цели. ### 2.2. `DUMP` — весь watchlist ``` → DUMP ← T 0 RU ← T 1 HydraRoute ← K ru 0 ← K google.ru 1 ← K telegram.org 1 ← … ← END 1897 ``` | Строка | Значение | |---|---| | `T <ранг> <цель>` | цель; строки идут по возрастанию ранга | | `K <ключ> <ранг>` | ключ watchlist и ранг его цели (ссылка на строку `T`); порядок произвольный (порядок бакетов хеш-таблицы) | | `END ` | конец выгрузки; `n` — число отправленных строк `K` | - Если строки `END` нет или число строк `K` не равно `n`, выгрузка оборвана. - Выгрузка идёт порциями по 8 КБ и не задерживает запросы других клиентов. Следующие запросы того же соединения ждут её окончания. - Объём — около 20 байт на ключ: конфиг тестового роутера (1 897 ключей) — 37 КБ, 76 тыс. ключей — около 1,5 МБ. - В `K` попадают ключи `domain.conf` и geosite (`Domain`/`Full`) без дублей: ключ принадлежит первой строке или тегу, где он встретился. --- ## 3. Ранг и цели - Ранг — целое 0…N−1, уникальное для каждой цели. Порядок: сначала цели из `PolicyOrder`, затем остальные по алфавиту. Это тот же порядок, что у правил CONNMARK. - Цель — политика Keenetic или, при `DirectRouteEnabled`, имя интерфейса. - Цели, у которых есть только CIDR из `ip.list`, а доменов нет, присутствуют в строках `T`, но в `K` и `MATCH` не встречаются. --- ## 4. Жизненный цикл данных - Watchlist меняется только перезапуском hrneo. SIGUSR1 его не перечитывает. - При перезапуске закрываются все соединения. Клиент, держащий соединение открытым, получает EOF — это сигнал, что состав и ранги могли измениться. - Нет файла сокета или `ECONNREFUSED` — hrneo не запущен или ещё загружается. --- ## 5. Порядок работы DNS-демона Демон держит одно постоянное соединение и спрашивает hrneo о каждом имени, на которое нужно выбрать upstream. Своей копии watchlist у демона нет. Своих правил (групп доменов) у демона может быть сколько угодно; при пересечении с watchlist выигрывает hrneo. ``` выбор upstream для имени Q: если соединение с hrneo открыто: отправить "MATCH Q", ждать ответ не дольше 100 мс "OK T …" → upstream цели T; группы демона не проверяются "NO" → перейти к группам таймаут, ошибка, EOF → закрыть соединение, считать hrneo не запущенным, перейти к группам группы: самый длинный суффикс Q среди ключей своих групп → upstream группы иначе → общий upstream соединение: открыть при старте демона; после разрыва — не чаще раза в 5 с, попыткой при очередном запросе или по таймеру ``` - **Таймаут — разрыв.** После таймаута соединение обязательно закрывается. Иначе опоздавший ответ будет прочитан как ответ на следующий запрос. - **Асинхронный демон** может не ждать ответа синхронно: отправить запрос, поставить Q в очередь ожидания и разобрать ответ, когда сокет станет читаемым. Ответы приходят в порядке запросов. - **Кэш MATCH не нужен** — ответ стоит один круг по Unix-сокету. Если кэш всё же есть, его нужно сбрасывать при разрыве соединения. - **hrneo не запущен** — работают только группы демона, имена из watchlist идут по общему пути. После переподключения hrneo снова выигрывает без перезапуска демона. - Что делать с целью, у которой нет своего upstream (например, правило без DNS), решает сам демон. MATCH лишь сообщает, что имя принадлежит цели, а значит группы для него не применяются. --- ## 6. CLI | Команда | Вывод | Код выхода | |---|---|---| | `hrneo --match <имя>` | строка ответа `MATCH` | 0 — `OK`, 1 — `NO`, 2 — `ERR`, нет демона, обрыв | | `hrneo --dump` | выгрузка `DUMP` | 0 — получена строка `END`, 2 — ошибка или обрыв | Конфиг не читается. Клиент подключается к `/var/run/hrneo.sock`, отправляет запрос, закрывает запись и выводит ответ до закрытия соединения. --- ## 7. Примеры Shell: ```sh if hrneo --match "$name" >/dev/null; then echo "$name — в watchlist"; fi hrneo --dump | awk '$1 == "K" { n++ } END { print n " ключей" }' printf 'MATCH vk.com\n' | socat - UNIX-CONNECT:/var/run/hrneo.sock ``` Go: соединение открыто заранее (`net.Dial("unix", "/var/run/hrneo.sock")`), `r := bufio.NewReader(conn)`. При ошибке вызывающий закрывает соединение и до переподключения считает hrneo не запущенным. ```go func match(conn net.Conn, r *bufio.Reader, name string) (target string, ok bool, err error) { conn.SetDeadline(time.Now().Add(100 * time.Millisecond)) if _, err = fmt.Fprintf(conn, "MATCH %s\n", name); err != nil { return "", false, err } line, err := r.ReadString('\n') if err != nil { return "", false, err } switch f := strings.Fields(line); f[0] { case "OK": return f[1], true, nil case "NO": return "", false, nil } return "", false, errors.New(strings.TrimSpace(line)) } ```