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
+16 -6
View File
@@ -347,12 +347,22 @@ Hysteria-интеграции с официальной документацие
странице конфигурации отвечал только на вопрос «достучится ли админка», странице конфигурации отвечал только на вопрос «достучится ли админка»,
поэтому `0.0.0.0` показывался без замечаний — хотя внутренний Traffic Stats поэтому `0.0.0.0` показывался без замечаний — хотя внутренний Traffic Stats
API при нём опубликован на всех интерфейсах, а оркестратор такой конфигурации API при нём опубликован на всех интерфейсах, а оркестратор такой конфигурации
не создаёт. Состояний теперь четыре: канон профиля, wildcard, не-канонический не создаёт. Теперь показываются три состояния: канон профиля, wildcard
loopback и недостижимый адрес; backend при этом по-прежнему отвечает только на (предупреждение) и недостижимый адрес (ошибка); backend при этом по-прежнему
вопрос достижимости — превращать лишнюю публикацию в отказ обслуживания отвечает только на вопрос достижимости — превращать лишнюю публикацию в отказ
значило бы отключить всех пиров. Заодно исправлено ложное утверждение в обслуживания значило бы отключить всех пиров. Заодно исправлено ложное
комментарии `parseTrafficStatsPort`: пустой хост `:36712` — это все утверждение в комментарии `parseTrafficStatsPort`: пустой хост `:36712` — это
интерфейсы, а не loopback. все интерфейсы, а не loopback.
- **«Какой-нибудь loopback» не означает «достижим по 127.0.0.1».** Проверка
принимала любой `ip.IsLoopback()`, то есть считала рабочим и `127.0.0.5`.
Слушатель на конкретном адресе принимает соединения только на него
(`bind 127.0.0.5` + `dial 127.0.0.1` = connection refused, проверено), а слой
proxy обращается строго к `http://127.0.0.1:<port>`: такой адрес выглядел
локальным, ломал контур доступа целиком, и админка об этом молчала.
Принимаются ровно `127.0.0.1`, `0.0.0.0` и пустой хост; свойство закреплено
тестом с настоящими сокетами, а гейт приёмки запрещает возврат `IsLoopback()`
и требует негативного случая в тестах.
### Исправлено — гейты сборки ### Исправлено — гейты сборки
+1 -3
View File
@@ -336,10 +336,8 @@ export default {
trafficStatsMissingHint: trafficStatsMissingHint:
"Without the trafficStats section the device limit, traffic accounting and forced disconnect do not work: the panel reaches Hysteria only through this API.", "Without the trafficStats section the device limit, traffic accounting and forced disconnect do not work: the panel reaches Hysteria only through this API.",
trafficStatsUnreachable: trafficStatsUnreachable:
"the panel cannot reach this address: the Traffic Stats API is queried over loopback only. While the addresses differ, the device limit, traffic accounting and forced disconnect do not work", "the panel cannot reach this address: the Traffic Stats API is queried through 127.0.0.1 only. While the addresses differ, the device limit, traffic accounting and forced disconnect do not work",
trafficStatsWildcard: trafficStatsWildcard:
"the API is reachable but listens on every interface. The HY2XS production profile uses 127.0.0.1: run hy2xs-orchestrator reconfigure", "the API is reachable but listens on every interface. The HY2XS production profile uses 127.0.0.1: run hy2xs-orchestrator reconfigure",
trafficStatsNonCanonical:
"the API is reachable but the address differs from 127.0.0.1 — the orchestrator never produces such a configuration",
}, },
}; };
+6 -6
View File
@@ -346,14 +346,14 @@ export default {
trafficStatsMissing: "Traffic Stats API не настроен", trafficStatsMissing: "Traffic Stats API не настроен",
trafficStatsMissingHint: trafficStatsMissingHint:
"Без секции trafficStats не работают лимит устройств, учёт трафика и принудительное отключение: панель обращается к Hysteria только через этот API.", "Без секции trafficStats не работают лимит устройств, учёт трафика и принудительное отключение: панель обращается к Hysteria только через этот API.",
// Три разные новости об одном поле, и объединять их нельзя: первая // Две разные новости об одном поле, и объединять их нельзя: первая
// означает, что доступ пиров уже не работает, вторая — что внутренний API // означает, что доступ пиров уже не работает, вторая — что API работает, но
// опубликован шире необходимого, третья — просто дрейф от профиля. // опубликован шире необходимого. Прочие адреса loopback (127.0.0.5 и
// подобные) относятся к ПЕРВОЙ: слушатель на конкретном адресе соединения
// на 127.0.0.1 не принимает.
trafficStatsUnreachable: trafficStatsUnreachable:
"панель не может обратиться по этому адресу: Traffic Stats API запрашивается только по loopback. Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают", "панель не может обратиться по этому адресу: Traffic Stats API запрашивается строго через 127.0.0.1. Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают",
trafficStatsWildcard: trafficStatsWildcard:
"API доступен, но слушает все интерфейсы. Production-профиль HY2XS использует 127.0.0.1: выполните hy2xs-orchestrator reconfigure", "API доступен, но слушает все интерфейсы. Production-профиль HY2XS использует 127.0.0.1: выполните hy2xs-orchestrator reconfigure",
trafficStatsNonCanonical:
"API доступен, но адрес отличается от 127.0.0.1 — оркестратор такой конфигурации не создаёт",
}, },
}; };
@@ -323,11 +323,6 @@
class="value-warning" class="value-warning"
>{{ $t("hysteria.trafficStatsWildcard") }}</span >{{ $t("hysteria.trafficStatsWildcard") }}</span
> >
<span
v-else-if="trafficStatsListenState === 'nonCanonicalLoopback'"
class="value-warning"
>{{ $t("hysteria.trafficStatsNonCanonical") }}</span
>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="trafficStats.secret"> <el-descriptions-item label="trafficStats.secret">
<el-tag <el-tag
@@ -460,14 +455,14 @@ function list(values: string[]): string {
* *
* Пустой хост (`:36712`) относится сюда же: в Go это тот же wildcard, а не * Пустой хост (`:36712`) относится сюда же: в Go это тот же wildcard, а не
* loopback. * loopback.
*
* Прочие адреса loopback — `127.0.0.5` и подобные — это НЕ «слегка не тот
* адрес», а недостижимость: слушатель на конкретном адресе принимает
* соединения только на него, поэтому `bind 127.0.0.5` + `dial 127.0.0.1` даёт
* connection refused. Панель обязана называть это ошибкой, а не
* предупреждением: control plane при таком адресе уже не работает.
*/ */
type TrafficStatsListenState = type TrafficStatsListenState = "canonical" | "wildcard" | "unreachable";
| "canonical"
| "wildcard"
| "nonCanonicalLoopback"
| "unreachable";
const LOOPBACK_IPV4 = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
const trafficStatsListenState = computed<TrafficStatsListenState>(() => { const trafficStatsListenState = computed<TrafficStatsListenState>(() => {
const listen = profile.value.trafficStats?.listen; const listen = profile.value.trafficStats?.listen;
@@ -486,9 +481,6 @@ const trafficStatsListenState = computed<TrafficStatsListenState>(() => {
if (host === "" || host === "0.0.0.0") { if (host === "" || host === "0.0.0.0") {
return "wildcard"; return "wildcard";
} }
if (LOOPBACK_IPV4.test(host)) {
return "nonCanonicalLoopback";
}
return "unreachable"; return "unreachable";
}); });
+28 -16
View File
@@ -143,8 +143,7 @@ func GetHysteria2ApiPort() (int64, error) {
// Что принимается: // Что принимается:
// //
// "127.0.0.1:36712" -> канонический production-профиль; // "127.0.0.1:36712" -> канонический production-профиль;
// "127.0.0.5:36712" -> любой loopback достижим по loopback; // "0.0.0.0:36712" -> wildcard принимает соединение на 127.0.0.1;
// "0.0.0.0:36712" -> wildcard включает loopback, обмен состоится;
// ":36712" -> тот же wildcard: пустой хост в Go означает ВСЕ // ":36712" -> тот же wildcard: пустой хост в Go означает ВСЕ
// интерфейсы, а не loopback. // интерфейсы, а не loopback.
// //
@@ -152,6 +151,24 @@ func GetHysteria2ApiPort() (int64, error) {
// РАБОТАЕТ: отказывать в обслуживании из-за лишней публикации внутреннего API // РАБОТАЕТ: отказывать в обслуживании из-за лишней публикации внутреннего API
// значило бы наказывать за неё отключением всех пиров. Сама лишняя публикация // значило бы наказывать за неё отключением всех пиров. Сама лишняя публикация
// при этом не остаётся незамеченной — о ней сообщает панель. // при этом не остаётся незамеченной — о ней сообщает панель.
//
// Прочие адреса loopback НЕ принимаются, и это не педантизм. Слушатель,
// привязанный к конкретному адресу, принимает соединения только на него:
//
// bind 127.0.0.5:36712 -> dial 127.0.0.1:36712 -> connection refused
//
// (проверено). То есть `ip.IsLoopback()` здесь слишком широк: `127.0.0.5`
// выглядит «локальным», но control plane при нём уже не работает, а молчаливое
// принятие такого адреса означало бы отказ авторизации всем пирам без единой
// подсказки о причине.
//
// IPv6-wildcard (`[::]:36712`) тоже не принимается, хотя на хосте с
// `net.ipv6.bindv6only=0` соединение на 127.0.0.1 он принимает. Причин две:
// HY2XS объявлен IPv4-only (`HY2XS_IPV6_ENABLED` обязан быть false), и
// достижимость такого слушателя зависит от настройки ядра, которой продукт не
// управляет. Отвечать «достучусь» на основании чужого sysctl нельзя, а
// указанное в отказе действие — `reconfigure` — для этой конфигурации всё равно
// верное.
func parseTrafficStatsPort(listen string) (int64, error) { func parseTrafficStatsPort(listen string) (int64, error) {
trimmed := strings.TrimSpace(listen) trimmed := strings.TrimSpace(listen)
if trimmed == "" { if trimmed == "" {
@@ -185,26 +202,21 @@ func parseTrafficStatsPort(listen string) (int64, error) {
// assertTrafficStatsHostReachable отвечает, сможет ли админка попасть по этому // assertTrafficStatsHostReachable отвечает, сможет ли админка попасть по этому
// адресу — а не «нравится ли он профилю». // адресу — а не «нравится ли он профилю».
//
// Совпадение точное, без `ip.IsLoopback()`: слушатель на конкретном адресе
// принимает соединения только на него, поэтому «какой-нибудь loopback» и
// «достижим по 127.0.0.1» — разные утверждения. Разбор — в комментарии выше.
func assertTrafficStatsHostReachable(host string) error { func assertTrafficStatsHostReachable(host string) error {
trimmed := strings.TrimSpace(host) switch strings.TrimSpace(host) {
if trimmed == "" { case "", "127.0.0.1", "0.0.0.0":
return nil return nil
} }
ip := net.ParseIP(trimmed)
if ip == nil {
return fmt.Errorf( return fmt.Errorf(
"trafficStats.listen указывает хост %q: HY2XS ожидает IP-адрес loopback (127.0.0.1)", "trafficStats.listen слушает %s, а админка обращается к Traffic Stats API строго через 127.0.0.1: "+
trimmed, "слушатель на конкретном адресе соединения на 127.0.0.1 не принимает. "+
)
}
if ip.IsLoopback() || ip.IsUnspecified() {
return nil
}
return fmt.Errorf(
"trafficStats.listen слушает %s, а админка обращается к Traffic Stats API только по loopback. "+
"Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают. "+ "Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают. "+
"Верните 127.0.0.1 через `hy2xs-orchestrator reconfigure`", "Верните 127.0.0.1 через `hy2xs-orchestrator reconfigure`",
trimmed, strings.TrimSpace(host),
) )
} }
+70 -4
View File
@@ -1,10 +1,12 @@
package service package service
import ( import (
"net"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
) )
@@ -25,10 +27,9 @@ func TestParseTrafficStatsPortAcceptsReachableAddresses(t *testing.T) {
}{ }{
{"канонический профиль", "127.0.0.1:36712", 36712}, {"канонический профиль", "127.0.0.1:36712", 36712},
{"пробелы по краям", " 127.0.0.1:36712 ", 36712}, {"пробелы по краям", " 127.0.0.1:36712 ", 36712},
{"другой адрес loopback", "127.0.0.5:36712", 36712}, // Wildcard принимает соединение на 127.0.0.1: обмен состоится, и
// Wildcard включает loopback: обмен состоится, и отказывать в // отказывать в обслуживании из-за лишней публикации внутреннего API
// обслуживании из-за лишней публикации внутреннего API нельзя — // нельзя — это отключило бы всех пиров. Запрет на wildcard принадлежит
// это отключило бы всех пиров. Запрет на wildcard принадлежит
// оркестратору, который конфиг и создаёт, а сообщает о нём панель. // оркестратору, который конфиг и создаёт, а сообщает о нём панель.
{"wildcard", "0.0.0.0:36712", 36712}, {"wildcard", "0.0.0.0:36712", 36712},
// Пустой хост — тот же wildcard, а не loopback: в Go `:36712` // Пустой хост — тот же wildcard, а не loopback: в Go `:36712`
@@ -57,6 +58,20 @@ func TestParseTrafficStatsPortRejectsUnreachableAddresses(t *testing.T) {
{"адрес LAN", "192.168.1.10:36712"}, {"адрес LAN", "192.168.1.10:36712"},
{"публичный адрес", "203.0.113.10:36712"}, {"публичный адрес", "203.0.113.10:36712"},
{"имя хоста", "hysteria.internal:36712"}, {"имя хоста", "hysteria.internal:36712"},
// Главный случай, ради которого проверка стала точной. Слушатель на
// конкретном адресе принимает соединения только на него:
//
// bind 127.0.0.5:36712 -> dial 127.0.0.1:36712 -> connection refused
//
// «Какой-нибудь loopback» и «достижим по 127.0.0.1» — разные
// утверждения, и прежний `ip.IsLoopback()` их смешивал: control plane
// при таком адресе уже не работает, а админка молчала.
{"другой адрес loopback", "127.0.0.5:36712"},
{"loopback-подсеть целиком", "127.1.2.3:36712"},
// IPv6-wildcard на хосте с bindv6only=0 соединение принял бы, но HY2XS
// объявлен IPv4-only, а зависеть в ответе «достучусь» от чужого sysctl
// нельзя.
{"IPv6 wildcard", "[::]:36712"},
{"пустая строка", ""}, {"пустая строка", ""},
{"порт вне диапазона", "127.0.0.1:70000"}, {"порт вне диапазона", "127.0.0.1:70000"},
{"без порта", "127.0.0.1"}, {"без порта", "127.0.0.1"},
@@ -94,3 +109,54 @@ func TestGetHysteria2ApiPortExplainsNonLoopbackListen(t *testing.T) {
t.Errorf("сообщение не называет способ починки: %q", err.Error()) t.Errorf("сообщение не называет способ починки: %q", err.Error())
} }
} }
// Свойство, на котором держится точность проверки, зафиксировано прямо здесь:
// слушатель на конкретном адресе loopback НЕ принимает соединения на
// 127.0.0.1, а wildcard принимает.
//
// Без этого теста правило «принимаем только 127.0.0.1 и 0.0.0.0» выглядит
// произвольным ужесточением, и следующий читатель имеет все основания вернуть
// `ip.IsLoopback()` обратно.
func TestLoopbackBindIsNotReachableThroughAnotherLoopbackAddress(t *testing.T) {
specific, err := net.Listen("tcp", "127.0.0.5:0")
if err != nil {
t.Skipf("хост не позволяет привязаться к 127.0.0.5: %v", err)
}
defer specific.Close()
_, port, err := net.SplitHostPort(specific.Addr().String())
if err != nil {
t.Fatalf("адрес слушателя не разобран: %v", err)
}
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", port), 2*time.Second)
if err == nil {
conn.Close()
t.Fatal("соединение на 127.0.0.1 принято слушателем, привязанным к 127.0.0.5: " +
"правило проверки адреса построено на неверном допущении")
}
// Обратная половина: wildcard такое соединение принимает, поэтому
// отказывать по нему в обслуживании было бы неверно.
wildcard, err := net.Listen("tcp", "0.0.0.0:0")
if err != nil {
t.Fatalf("не удалось занять wildcard-порт: %v", err)
}
defer wildcard.Close()
go func() {
accepted, acceptErr := wildcard.Accept()
if acceptErr == nil {
accepted.Close()
}
}()
_, wildcardPort, err := net.SplitHostPort(wildcard.Addr().String())
if err != nil {
t.Fatalf("адрес wildcard-слушателя не разобран: %v", err)
}
conn, err = net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", wildcardPort), 2*time.Second)
if err != nil {
t.Fatalf("wildcard не принял соединение на 127.0.0.1: %v", err)
}
conn.Close()
}
@@ -176,16 +176,46 @@ host === "" || host === "127.0.0.1" || host === "0.0.0.0"
поведение это не влияло (wildcard включает loopback, поэтому обмен состоится), поведение это не влияло (wildcard включает loopback, поэтому обмен состоится),
но описание контракта было ложным и исправлено. но описание контракта было ложным и исправлено.
**Как закрыто.** Backend не тронут: он по-прежнему отвечает ровно на вопрос **Как закрыто.** Панель отвечает на оба вопроса раздельно, backend продолжает
достижимости, и превращать лишнюю публикацию в отказ обслуживания нельзя — это отвечать только на вопрос достижимости превращать лишнюю публикацию в отказ
отключило бы всех пиров. Разделены понятия на стороне панели: обслуживания нельзя, это отключило бы всех пиров.
| адрес | состояние | что показано | | адрес | состояние | что показано |
| --- | --- | --- | | --- | --- | --- |
| `127.0.0.1` | `canonical` | без пометки | | `127.0.0.1` | `canonical` | без пометки |
| `0.0.0.0`, пустой хост | `wildcard` | предупреждение: API доступен, но слушает все интерфейсы | | `0.0.0.0`, пустой хост | `wildcard` | предупреждение: API доступен, но слушает все интерфейсы |
| `127.0.0.x` | `nonCanonicalLoopback` | предупреждение: доступен, но оркестратор такого не создаёт | | прочее, включая `127.0.0.5` | `unreachable` | ошибка: доступ пиров уже не работает |
| прочее | `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-журнал не разбирался ни одной строкой ## ADM-HY2-02 — JSON-журнал не разбирался ни одной строкой
@@ -385,11 +385,22 @@ API только по loopback. ...Верните 127.0.0.1 через `hy2xs-or
| --- | --- | --- | | --- | --- | --- |
| `127.0.0.1:36712` | без пометки | канон production-профиля | | `127.0.0.1:36712` | без пометки | канон production-профиля |
| `0.0.0.0:36712`, `:36712` | предупреждение | API достижим, но опубликован на всех интерфейсах; при `HY2XS_FIREWALL_MODE=external\|off` его не прикрывает ничто | | `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` | ошибка | панель до него не достучится, доступ пиров уже не работает | | `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 секунд 27. живая сессия, которой в базе больше ничего не соответствует (пир удалён либо его `auth_id` заменён импортом, а разрыв в тот момент не удался), завершается очередным циклом учёта — не позднее 30 секунд
28. превышение `maxDevices` живыми сессиями устраняется тем же циклом: после неудавшегося разрыва при снижении лимита повтор формы даёт успех без `/kick`, и единственный механизм схождения здесь — cron 28. превышение `maxDevices` живыми сессиями устраняется тем же циклом: после неудавшегося разрыва при снижении лимита повтор формы даёт успех без `/kick`, и единственный механизм схождения здесь — cron
29. смена секрета пира меняет его `auth_id`: клиент со старым секретом теряет доступ не позднее 30 секунд даже в том случае, когда `/kick` прошёл успешно, а соединение зарегистрировалось после него 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`: внешних запросов проверки версии при старте нет 31. `hysteria-server.service` запущен с `HYSTERIA_DISABLE_UPDATE_CHECK=1`: внешних запросов проверки версии при старте нет
32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd 32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd
33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON 33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON
+31
View File
@@ -2051,6 +2051,37 @@ run_access_revocation_acceptance() {
# прочитанного адреса, а называет расхождение. # прочитанного адреса, а называет расхождение.
code_has apps/service/config.go -F -- 'func assertTrafficStatsHostReachable' \ code_has apps/service/config.go -F -- 'func assertTrafficStatsHostReachable' \
|| fail "acceptance: the admin backend accepts any Traffic Stats host silently" || fail "acceptance: the admin backend accepts any Traffic Stats host silently"
# Проверяется СЕМАНТИКА, а не наличие функции.
#
# Первая версия принимала любой `ip.IsLoopback()`, и это неверно: слушатель на
# конкретном адресе принимает соединения только на него, поэтому
# `bind 127.0.0.5:36712` + `dial 127.0.0.1:36712` даёт connection refused.
# Такой адрес выглядит «локальным», а control plane при нём уже не работает —
# то есть отказ авторизации всем пирам без единой подсказки о причине.
! code_has apps/service/config.go -F -- 'IsLoopback()' \
|| fail "acceptance: the Traffic Stats host check is back to any loopback address"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/config.go", "utf8");
const start = source.indexOf("func assertTrafficStatsHostReachable");
if (start < 0) throw new Error("assertTrafficStatsHostReachable is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = (end < 0 ? rest : rest.slice(0, end))
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
// Принимается ровно три формы: канон, IPv4-wildcard и пустой хост (тот же
// wildcard). Всё остальное, включая прочие адреса loopback, недостижимо.
for (const accepted of [`"127.0.0.1"`, `"0.0.0.0"`]) {
if (!body.includes(accepted)) {
throw new Error("the reachable host " + accepted + " is no longer accepted explicitly");
}
}
' || fail "acceptance: the Traffic Stats host must be matched exactly"
# Негативный случай закреплён тестом: без него правило выглядит произвольным
# ужесточением, и следующий читатель вернёт IsLoopback обратно.
code_has apps/service/config_traffic_stats_test.go -F -- '127.0.0.5' \
|| fail "acceptance: the unreachable loopback address is not pinned by a test"
log_step "Acceptance: the systemd state has three values, not two" log_step "Acceptance: the systemd state has three values, not two"
# util.Exec выбрасывает вывод при ненулевом коде, а `systemctl is-active` # util.Exec выбрасывает вывод при ненулевом коде, а `systemctl is-active`
+9 -3
View File
@@ -563,16 +563,22 @@ describe("страница конфигурации Hysteria", () => {
// интерфейсы, а не loopback. // интерфейсы, а не loopback.
expect(source).toMatch(/host === "" \|\| host === "0\.0\.0\.0"/); expect(source).toMatch(/host === "" \|\| host === "0\.0\.0\.0"/);
for (const state of ["canonical", "wildcard", "nonCanonicalLoopback", "unreachable"]) { for (const state of ["canonical", "wildcard", "unreachable"]) {
expect(source).toContain(state); expect(source).toContain(state);
} }
// Три разные новости — три разные фразы, и ни одна не потеряна в словарях. // Прочие адреса loopback — это НЕ «слегка не тот адрес», а
// недостижимость: слушатель на конкретном адресе принимает соединения
// только на него, поэтому bind 127.0.0.5 + dial 127.0.0.1 даёт connection
// refused. Отдельного мягкого состояния для них быть не должно.
expect(source).not.toContain("nonCanonicalLoopback");
expect(source).not.toMatch(/127\\\.\\d/);
// Две разные новости — две разные фразы, и ни одна не потеряна в словарях.
for (const dictionary of [ru, en] as const) { for (const dictionary of [ru, en] as const) {
const hysteria = (dictionary as any).hysteria ?? {}; const hysteria = (dictionary as any).hysteria ?? {};
expect(typeof hysteria.trafficStatsUnreachable).toBe("string"); expect(typeof hysteria.trafficStatsUnreachable).toBe("string");
expect(typeof hysteria.trafficStatsWildcard).toBe("string"); expect(typeof hysteria.trafficStatsWildcard).toBe("string");
expect(typeof hysteria.trafficStatsNonCanonical).toBe("string");
} }
}); });