a8407cf16b
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с таким именем не регистрировалось: при переименовании в `credentialStr` правка не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery превращал панику в HTTP 500 на каждый POST /api/auth/login. Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам регексп, в обход валидатора, а обработчика входа не касался ни один тест. Очевидная замена не помогла бы: цепочка правил поля обрывается на первом несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его валидатору отдельно — незарегистрированное правило паникует так же, как в бою, но на сборке. Барьер проверен возвратом исходного тега. Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба идёт в любом режиме операции и от актуальности пароля не зависит. Рядом лежали три расхождения того же класса, найденные при разборе. Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая установка проходила целиком, создавая учётную запись, под которой невозможно войти. Про одно имя существовало три расходящихся умолчания. Оба значения теперь проверяются при разборе окружения — той стороной, которая их порождает: отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход на готовом сервере — переустановкой. Панель была строже сервера. Форма входа ограничивала пароль 32 символами при серверном пределе в 64, а форма смены пароля назначала до 64: пароль, назначенный штатной операцией, после этого не вводился. Набор символов на пароле отвергал значение, которое сервер принял бы, — сервер его не ограничивает нигде. Контракт учётных данных объявлен один раз в service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним тестами, читающими Go-исходник. Класс символов логина был записан диапазоном по опечатке: неэкранированный дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным набором это совпадало только потому, что обе стороны несли одну опечатку. Набор записан явно и НЕ сужен — он уже действует на установленных серверах. Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх, проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина начиналась после иконки, у пароля обрывалась перед «глазом». Индикация перенесена на элемент, который оператор и видит полем; чужая тень гасится селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, — конкретностью, а не !important. Остальные формы панели проверены: собственная рамка на el-form-item есть только на форме входа. Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому — UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login сразу после успешной проверки пароля; отказ записи вход не отменяет, но попадает в журнал. Обработчик входа переехал из controller/peer.go в controller/auth.go: стек в journal указывал на управление пирами. Требование теперь называется, а не сообщается фактом нарушения. «Неверный формат логина» и «Некорректное значение» не давали оператору способа узнать, что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не показан. Фразы форм и серверная причина credential_format перечисляют границы и набор. Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления — по той же причине, что и гейт детектора гонок. Каждое из его утверждений проверено мутационной пробой на реальный отказ; две первые редакции оказались вакуумными и переписаны. Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это релизный гейт сборщика. Прогон задокументирован в docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
91 lines
6.4 KiB
Go
91 lines
6.4 KiB
Go
package service
|
||
|
||
import (
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// Контракт учётных данных администратора — ОДИН на весь продукт.
|
||
//
|
||
// Зачем он вынесен сюда. Про логин и пароль администратора одновременно знали
|
||
// четыре места: регексп в слое контроллеров, границы длины в тегах LoginDto,
|
||
// правило формы входа в панели и правило формы смены пароля в ней же. Ни одно
|
||
// из них не ссылалось на остальные, и все четыре успели разойтись:
|
||
//
|
||
// - тег `validateStr` на Username пережил переименование правила и остался
|
||
// ссылкой на несуществующую функцию — валидатор паниковал на КАЖДОМ входе,
|
||
// а gin.Recovery превращал панику в HTTP 500;
|
||
// - форма входа ограничивала пароль 32 символами, форма смены пароля — 64,
|
||
// сервер — тоже 64. То есть пароль, назначенный штатной формой смены,
|
||
// форма входа отказывалась отправлять: панель запирала оператора снаружи
|
||
// после операции, которую сама же и предложила;
|
||
// - оркестратор не проверял HY2XS_ADMIN_USER вовсе и по умолчанию писал в
|
||
// hy2xs.env `admin` — пять символов при минимуме в шесть. Установка
|
||
// завершалась успешно, а войти в панель было нельзя.
|
||
//
|
||
// Общее у всех трёх — контракт существовал только как совпадение литералов,
|
||
// которое ничто не проверяло. Теперь он объявлен здесь, слой контроллеров зовёт
|
||
// IsValidAdminUsername, а расхождение с панелью и оркестратором ловится
|
||
// тестами, читающими эти же константы.
|
||
const (
|
||
AdminUsernameMinLength = 6
|
||
AdminUsernameMaxLength = 32
|
||
|
||
// Границы пароля. Верхняя выбрана не круглым числом: bcrypt читает первые
|
||
// 72 БАЙТА и молча отбрасывает остаток, поэтому предел обязан быть заведомо
|
||
// ниже — иначе два разных пароля, совпадающие в начале, окажутся одним и
|
||
// тем же паролем.
|
||
AdminPasswordMinLength = 6
|
||
AdminPasswordMaxLength = 64
|
||
)
|
||
|
||
// adminUsernameCharacterClass — набор символов логина в записи регекспа.
|
||
//
|
||
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
|
||
// последствия неэкранированного дефиса в исходной записи `_+-=`: там `+-=`
|
||
// образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Сужать набор здесь
|
||
// нельзя в одиночку — имя администратора приходит из HY2XS_ADMIN_USER, и
|
||
// установка с логином вроде `admin.ops` перестала бы пускать оператора в
|
||
// панель. Набор и проверка на стороне оркестратора меняются только вместе.
|
||
const adminUsernameCharacterClass = `a-zA-Z0-9!@#$%^&*()_+,\-./:;<=`
|
||
|
||
// AdminUsernameCharset — тот же набор в том виде, в каком его показывают
|
||
// оператору. Объявлен рядом с регекспом, чтобы сообщение об отказе не заводило
|
||
// собственную копию того же множества.
|
||
const AdminUsernameCharset = `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`
|
||
|
||
// adminUsernamePattern собирается из констант, а не пишется литералом: границы
|
||
// длины обязаны быть одними и теми же для правила и для сообщения о нём.
|
||
var adminUsernamePattern = regexp.MustCompile(
|
||
"^[" + adminUsernameCharacterClass + "]{" +
|
||
strconv.Itoa(AdminUsernameMinLength) + "," +
|
||
strconv.Itoa(AdminUsernameMaxLength) + "}$",
|
||
)
|
||
|
||
// IsValidAdminUsername сообщает, пригодно ли имя администратора.
|
||
//
|
||
// Пробелы по краям снимаются здесь повторно: через форму входа они уже сняты
|
||
// нормализацией DTO, но эту же функцию зовёт проверка HY2XS_ADMIN_USER, куда
|
||
// значение приходит из файла окружения, а не из DTO.
|
||
func IsValidAdminUsername(username string) bool {
|
||
return adminUsernamePattern.MatchString(strings.TrimSpace(username))
|
||
}
|
||
|
||
// IsValidAdminPassword проверяет ТОЛЬКО границы длины.
|
||
//
|
||
// Набор символов пароля не ограничен, и это осознанно. Пароль назначает
|
||
// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, —
|
||
// и сервер нигде его набор не проверяет. Ограничение на форме входа не
|
||
// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает
|
||
// пароль, который сервер принял бы, то есть умеет только запереть оператора.
|
||
//
|
||
// Длина считается в РУНАХ: ровно так её считает go-playground/validator в
|
||
// правилах `min`/`max` на строке, и расхождение означало бы, что пароль с
|
||
// кириллицей проходит одну проверку и отказывается на другой.
|
||
func IsValidAdminPassword(password string) bool {
|
||
length := utf8.RuneCountInString(password)
|
||
return length >= AdminPasswordMinLength && length <= AdminPasswordMaxLength
|
||
}
|