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 после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
@@ -25,13 +25,29 @@ func adminClaimsFromContext(c *gin.Context) (bo.AccountBo, bool) {
|
||||
return claims, castOK
|
||||
}
|
||||
|
||||
// ErrInvalidCredentials — логин или пароль не подошли.
|
||||
//
|
||||
// ОДНО значение на оба случая, и это не упрощение. «Такого администратора
|
||||
// нет» и «пароль не тот» обязаны быть неразличимы снаружи: иначе форма входа
|
||||
// превращается в способ проверять существование имён администраторов, а
|
||||
// панель слушает только localhost именно потому, что вход — самая ценная
|
||||
// дверь продукта.
|
||||
//
|
||||
// Отказ хранилища при этом сюда НЕ сворачивается: слой данных уже умеет
|
||||
// отличать «записи нет» от «база не ответила» (dao.IsNotFound), и недоступная
|
||||
// SQLite обязана выглядеть как системная ошибка, а не как неверный пароль.
|
||||
var ErrInvalidCredentials = errors.New(constant.WrongPassword)
|
||||
|
||||
func Login(username string, plainPassword string) (string, bool, error) {
|
||||
admin, err := dao.GetAdminUser("username = ? and status = 1", username)
|
||||
if err != nil {
|
||||
if dao.IsNotFound(err) {
|
||||
return "", false, ErrInvalidCredentials
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
if !util.VerifyPassword(plainPassword, *admin.PasswordHash) {
|
||||
return "", false, errors.New(constant.WrongPassword)
|
||||
return "", false, ErrInvalidCredentials
|
||||
}
|
||||
tokenVersion := int64(1)
|
||||
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
|
||||
|
||||
+21
-3
@@ -70,6 +70,24 @@ func GenToken(accountBo bo.AccountBo) (string, error) {
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
|
||||
}
|
||||
|
||||
// Отказ разбора токена — это ЗНАЧЕНИЕ, а не свежая ошибка с текстом внутри.
|
||||
//
|
||||
// Различие существенно для вызывающего: middleware обязан отличить истёкшую
|
||||
// сессию от недействительного токена, потому что оператору это показывается
|
||||
// по-разному — «сессия истекла, войдите заново» против «войдите». Раньше
|
||||
// единственным способом задать этот вопрос было сравнение err.Error() с
|
||||
// константой, то есть разбор человеческого текста; такое сравнение молча
|
||||
// перестаёт работать при первой же правке формулировки.
|
||||
//
|
||||
// Тексты сохранены прежними: они уезжают в ответ панели.
|
||||
var (
|
||||
// ErrTokenExpired — токен разобран, но его срок истёк.
|
||||
ErrTokenExpired = errors.New(constant.TokenExpiredError)
|
||||
// ErrTokenInvalid — токен не разобран, подписан не тем ключом или не
|
||||
// содержит ожидаемых утверждений.
|
||||
ErrTokenInvalid = errors.New(constant.IllegalTokenError)
|
||||
)
|
||||
|
||||
func ParseToken(tokenString string) (*MyClaims, error) {
|
||||
secret, err := jwtSecret()
|
||||
if err != nil {
|
||||
@@ -89,14 +107,14 @@ func ParseToken(tokenString string) (*MyClaims, error) {
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, errors.New(constant.TokenExpiredError)
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
return nil, errors.New(constant.IllegalTokenError)
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*MyClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New(constant.IllegalTokenError)
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -119,8 +120,14 @@ func TestParseTokenRejectsExpiredToken(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("истёкший токен принят")
|
||||
}
|
||||
if err.Error() != constant.TokenExpiredError {
|
||||
t.Errorf("истечение срока должно сообщаться отдельно, получено: %v", err)
|
||||
// Вопрос задаётся значению, а не тексту: middleware различает истёкшую
|
||||
// сессию и недействительный токен именно через errors.Is, и проверка здесь
|
||||
// обязана закреплять тот же способ.
|
||||
if !errors.Is(err, ErrTokenExpired) {
|
||||
t.Errorf("истечение срока должно сообщаться отдельным значением, получено: %v", err)
|
||||
}
|
||||
if errors.Is(err, ErrTokenInvalid) {
|
||||
t.Error("истёкший токен не должен выглядеть как недействительный")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-22
@@ -63,39 +63,36 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
|
||||
// результат которых виден в списке пиров. Bootstrap-пир после установки
|
||||
// остаётся действующим доступом, и запрет его убрать означал бы вечный
|
||||
// неотзываемый доступ.
|
||||
var errBootstrapPeerIdentity = fmt.Errorf(
|
||||
"пир %q принадлежит установщику: его имя и секрет продублированы в "+
|
||||
"/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+
|
||||
"Ненужный bootstrap-пир следует удалить целиком, а не переподписывать",
|
||||
ReservedBootstrapPeerName,
|
||||
)
|
||||
var errBootstrapPeerIdentity = &PeerError{
|
||||
Code: constant.ErrCodePeerBootstrapLocked,
|
||||
Message: fmt.Sprintf(
|
||||
"пир %q принадлежит установщику: его имя и секрет продублированы в "+
|
||||
"/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+
|
||||
"Ненужный bootstrap-пир следует удалить целиком, а не переподписывать",
|
||||
ReservedBootstrapPeerName,
|
||||
),
|
||||
}
|
||||
|
||||
func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
|
||||
if peerDto.Name == nil || *peerDto.Name == "" {
|
||||
return vo.PeerVo{}, errors.New(constant.InvalidError)
|
||||
return vo.PeerVo{}, ErrPeerNameRequired
|
||||
}
|
||||
// Имя зарезервировано за установщиком даже когда сам пир уже удалён:
|
||||
// иначе после удаления обычный пир мог бы занять это имя и оказаться под
|
||||
// защитой, предназначенной не ему.
|
||||
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
|
||||
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
|
||||
return vo.PeerVo{}, ErrPeerNameReserved
|
||||
}
|
||||
taken, err := ExistPeerName(*peerDto.Name, 0)
|
||||
if err != nil {
|
||||
return vo.PeerVo{}, err
|
||||
}
|
||||
if taken {
|
||||
return vo.PeerVo{}, fmt.Errorf("name %s already exists", *peerDto.Name)
|
||||
return vo.PeerVo{}, PeerNameTakenError(*peerDto.Name)
|
||||
}
|
||||
secret := ""
|
||||
if peerDto.Secret != nil && *peerDto.Secret != "" {
|
||||
secret = *peerDto.Secret
|
||||
} else {
|
||||
generated, err := util.RandomString(24)
|
||||
if err != nil {
|
||||
return vo.PeerVo{}, err
|
||||
}
|
||||
secret = fmt.Sprintf("%s.%s", *peerDto.Name, generated)
|
||||
secret, err := resolvePeerSecret(*peerDto.Name, peerDto.Secret)
|
||||
if err != nil {
|
||||
return vo.PeerVo{}, err
|
||||
}
|
||||
authId, err := util.RandomString(18)
|
||||
if err != nil {
|
||||
@@ -178,7 +175,7 @@ func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) e
|
||||
return err
|
||||
}
|
||||
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
|
||||
return fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
|
||||
return ErrPeerNameReserved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +215,7 @@ func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) e
|
||||
//
|
||||
// Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом
|
||||
// владеет оркестратор, админка его не трогает; после отзыва он содержит уже
|
||||
// недействующее значение (см. docs/04-admin-panel.md).
|
||||
// недействующее значение (см. docs/admin/04-admin-panel.md).
|
||||
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
|
||||
|
||||
func GetPeerVo(id int64) (vo.PeerVo, error) {
|
||||
@@ -428,11 +425,12 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
|
||||
entry.createDigest = digest
|
||||
entry.createCipher = cipher
|
||||
} else {
|
||||
generated, err := util.RandomString(24)
|
||||
// Генерация одна на весь продукт: импорт без секрета обязан давать
|
||||
// пира, неотличимого от созданного через форму.
|
||||
createSecret, err := GeneratePeerSecret(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createSecret := fmt.Sprintf("%s.%s", name, generated)
|
||||
digest, err := PeerSecretDigest(createSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
// Доменные отказы пиров несут КОД, а не только текст.
|
||||
//
|
||||
// Раньше причина отказа существовала исключительно в виде человеческой фразы:
|
||||
// `fmt.Errorf("name %s already exists", …)`. Слой контроллеров отдавал её
|
||||
// панели как есть, панель показывала её тостом, и всё, что могло бы захотеть
|
||||
// отреагировать на причину — подсветить нужное поле формы, перевести фразу на
|
||||
// язык оператора, — было вынуждено сравнивать текст. Такое сравнение молча
|
||||
// ломается при первой же правке формулировки.
|
||||
//
|
||||
// Код и имя поля объявляются здесь, рядом с местом, которое отказ порождает.
|
||||
|
||||
// PeerError — отказ операции над пиром с машиночитаемой причиной.
|
||||
type PeerError struct {
|
||||
// Code — причина из constant.ErrCode*.
|
||||
Code string
|
||||
// Field — поле формы, к которому относится отказ; пусто, если отказ
|
||||
// относится к операции целиком.
|
||||
Field string
|
||||
// Message — человекочитаемое объяснение для клиента без UI.
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *PeerError) Error() string { return e.Message }
|
||||
|
||||
// ErrPeerNameRequired — имя пира не передано.
|
||||
//
|
||||
// Правило `required` в DTO ловит это раньше, но CreatePeer вызывается и из
|
||||
// тестов, и потенциально из будущих внутренних путей, поэтому проверка
|
||||
// остаётся, а не превращается в предположение.
|
||||
var ErrPeerNameRequired = &PeerError{
|
||||
Code: constant.ErrCodeRequired,
|
||||
Field: "name",
|
||||
Message: "имя пира обязательно",
|
||||
}
|
||||
|
||||
// ErrPeerNameReserved — имя принадлежит пиру установщика.
|
||||
//
|
||||
// Имя зарезервировано и когда сам пир уже удалён: иначе обычный пир занял бы
|
||||
// его и оказался под защитой, предназначенной не ему.
|
||||
var ErrPeerNameReserved = &PeerError{
|
||||
Code: constant.ErrCodePeerNameReserved,
|
||||
Field: "name",
|
||||
Message: fmt.Sprintf(
|
||||
"имя %q зарезервировано за пиром установщика",
|
||||
ReservedBootstrapPeerName,
|
||||
),
|
||||
}
|
||||
|
||||
// PeerNameTakenError — имя уже занято другим пиром.
|
||||
func PeerNameTakenError(name string) *PeerError {
|
||||
return &PeerError{
|
||||
Code: constant.ErrCodePeerNameTaken,
|
||||
Field: "name",
|
||||
Message: fmt.Sprintf("пир с именем %q уже существует", name),
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,39 @@ const MaxPeerImportItems = 5000
|
||||
// константы рано или поздно разошлись бы.
|
||||
const ReservedBootstrapPeerName = dao.BootstrapPeerName
|
||||
|
||||
// Тот же набор символов, что и у validateStr в слое контроллеров.
|
||||
// Правило имени пира — ОДНО на весь продукт.
|
||||
//
|
||||
// В таблицу пиров ведут две двери: обычное создание через 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}$`)
|
||||
@@ -70,8 +100,11 @@ func ValidatePeerImportBatch(items []bo.PeerExport) error {
|
||||
if name == "" {
|
||||
return peerImportError(i, "пустое имя")
|
||||
}
|
||||
if !peerNamePattern.MatchString(name) {
|
||||
return peerImportError(i, fmt.Sprintf("недопустимое имя %q: 6-32 символа из [a-zA-Z0-9!@#$%%^&*()_+-=]", name))
|
||||
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))
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
// Секреты пиров: генерация, отпечаток, шифрование.
|
||||
|
||||
// Криптоматериал пиров берётся из dao, а не создаётся здесь заново.
|
||||
//
|
||||
// Что было. В этом файле лежали СОБСТВЕННЫЕ getOrCreateConfigKey и
|
||||
@@ -22,6 +24,53 @@ import (
|
||||
// из-за которого каждая первая загрузка печатала в журнал
|
||||
// `duplicated key not allowed` уровня error на здоровом старте.
|
||||
|
||||
// generatedSecretRandomLength — длина случайной части автогенерируемого
|
||||
// секрета.
|
||||
//
|
||||
// 24 символа из алфавита util.RandomString (62 символа) дают примерно 143 бита
|
||||
// энтропии. Источник — crypto/rand с отбрасыванием смещённых байтов, то есть
|
||||
// тот же генератор, которым создаются JWT_SECRET и ключи шифрования секретов.
|
||||
const generatedSecretRandomLength = 24
|
||||
|
||||
// GeneratePeerSecret создаёт секрет подключения пира.
|
||||
//
|
||||
// Владелец автогенерации — сервисный слой, и это существенно. Панель обещает
|
||||
// оператору «оставьте пустым — сгенерируем автоматически», и то же обещание
|
||||
// обязано действовать для прямого вызова API, для импорта и для будущих
|
||||
// клиентов. Генерация во frontend означала бы, что обещание выполняется ровно
|
||||
// для одной двери из четырёх, а остальные тихо получают пустое значение.
|
||||
//
|
||||
// Имя пира входит в секрет префиксом: в клиенте секрет виден оператору, и
|
||||
// узнать по нему, какому пиру он принадлежит, полезнее, чем скрыть эту связь.
|
||||
// Стойкость от этого не страдает — она обеспечивается случайной частью, а имя
|
||||
// пира и так публично известно из ссылки.
|
||||
func GeneratePeerSecret(peerName string) (string, error) {
|
||||
generated, err := util.RandomString(generatedSecretRandomLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if peerName == "" {
|
||||
return generated, nil
|
||||
}
|
||||
return fmt.Sprintf("%s.%s", peerName, generated), nil
|
||||
}
|
||||
|
||||
// resolvePeerSecret возвращает секрет, который следует сохранить: заданный
|
||||
// оператором либо сгенерированный.
|
||||
//
|
||||
// К этому моменту нормализация DTO уже привела «поле отсутствует», «пустая
|
||||
// строка» и «одни пробелы» к одному состоянию — nil. Повторный TrimSpace здесь
|
||||
// нужен для вызовов мимо слоя DTO (тесты, внутренние пути): «сгенерировать»
|
||||
// обязано означать одно и то же на всех входах.
|
||||
func resolvePeerSecret(peerName string, provided *string) (string, error) {
|
||||
if provided != nil {
|
||||
if manual := strings.TrimSpace(*provided); manual != "" {
|
||||
return manual, nil
|
||||
}
|
||||
}
|
||||
return GeneratePeerSecret(peerName)
|
||||
}
|
||||
|
||||
// GetPeerSecretKey — HMAC-ключ, которым считается secret_digest пира.
|
||||
func GetPeerSecretKey() (string, error) {
|
||||
return dao.GetOrCreatePeerSecretDigestKey()
|
||||
|
||||
Reference in New Issue
Block a user