# 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>` | конец выгрузки; `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))
}
```