Files
HY2XS_flamy/apps/service/peer_import_test.go
T
founder b52fac1394 fix(admin): убрать каналы утечки секретов и остатки H UI из runtime
Экспорт в панели формировался через os.Create в /var/lib/hy2xs-admin/export,
и файл там оставался навсегда. При includeSecrets=true это означало
расшифрованные секреты пиров — фактические учётные данные доступа — в
открытом виде на диске, накапливающиеся с каждым нажатием кнопки. Выгрузки
формируются в памяти, каталога export/ больше нет.

Generic export/import таблицы config удалён целиком. Он исключал только сырой
Hysteria YAML, а в той же таблице лежат JWT_SECRET, PEER_SECRET_KEY,
PEER_SECRET_ENCRYPTION_KEY и HYSTERIA2_TRAFFIC_STATS_SECRET: кнопка Export
выгружала их открытым текстом, импорт позволял подменить. Для
PEER_SECRET_ENCRYPTION_KEY подмена ломает расшифровку секретов уже
существующих пиров. Production-сценария у этой пары не было.

Импорт пиров шёл мимо всей валидации, которую проходит обычное создание пира:
в базу попадало имя любой длины и с любыми символами, disabled с произвольным
числом, отрицательные счётчики. Файл применялся построчно, поэтому ошибка в
середине оставляла список наполовину изменённым, а импорт мог перезаписать
bootstrap-admin-peer, чей секрет продублирован в bootstrap-admin.secret.
Партия проверяется целиком до первой записи, неизвестные поля отклоняются.

Убран слой сетевых настроек панели: H_UI_WEB_PORT, H_UI_WEB_CONTEXT,
H_UI_CRT_PATH, H_UI_KEY_PATH и собственный TLS. Оркестратор передавал порт
аргументом, админка писала его в SQLite и тут же читала обратно, а UI
показывал поля disabled — второй источник истины, из которого ничего нельзя
было изменить. HUI_DATA/HUI_LOG заменены на HY2XS_DATA_DIR/HY2XS_LOG_DIR,
база переименована в hy2xs-admin.db, reference-схема — в schema.sql.

API namespace разделён по природе маршрутов: операторский API на /api,
machine-auth Hysteria на /internal/hysteria/auth. Путь machine-auth —
runtime-контракт, он уезжает в config.yaml и post-install.env, поэтому
объявлен одной константой на компонент.

Go-санитайзер экспорта вырезал секреты из URL только у ключей url/addr:
будущее upstream-поле с другим именем уносило учётные данные и access_token
целиком, а URL внутри списков не обрабатывались вовсе. Граница определяется
значением, а не именем ключа — как в TS-санитайзере оркестратора.

Заодно индикатор загрузки и цвета 401/404 переведены на брендовый токен:
NProgress приходил со своим #29d и был единственным элементом вне палитры.
2026-08-27 20:49:07 +05:00

162 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"strings"
"testing"
"hy2xs-admin/model/bo"
)
func validPeer(name string) bo.PeerExport {
return bo.PeerExport{
Name: name,
Remark: "office laptop",
QuotaBytes: -1,
ExpiresAt: 0,
MaxDevices: 3,
Disabled: 0,
}
}
func TestValidatePeerImportBatchAcceptsExportShape(t *testing.T) {
items := []bo.PeerExport{
validPeer("alpha1"),
{
Name: "bravo-2",
AuthId: "Kf83jd0sLa93kd0sQx",
Secret: "s3cret-value",
QuotaBytes: 107374182400,
DownloadBytes: 12,
UploadBytes: 34,
ExpiresAt: 1893456000000,
MaxDevices: 5,
Disabled: 1,
BannedUntil: 0,
LastConnectionAt: 1893456000000,
},
}
if err := ValidatePeerImportBatch(items); err != nil {
t.Fatalf("валидная выгрузка отклонена: %v", err)
}
}
func TestValidatePeerImportBatchRejectsEmpty(t *testing.T) {
if err := ValidatePeerImportBatch(nil); err == nil {
t.Fatal("пустой импорт должен быть отклонён")
}
}
// Регрессия: импорт шёл мимо dto.PeerSaveDto, поэтому в базу попадало имя
// любой длины и с любыми символами — включая перевод строки, который потом
// оказался бы в клиентской ссылке.
func TestValidatePeerImportBatchRejectsBadNames(t *testing.T) {
cases := map[string]string{
"пустое": "",
"только пробелы": " ",
"короткое": "abc",
"слишком длинное": strings.Repeat("a", 33),
"перевод строки": "peer\nname",
"пробел внутри": "peer name",
"недопустимый слэш": "peer/name",
}
for label, name := range cases {
item := validPeer(name)
if err := ValidatePeerImportBatch([]bo.PeerExport{item}); err == nil {
t.Errorf("%s: имя %q принято, ожидался отказ", label, name)
}
}
}
// Пир установщика продублирован в /etc/hy2xs/bootstrap-admin.secret:
// подмена его секрета импортом рассинхронизировала бы файл с базой.
func TestValidatePeerImportBatchRejectsReservedBootstrapPeer(t *testing.T) {
item := validPeer(ReservedBootstrapPeerName)
err := ValidatePeerImportBatch([]bo.PeerExport{item})
if err == nil {
t.Fatal("зарезервированное имя bootstrap-пира принято")
}
if !strings.Contains(err.Error(), ReservedBootstrapPeerName) {
t.Fatalf("сообщение не называет зарезервированное имя: %v", err)
}
byAuthID := validPeer("alpha1")
byAuthID.AuthId = ReservedBootstrapPeerName
if err := ValidatePeerImportBatch([]bo.PeerExport{byAuthID}); err == nil {
t.Fatal("зарезервированный authId принят")
}
}
func TestValidatePeerImportBatchRejectsOutOfRangeNumbers(t *testing.T) {
cases := map[string]func(*bo.PeerExport){
"quota < -1": func(p *bo.PeerExport) { p.QuotaBytes = -2 },
"expiresAt < 0": func(p *bo.PeerExport) { p.ExpiresAt = -1 },
"maxDevices < 0": func(p *bo.PeerExport) { p.MaxDevices = -1 },
"disabled = 7": func(p *bo.PeerExport) { p.Disabled = 7 },
"bannedUntil < 0": func(p *bo.PeerExport) { p.BannedUntil = -1 },
"download < 0": func(p *bo.PeerExport) { p.DownloadBytes = -1 },
"upload < 0": func(p *bo.PeerExport) { p.UploadBytes = -1 },
"lastConnection < 0": func(p *bo.PeerExport) { p.LastConnectionAt = -1 },
"remark длиннее 64": func(p *bo.PeerExport) { p.Remark = strings.Repeat("я", 65) },
"secret короче 6": func(p *bo.PeerExport) { p.Secret = "abc" },
"secret длиннее 128": func(p *bo.PeerExport) { p.Secret = strings.Repeat("s", 129) },
"authId с пробелом": func(p *bo.PeerExport) { p.AuthId = "bad id" },
"authId длиннее 64": func(p *bo.PeerExport) { p.AuthId = strings.Repeat("a", 65) },
}
for label, mutate := range cases {
item := validPeer("alpha1")
mutate(&item)
if err := ValidatePeerImportBatch([]bo.PeerExport{item}); err == nil {
t.Errorf("%s: запись принята, ожидался отказ", label)
}
}
}
// -1 (безлимит) и 0 (не задано) — легальные значения, а не пограничный мусор.
func TestValidatePeerImportBatchAcceptsSentinelValues(t *testing.T) {
item := validPeer("alpha1")
item.QuotaBytes = -1
item.MaxDevices = 0
item.ExpiresAt = 0
if err := ValidatePeerImportBatch([]bo.PeerExport{item}); err != nil {
t.Fatalf("допустимые sentinel-значения отклонены: %v", err)
}
}
func TestValidatePeerImportBatchRejectsDuplicates(t *testing.T) {
dupName := []bo.PeerExport{validPeer("alpha1"), validPeer("alpha1")}
if err := ValidatePeerImportBatch(dupName); err == nil {
t.Error("дублирующееся имя принято")
}
first := validPeer("alpha1")
first.AuthId = "same-auth-id"
second := validPeer("bravo1")
second.AuthId = "same-auth-id"
if err := ValidatePeerImportBatch([]bo.PeerExport{first, second}); err == nil {
t.Error("дублирующийся authId принят")
}
}
func TestValidatePeerImportBatchRejectsOversizedBatch(t *testing.T) {
items := make([]bo.PeerExport, MaxPeerImportItems+1)
for i := range items {
items[i] = validPeer("alpha1")
}
if err := ValidatePeerImportBatch(items); err == nil {
t.Fatal("партия больше лимита принята")
}
}
// Валидация обязана быть полной до первой записи в базу: файл, у которого
// невалидна последняя запись, не должен применить первые.
func TestValidatePeerImportBatchReportsLastInvalidRecord(t *testing.T) {
items := []bo.PeerExport{validPeer("alpha1"), validPeer("bravo1"), validPeer("x")}
err := ValidatePeerImportBatch(items)
if err == nil {
t.Fatal("невалидная последняя запись пропущена")
}
if !strings.Contains(err.Error(), "#3") {
t.Fatalf("сообщение не указывает номер записи: %v", err)
}
}