Ground-Zerro / HydraRoute Public
Code Issues Pull requests Actions Releases View on GitHub ↗
11.6 KB markdown
# 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))
}
```