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 и был единственным элементом вне палитры.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user