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:
+16
-6
@@ -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()`
|
||||||
|
и требует негативного случая в тестах.
|
||||||
|
|
||||||
### Исправлено — гейты сборки
|
### Исправлено — гейты сборки
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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(
|
|
||||||
"trafficStats.listen указывает хост %q: HY2XS ожидает IP-адрес loopback (127.0.0.1)",
|
|
||||||
trimmed,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if ip.IsLoopback() || ip.IsUnspecified() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"trafficStats.listen слушает %s, а админка обращается к Traffic Stats API только по loopback. "+
|
"trafficStats.listen слушает %s, а админка обращается к Traffic Stats API строго через 127.0.0.1: "+
|
||||||
|
"слушатель на конкретном адресе соединения на 127.0.0.1 не принимает. "+
|
||||||
"Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают. "+
|
"Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают. "+
|
||||||
"Верните 127.0.0.1 через `hy2xs-orchestrator reconfigure`",
|
"Верните 127.0.0.1 через `hy2xs-orchestrator reconfigure`",
|
||||||
trimmed,
|
strings.TrimSpace(host),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-адрес.
|
||||||
|
|
||||||
### Дашборд показывает «состояние службы неизвестно»
|
### Дашборд показывает «состояние службы неизвестно»
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user