b9d3c03f8d
Проверка принимала любой 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 в тестах.
163 lines
7.2 KiB
Go
163 lines
7.2 KiB
Go
package service
|
||
|
||
import (
|
||
"net"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"hy2xs-admin/model/constant"
|
||
)
|
||
|
||
// Адрес Traffic Stats API — межкомпонентный контракт, и админка обязана
|
||
// заметить его нарушение, а не работать «как будто там loopback».
|
||
//
|
||
// Ломается это одной строкой в `/etc/hysteria/config.yaml`: Hysteria начинает
|
||
// слушать другой адрес, админка продолжает стучаться в 127.0.0.1, и весь контур
|
||
// доступа выключается fail-closed — ни один пир не проходит авторизацию, потому
|
||
// что лимит устройств проверить нечем. Отказ обязан называть причину.
|
||
|
||
func TestParseTrafficStatsPortAcceptsReachableAddresses(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
listen string
|
||
want int64
|
||
}{
|
||
{"канонический профиль", "127.0.0.1:36712", 36712},
|
||
{"пробелы по краям", " 127.0.0.1:36712 ", 36712},
|
||
// Wildcard принимает соединение на 127.0.0.1: обмен состоится, и
|
||
// отказывать в обслуживании из-за лишней публикации внутреннего API
|
||
// нельзя — это отключило бы всех пиров. Запрет на wildcard принадлежит
|
||
// оркестратору, который конфиг и создаёт, а сообщает о нём панель.
|
||
{"wildcard", "0.0.0.0:36712", 36712},
|
||
// Пустой хост — тот же wildcard, а не loopback: в Go `:36712`
|
||
// означает все интерфейсы.
|
||
{"хост не задан", ":36712", 36712},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got, err := parseTrafficStatsPort(tc.listen)
|
||
if err != nil {
|
||
t.Fatalf("адрес %q отклонён: %v", tc.listen, err)
|
||
}
|
||
if got != tc.want {
|
||
t.Fatalf("порт %d, ожидался %d", got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseTrafficStatsPortRejectsUnreachableAddresses(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
listen string
|
||
}{
|
||
{"адрес LAN", "192.168.1.10:36712"},
|
||
{"публичный адрес", "203.0.113.10: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"},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
if _, err := parseTrafficStatsPort(tc.listen); err == nil {
|
||
t.Fatalf("недостижимый адрес %q принят", tc.listen)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// Отказ обязан называть и адрес, и способ починки: оператор, получивший
|
||
// «connection refused» без объяснения, ищет проблему в Hysteria.
|
||
func TestGetHysteria2ApiPortExplainsNonLoopbackListen(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||
content := "listen: \":443\"\ntrafficStats:\n listen: \"192.168.1.10:36712\"\n secret: \"s\"\n"
|
||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||
t.Fatalf("не удалось записать тестовый конфиг: %v", err)
|
||
}
|
||
previous := constant.Hysteria2ConfigPath
|
||
constant.Hysteria2ConfigPath = path
|
||
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
|
||
|
||
_, err := GetHysteria2ApiPort()
|
||
if err == nil {
|
||
t.Fatal("админка приняла адрес, по которому не может обратиться")
|
||
}
|
||
if !strings.Contains(err.Error(), "192.168.1.10") {
|
||
t.Errorf("сообщение не называет фактический адрес: %q", err.Error())
|
||
}
|
||
if !strings.Contains(err.Error(), "reconfigure") {
|
||
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()
|
||
}
|