Files
HY2XS_flamy/apps/service/peer_import.go
T
founder c0a43ae915 fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
2026-09-01 07:27:15 +05:00

168 lines
8.5 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 (
"errors"
"fmt"
"regexp"
"strings"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
)
// Проверка импортируемой выгрузки пиров.
//
// Обычный путь создания пира проходит через dto.PeerSaveDto и его теги
// валидатора: имя 6-32 символа из ограниченного набора, quota >= -1,
// maxDevices >= 1, disabled строго 0/1 и так далее. Импорт JSON шёл мимо всего
// этого и писал в базу что угодно — включая disabled=7 и имя с переводом
// строки, которое потом попадало бы в клиентскую ссылку.
//
// Правила здесь намеренно повторяют PeerSaveDto: две двери в одну и ту же
// таблицу не имеют права требовать разного.
// MaxPeerImportItems ограничивает размер одной операции импорта.
// Верхняя граница нужна не для памяти (файл уже ограничен 2 МБ), а чтобы
// одна ошибка в файле не превращалась в многоминутную операцию с AES на
// каждой записи.
const MaxPeerImportItems = 5000
// ReservedBootstrapPeerName — пир, который создаёт установка из
// HY2XS_ADMIN_CON_PASS. Его секрет продублирован в
// /etc/hy2xs/bootstrap-admin.secret, и подмена секрета импортом молча
// рассинхронизировала бы файл на диске с базой.
//
// Значение берётся из dao, а не пишется здесь второй раз: guard обязан
// защищать ровно тот пир, который создаёт seeder, и две независимые строковые
// константы рано или поздно разошлись бы.
const ReservedBootstrapPeerName = dao.BootstrapPeerName
// Правило имени пира — ОДНО на весь продукт.
//
// В таблицу пиров ведут две двери: обычное создание через PeerSaveDto и импорт
// выгрузки. Правило у них обязано быть одним, и оно уже расходилось: слой
// контроллеров нёс собственную копию
//
// ^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
//
// с комментарием «тот же набор символов». Набор был другим — дефис внутри
// класса не экранирован, поэтому `+-=` образует диапазон и впускает
// `, - . / 0-9 : ; < =`. Через панель проходило имя `peer/name`, которое
// импорт того же пира отклонял, хотя имя уезжает во fragment клиентской ссылки
// и в автогенерируемый секрет.
//
// Теперь правило объявлено здесь один раз, а controller.validatePeerName зовёт
// IsValidPeerName. Границы длины и человекочитаемый набор экспортируются, чтобы
// сообщение об отказе не заводило собственную копию тех же чисел.
const (
PeerNameMinLength = 6
PeerNameMaxLength = 32
// PeerNameCharset — набор в том виде, в каком его показывают оператору.
PeerNameCharset = `a-z A-Z 0-9 !@#$%^&*()_+-=`
)
var peerNamePattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$`)
// IsValidPeerName сообщает, пригодно ли имя пира. Пробелы по краям к этому
// моменту уже сняты нормализацией DTO; здесь они снимаются повторно, потому
// что импорт приходит не через DTO.
func IsValidPeerName(name string) bool {
return peerNamePattern.MatchString(strings.TrimSpace(name))
}
// authId генерируется через util.RandomString и участвует в HTTP-обмене с
// Hysteria, поэтому здесь набор ещё уже.
var peerAuthIDPattern = regexp.MustCompile(`^[a-zA-Z0-9._\-]{1,64}$`)
func peerImportError(index int, reason string) error {
return fmt.Errorf("peer import: запись #%d: %s", index+1, reason)
}
// ValidatePeerImportBatch проверяет всю партию целиком и не меняет состояние.
//
// Импорт применяется двумя проходами: сначала проверка всех записей, затем
// запись. Иначе файл, у которого невалидна десятая запись, оставлял бы первые
// девять уже применёнными — оператор получал бы ошибку и наполовину изменённый
// список пиров.
func ValidatePeerImportBatch(items []bo.PeerExport) error {
if len(items) == 0 {
return errors.New("peer import: файл не содержит ни одной записи")
}
if len(items) > MaxPeerImportItems {
return fmt.Errorf("peer import: слишком много записей: %d (максимум %d)", len(items), MaxPeerImportItems)
}
seenNames := make(map[string]int, len(items))
seenAuthIDs := make(map[string]int, len(items))
for i, item := range items {
name := strings.TrimSpace(item.Name)
if name == "" {
return peerImportError(i, "пустое имя")
}
if !IsValidPeerName(name) {
return peerImportError(i, fmt.Sprintf(
"недопустимое имя %q: от %d до %d символов из набора %s",
name, PeerNameMinLength, PeerNameMaxLength, PeerNameCharset,
))
}
if name == ReservedBootstrapPeerName {
return peerImportError(i, fmt.Sprintf("имя %q зарезервировано установщиком и не может быть импортировано", name))
}
if prev, ok := seenNames[name]; ok {
return peerImportError(i, fmt.Sprintf("имя %q дублирует запись #%d", name, prev+1))
}
seenNames[name] = i
authID := strings.TrimSpace(item.AuthId)
if authID != "" {
if !peerAuthIDPattern.MatchString(authID) {
return peerImportError(i, fmt.Sprintf("недопустимый authId %q", authID))
}
if authID == ReservedBootstrapPeerName {
return peerImportError(i, fmt.Sprintf("authId %q зарезервирован установщиком", authID))
}
if prev, ok := seenAuthIDs[authID]; ok {
return peerImportError(i, fmt.Sprintf("authId %q дублирует запись #%d", authID, prev+1))
}
seenAuthIDs[authID] = i
}
if secret := strings.TrimSpace(item.Secret); secret != "" {
if len(secret) < 6 || len(secret) > 128 {
return peerImportError(i, "длина secret должна быть 6-128 символов")
}
}
if len([]rune(item.Remark)) > 64 {
return peerImportError(i, "remark длиннее 64 символов")
}
if item.QuotaBytes < -1 {
return peerImportError(i, fmt.Sprintf("quotaBytes должен быть >= -1, получено %d", item.QuotaBytes))
}
if item.ExpiresAt < 0 {
return peerImportError(i, fmt.Sprintf("expiresAt должен быть >= 0, получено %d", item.ExpiresAt))
}
// Ноль означает «не задано»: UpsertPeerExport подставит значение по
// умолчанию. Отрицательное значение — это уже ошибка в файле.
if item.MaxDevices < 0 {
return peerImportError(i, fmt.Sprintf("maxDevices должен быть >= 0, получено %d", item.MaxDevices))
}
if item.Disabled != 0 && item.Disabled != 1 {
return peerImportError(i, fmt.Sprintf("disabled должен быть 0 или 1, получено %d", item.Disabled))
}
if item.BannedUntil < 0 {
return peerImportError(i, fmt.Sprintf("bannedUntil должен быть >= 0, получено %d", item.BannedUntil))
}
if item.DownloadBytes < 0 || item.UploadBytes < 0 {
return peerImportError(i, "счётчики трафика не могут быть отрицательными")
}
if item.LastConnectionAt < 0 {
return peerImportError(i, fmt.Sprintf("lastConnectionAt должен быть >= 0, получено %d", item.LastConnectionAt))
}
}
return nil
}