fix(admin): считать достижимым только тот адрес Traffic Stats API, который админка действительно опрашивает

Проверка принимала любой ip.IsLoopback(), то есть считала рабочим и 127.0.0.5.
Это неверно: слушатель на конкретном адресе принимает соединения только на него,
а слой proxy обращается строго к http://127.0.0.1:<port>.

  bind 127.0.0.5:38712  ->  dial 127.0.0.1:38712  ->  connection refused
  bind 0.0.0.0:38713    ->  dial 127.0.0.1:38713  ->  connected

Такой адрес выглядел локальным, ломал контур доступа целиком (лимит устройств
fail-closed => не подключается никто) и не вызывал у админки ни одного
возражения. Принимаются ровно 127.0.0.1, 0.0.0.0 и пустой хост.

IPv6-wildcard не принимается сознательно: соединение он принял бы, но HY2XS
объявлен IPv4-only, а зависеть в ответе «достучусь» от net.ipv6.bindv6only
нельзя.

На странице конфигурации мягкое состояние nonCanonicalLoopback убрано: прочий
loopback — это ошибка, а не предупреждение. Осталось три состояния: канон
профиля, wildcard, недостижим.

Свойство закреплено тестом с настоящими сокетами, а гейт приёмки запрещает
возврат IsLoopback() и требует негативного случая 127.0.0.5 в тестах.
This commit is contained in:
2026-09-03 03:53:46 +05:00
parent b315001288
commit b9d3c03f8d
11 changed files with 218 additions and 62 deletions
@@ -176,16 +176,46 @@ host === "" || host === "127.0.0.1" || host === "0.0.0.0"
поведение это не влияло (wildcard включает loopback, поэтому обмен состоится),
но описание контракта было ложным и исправлено.
**Как закрыто.** Backend не тронут: он по-прежнему отвечает ровно на вопрос
достижимости, и превращать лишнюю публикацию в отказ обслуживания нельзя — это
отключило бы всех пиров. Разделены понятия на стороне панели:
**Как закрыто.** Панель отвечает на оба вопроса раздельно, backend продолжает
отвечать только на вопрос достижимости превращать лишнюю публикацию в отказ
обслуживания нельзя, это отключило бы всех пиров.
| адрес | состояние | что показано |
| --- | --- | --- |
| `127.0.0.1` | `canonical` | без пометки |
| `0.0.0.0`, пустой хост | `wildcard` | предупреждение: API доступен, но слушает все интерфейсы |
| `127.0.0.x` | `nonCanonicalLoopback` | предупреждение: доступен, но оркестратор такого не создаёт |
| прочее | `unreachable` | ошибка: доступ пиров уже не работает |
| прочее, включая `127.0.0.5` | `unreachable` | ошибка: доступ пиров уже не работает |
### DIAG-01a — «прочий loopback» был не предупреждением, а отказом
Первая редакция этого же фикса завела мягкое состояние
`nonCanonicalLoopback` для адресов вида `127.0.0.5`, а backend принимал их
через `ip.IsLoopback()`. **Это неверно, и проверено экспериментом:**
```text
bind 127.0.0.5:38712 → dial 127.0.0.1:38712 → connection refused
bind 0.0.0.0:38713 → dial 127.0.0.1:38713 → connected
bind [::]:38714 → dial 127.0.0.1:38714 → connected
```
Слушатель на конкретном адресе принимает соединения только на него, а слой
proxy обращается строго к `http://127.0.0.1:<port>`. То есть `127.0.0.5`
выглядит «локальным», но control plane при нём уже не работает — и админка
молчала бы об этом, отказывая при этом всем пирам.
Проверка стала точной: принимаются ровно `127.0.0.1`, `0.0.0.0` и пустой хост.
IPv6-wildcard (`[::]`) не принимается, хотя эксперимент показал, что соединение
он принял бы: HY2XS объявлен IPv4-only, а достижимость такого слушателя зависит
от `net.ipv6.bindv6only`, которым продукт не управляет — отвечать «достучусь» на
основании чужого sysctl нельзя, а указанное в отказе `reconfigure` для этой
конфигурации всё равно верное действие.
Гейт приёмки закрепляет именно семантику: `ip.IsLoopback()` в файле запрещён,
обе принимаемые формы названы литералами, а негативный случай `127.0.0.5`
обязан присутствовать в тестах. Само свойство «bind на конкретный loopback не
принимает соединение на 127.0.0.1» зафиксировано отдельным тестом с настоящими
сокетами — иначе правило выглядит произвольным ужесточением, и следующий
читатель вернёт `IsLoopback()` обратно.
## ADM-HY2-02 — JSON-журнал не разбирался ни одной строкой
@@ -385,11 +385,22 @@ API только по loopback. ...Верните 127.0.0.1 через `hy2xs-or
| --- | --- | --- |
| `127.0.0.1:36712` | без пометки | канон production-профиля |
| `0.0.0.0:36712`, `:36712` | предупреждение | API достижим, но опубликован на всех интерфейсах; при `HY2XS_FIREWALL_MODE=external\|off` его не прикрывает ничто |
| `127.0.0.5:36712` | предупреждение | достижим, но оркестратор такого не создаёт — конфиг правили руками |
| `127.0.0.5:36712` | ошибка | **недостижим**, см. ниже |
| `192.168.1.10:36712` | ошибка | панель до него не достучится, доступ пиров уже не работает |
Пустой хост в `listen` — это не loopback: в Go `:36712` означает все интерфейсы,
ровно как `0.0.0.0`.
Две детали, на которых легко ошибиться:
- пустой хост в `listen` — это **не** loopback: в Go `:36712` означает все
интерфейсы, ровно как `0.0.0.0`;
- другой адрес loopback — это **не** «почти правильно». Слушатель на конкретном
адресе принимает соединения только на него:
```text
bind 127.0.0.5:36712 → dial 127.0.0.1:36712 → connection refused
```
Админка обращается к Traffic Stats API строго через `127.0.0.1`, поэтому
`127.0.0.5` ломает контур доступа так же, как LAN-адрес.
### Дашборд показывает «состояние службы неизвестно»
+1 -1
View File
@@ -47,7 +47,7 @@
27. живая сессия, которой в базе больше ничего не соответствует (пир удалён либо его `auth_id` заменён импортом, а разрыв в тот момент не удался), завершается очередным циклом учёта — не позднее 30 секунд
28. превышение `maxDevices` живыми сессиями устраняется тем же циклом: после неудавшегося разрыва при снижении лимита повтор формы даёт успех без `/kick`, и единственный механизм схождения здесь — cron
29. смена секрета пира меняет его `auth_id`: клиент со старым секретом теряет доступ не позднее 30 секунд даже в том случае, когда `/kick` прошёл успешно, а соединение зарегистрировалось после него
30. `trafficStats.listen` слушает `127.0.0.1`; конфиг с не-loopback адресом админка отвергает с явным сообщением, а не молча ходит на loopback. Страница конфигурации различает три состояния адреса: канон профиля, достижимый но опубликованный шире необходимого (`0.0.0.0`, пустой хост, прочий loopback) и недостижимый
30. `trafficStats.listen` слушает `127.0.0.1`. Админка принимает ровно три формы — `127.0.0.1`, `0.0.0.0` и пустой хост (тот же wildcard), — а любой другой адрес, **включая прочие адреса loopback вроде `127.0.0.5`**, отвергает с явным сообщением: слушатель на конкретном адресе соединения на `127.0.0.1` не принимает. Страница конфигурации показывает три состояния: канон профиля, достижим но опубликован шире необходимого (wildcard), недостижим
31. `hysteria-server.service` запущен с `HYSTERIA_DISABLE_UPDATE_CHECK=1`: внешних запросов проверки версии при старте нет
32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd
33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON