fix(auth): контракт пароля администратора расходился с bcrypt в четырёх местах

Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом
bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от
64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль
длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как
утверждал комментарий, — так вела себя редакция пакета до v0.28.

Следствие: пароль из 64 кириллических букв (128 байт) проходил панель,
оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на
штатной смене пароля, а при установке падением старта админки, то есть
сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта
было то, что тест закреплял это значение как ожидаемое.

Вместе с ним закрыты три соседних расхождения того же контракта.

Пароль триммился вопреки собственному контракту. util.HashPassword вёл
проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал
strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение
"abcde " принимали все двери продукта и не мог захешировать никто, а первая
учётная запись создавалась не с тем паролем, который оператор записал в
hy2xs.env.

Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы
async-validator, а он сравнивает min/max с String.prototype.length: пароль из
трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера,
который панель не могла объяснить.

hy2xs.env не был форматом. Значения писались интерполяцией, а читались
split("=") с trim(); при этом файл читает не только оркестратор — он объявлен
EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd
срезает краевые пробелы и трактует обратный слеш как escape.

Что сделано:

- контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword
  и dao, а service импортирует util — обратный импорт был бы циклическим, и
  именно поэтому HashPassword завёл собственную копию правила;
- AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в
  оркестраторе и панели; сверяется тестами, читающими Go-исходник;
- одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в
  байтах тегом валидатора не выразить) и код причины admin_password_format,
  называющий обе границы;
- TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет
  контракт сам и падает с текстом, называющим переменную и файл;
- панель считает code points и UTF-8 байты общим adminPasswordFormRule на
  обеих формах вместо встроенных min/max;
- orchestrator/src/lib/envFile.ts — порт конечного автомата
  parse_env_file_internal из systemd и обратный ему кодировщик; экранируются
  только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные
  значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же
  кодировщиком пишется bootstrap-admin.secret;
- управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а
  ввести такой пароль в форму входа всё равно нельзя;
- отрицательная проба smoke сверяет конверт отказа (code 50000,
  invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль
  генерирует, а не берёт из литерала;
- положительная проба читает bootstrap-секрет парсером формата вместо
  grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы.

Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я»,
«abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами
инвертирован; round-trip env-формата на значениях с кавычками, слешами и
краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов
приёмки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 18:38:04 +05:00
parent a8407cf16b
commit 65042ee335
38 changed files with 2646 additions and 400 deletions
+94 -1
View File
@@ -107,6 +107,25 @@ Debian 13. Тема: **разница между «поднялось» и «р
настоящий вход. Прогон задокументирован в настоящий вход. Прогон задокументирован в
[docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md](docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md). [docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md](docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md).
Пятнадцатый проход — разбор того, что четырнадцатый построил, со сверкой по
исходникам используемых библиотек, а не по их описаниям. Тема прохода: **новый
контракт, объявленный точнее, чем он есть на самом деле**. Четырнадцатый проход
свёл правило о пароле в одно место и объявил его границей в 64 символа,
обосновав выбор пределом bcrypt в 72 байта. Обоснование верно только для ASCII:
`golang.org/x/crypto/bcrypt` считает БАЙТЫ и отвечает `ErrPasswordTooLong`, а не
«молча отбрасывает остаток», как утверждал комментарий, — так вела себя редакция
пакета до v0.28. Пароль из 64 кириллических букв (128 байт) проходил панель,
оркестратор и DTO и падал в хешировании: системной ошибкой на штатной смене
пароля, а после установки — отсутствием администратора вовсе. Хуже самого
дефекта было то, что новый тест закреплял это значение как ожидаемое.
Вместе с ним закрыты три соседних расхождения того же контракта: `HashPassword`
и bootstrap триммили пароль, у которого краевой пробел объявлен частью значения;
панель считала длину в единицах UTF-16 (`async-validator`), тогда как сервер
считает code points; и сам транспорт — `hy2xs.env` — не был форматом,
переживающим произвольное значение, хотя его читает не только оркестратор, но и
systemd.
### Исправлено — вход в панель ### Исправлено — вход в панель
- **`POST /api/auth/login` паниковал до проверки учётных данных.** На - **`POST /api/auth/login` паниковал до проверки учётных данных.** На
@@ -144,7 +163,7 @@ Debian 13. Тема: **разница между «поднялось» и «р
смены пароля назначала до 64: пароль, назначенный штатной операцией, после смены пароля назначала до 64: пароль, назначенный штатной операцией, после
этого не вводился. Набор символов на пароле отвергал значение, которое сервер этого не вводился. Набор символов на пароле отвергал значение, которое сервер
принял бы, — сервер его не ограничивает нигде. Контракт учётных данных теперь принял бы, — сервер его не ограничивает нигде. Контракт учётных данных теперь
объявлен один раз в `apps/service/admin_credentials.go`, а копии в панели и объявлен один раз в `apps/credential/admin.go`, а копии в панели и
оркестраторе сверяются с ним тестами, читающими Go-исходник. оркестраторе сверяются с ним тестами, читающими Go-исходник.
- **Класс символов логина был записан диапазоном по опечатке.** Неэкранированный - **Класс символов логина был записан диапазоном по опечатке.** Неэкранированный
@@ -172,6 +191,80 @@ Debian 13. Тема: **разница между «поднялось» и «р
ставится в `service.Login` сразу после успешной проверки пароля; отказ записи ставится в `service.Login` сразу после успешной проверки пароля; отказ записи
вход не отменяет, но попадает в журнал. вход не отменяет, но попадает в журнал.
### Исправлено — контракт пароля администратора
- **Граница пароля не совпадала с границей bcrypt.** Предел был объявлен в 64
СИМВОЛА и обоснован пределом bcrypt в 72 БАЙТА — верно только для ASCII. У 64
символов длина от 64 до 256 байт: `64 x "я"` — это 128 байт, `19 x "😀"` — 76.
`bcrypt.GenerateFromPassword` (`golang.org/x/crypto@v0.55.0`, `bcrypt.go:96`)
отвечает на такое `ErrPasswordTooLong`. Отказ приходил не с формы, а из
хеширования: на смене пароля — системной ошибкой, при установке — падением
старта админки, то есть сервером без администратора после
`INSTALL EXIT CODE: 0`. Введена отдельная константа `AdminPasswordMaxBytes =
72`, зеркальная в оркестраторе и панели; граничные значения (36 x `я`, 18 x
`😀` = 72 байта) закреплены тестами во всех четырёх слоях.
- **Тест закреплял дефект как ожидаемое поведение.** Проверка «длина считается в
символах, а не в байтах» требовала ПРИЁМА пароля из 64 кириллических букв —
ровно того значения, на котором продукт ломался. Тест инвертирован.
- **Пароль триммился в двух местах вопреки собственному контракту.** Контракт
объявляет краевой пробел частью пароля и не снимает его ни на форме входа, ни
при смене. При этом `util.HashPassword` вёл собственную проверку
`len(strings.TrimSpace(password)) < 6`, а bootstrap читал
`strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Следствия:
пароль `abcde ` принимали все двери продукта и не мог захешировать никто, а
первая учётная запись создавалась не с тем паролем, который оператор записал в
`hy2xs.env`. Оба тримминга убраны.
- **Контракт переехал в leaf-пакет.** Собственная проверка в `HashPassword`
существовала не по недосмотру: `service` импортирует `util`, и позвать оттуда
контракт было невозможно. Правило переехало в `apps/credential`, ниже `util`,
`dao` и `service`, — теперь у него физически не может появиться второго
экземпляра.
- **Границы пароля стояли рядом с правилом, а не внутри него, и были неполны.**
`min=6,max=64` в тегах DTO — та же конструкция, от которой уже отказались на
логине; у пароля она вдобавок не могла выразить границу в байтах, потому что
правила длины в байтах у `go-playground/validator` нет. Введено одно правило
`adminPassword` и код причины `admin_password_format`, называющий обе границы.
- **Панель считала длину в единицах UTF-16.** Element Plus делегирует правила
формы библиотеке `async-validator`, а она сравнивает `min`/`max` строки с
`String.prototype.length`. Пароль из трёх эмодзи имел `length === 6`, проходил
минимум формы и получал отказ сервера, который панель не могла объяснить:
сервер видит три руны. Встроенные `min`/`max` на пароле заменены общим
правилом `adminPasswordFormRule`, считающим code points и UTF-8 байты.
- **`hy2xs.env` не был форматом.** Значения писались интерполяцией, а читались
построчным `split("=")` с `trim()`; совпадение поведения держалось на том, что
в них не встречалось ни пробелов по краям, ни кавычек, ни обратных слешей. При
этом файл читает не только оркестратор: он объявлен `EnvironmentFile=` в юните
`hy2xs-admin`, и у незакавыченного значения systemd срезает краевые пробелы и
трактует `\` как escape. Пароль с краевым пробелом терялся ещё до запуска
админки. Добавлен `orchestrator/src/lib/envFile.ts` — порт конечного автомата
`parse_env_file_internal` из systemd и обратный ему кодировщик; тем же
кодировщиком пишется `bootstrap-admin.secret`. Обычные значения остаются без
кавычек, поэтому релизные гейты и инструкции оператора не меняются.
- **Управляющие символы запрещены контрактом.** Формат `KEY=VALUE` их не несёт —
перевод строки является границей записи, — а ввести такой пароль в
однострочное поле формы входа всё равно нельзя: он умел только запереть
оператора. Запрет делает транспорт полным: всякий принятый контрактом пароль
доезжает до bcrypt побайтово тем же, каким его задал оператор.
- **Отрицательная проба smoke не доказывала отказа.** Она сверяла `%{http_code}`
с `200` и доказывала ровно одно: что запрос не закончился пятисоткой — админка
отвечает `200 OK` и на успешный вход тоже. Пароль пробы вдобавок был записан
литералом и теоретически мог оказаться настоящим. Теперь пароль генерируется в
момент пробы, а результат сверяется с конвертом: `code: 50000`, причина
`invalid_credentials`, отсутствие `accessToken`.
- **Положительная проба smoke читала пароль шелл-конвейером.** `grep … | cut -d=
-f2-` с `.trim()` был третьим по счёту слоем, срезающим краевые пробелы:
проба взяла бы не тот пароль и объявила бы рабочую установку сломанной.
Bootstrap-секрет читается парсером собственного формата, минуя shell.
### Исправлено — правило доступа ### Исправлено — правило доступа
- **Исчерпанная квота не отключала пира никогда.** Правило доступа - **Исчерпанная квота не отключала пира никогда.** Правило доступа
+1 -1
View File
@@ -635,7 +635,7 @@ hy2xs-orchestrator status \
| `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` | | `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` |
| `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` | | `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` |
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора: 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`. Значение вне контракта роняет установку — панель его не приняла бы на форме входа | `hy2xsadmin` | | `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора: 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`. Значение вне контракта роняет установку — панель его не приняла бы на форме входа | `hy2xsadmin` |
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора: 6-64 символа, набор символов не ограничен; `__GENERATE__` генерируется при install | `__GENERATE__` | | `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора: 6-64 символа Unicode **и** не более 72 байт в UTF‑8 (предел bcrypt); набор символов не ограничен, кроме управляющих; пробелы по краям — часть пароля, поэтому такое значение записывается в двойных кавычках; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` | | `HY2XS_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` | | `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` |
| `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` | | `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` |
+132 -40
View File
@@ -16,12 +16,12 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hy2xs-admin/credential"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/dto" "hy2xs-admin/model/dto"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/service"
"hy2xs-admin/util" "hy2xs-admin/util"
) )
@@ -174,11 +174,16 @@ func TestLoginDtoReportsReasonInsteadOfPanic(t *testing.T) {
{"кириллица в логине", "админ12", "bootstrap-password", constant.ErrCodeCredentialStr, "username"}, {"кириллица в логине", "админ12", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"пробел в логине", "hy2xs admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"}, {"пробел в логине", "hy2xs admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"логин короче минимума", "admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"}, {"логин короче минимума", "admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"логин длиннее максимума", strings.Repeat("a", service.AdminUsernameMaxLength+1), "bootstrap-password", constant.ErrCodeCredentialStr, "username"}, {"логин длиннее максимума", strings.Repeat("a", credential.AdminUsernameMaxLength+1), "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"пустой логин", " ", "bootstrap-password", constant.ErrCodeRequired, "username"}, {"пустой логин", " ", "bootstrap-password", constant.ErrCodeRequired, "username"},
{"пустой пароль", "hy2xsadmin", "", constant.ErrCodeRequired, "pass"}, {"пустой пароль", "hy2xsadmin", "", constant.ErrCodeRequired, "pass"},
{"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeMinLength, "pass"}, {"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeAdminPassword, "pass"},
{"пароль длиннее максимума", "hy2xsadmin", strings.Repeat("a", service.AdminPasswordMaxLength+1), constant.ErrCodeMaxLength, "pass"}, {"пароль длиннее максимума", "hy2xsadmin", strings.Repeat("a", credential.AdminPasswordMaxLength+1), constant.ErrCodeAdminPassword, "pass"},
// Укладывается в 64 символа и НЕ укладывается в 72 байта. Прежний
// контракт принимал это значение, и отказ приходил из bcrypt уже после
// всех проверок — системной ошибкой, а не причиной на поле формы.
{"пароль длиннее предела bcrypt", "hy2xsadmin", strings.Repeat("я", credential.AdminPasswordMaxLength), constant.ErrCodeAdminPassword, "pass"},
{"управляющий символ в пароле", "hy2xsadmin", "bootstrap\npassword", constant.ErrCodeAdminPassword, "pass"},
} }
for _, testCase := range cases { for _, testCase := range cases {
@@ -226,13 +231,13 @@ func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) {
if reason.Code != constant.ErrCodeCredentialStr { if reason.Code != constant.ErrCodeCredentialStr {
continue continue
} }
if reason.Params["charset"] != service.AdminUsernameCharset { if reason.Params["charset"] != credential.AdminUsernameCharset {
t.Errorf("причина не называет набор символов: %+v", reason.Params) t.Errorf("причина не называет набор символов: %+v", reason.Params)
} }
if reason.Params["min"] != strconv.Itoa(service.AdminUsernameMinLength) { if reason.Params["min"] != strconv.Itoa(credential.AdminUsernameMinLength) {
t.Errorf("причина не называет нижнюю границу: %+v", reason.Params) t.Errorf("причина не называет нижнюю границу: %+v", reason.Params)
} }
if reason.Params["max"] != strconv.Itoa(service.AdminUsernameMaxLength) { if reason.Params["max"] != strconv.Itoa(credential.AdminUsernameMaxLength) {
t.Errorf("причина не называет верхнюю границу: %+v", reason.Params) t.Errorf("причина не называет верхнюю границу: %+v", reason.Params)
} }
return return
@@ -242,44 +247,88 @@ func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) {
// ---------------------------------------------------------------- контракты --- // ---------------------------------------------------------------- контракты ---
// Границы пароля объявлены в трёх местах и обязаны совпадать. // passwordFields — все поля продукта, несущие пароль администратора.
// var passwordFields = []struct {
// Пока они расходились, панель запирала оператора снаружи: форма смены пароля
// принимала 64 символа, форма входа — 32. Тег структуры не умеет ссылаться на
// константу, поэтому равенство удерживается здесь.
func TestPasswordBoundsAreOneContract(t *testing.T) {
for _, subject := range []struct {
name string name string
typ reflect.Type typ reflect.Type
field string field string
}{ }{
{"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"}, {"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"},
{"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"}, {"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"},
{"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"}, {"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"},
} { }
// Правило пароля ОДНО, и границы живут внутри него.
//
// Здесь стояли `min=6,max=64` прямо в тегах, и проверка следила за тем, чтобы
// числа совпадали с контрактом. Совпадали они честно — и всё равно закрепляли
// неполное правило: тег считает символы Unicode, а bcrypt считает байты, и
// границы в байтах тегом не выразить вовсе. Поэтому теперь проверяется не
// равенство чисел, а ОТСУТСТВИЕ чисел: длина принадлежит правилу, а не соседям
// по тегу. Тот же барьер уже стоит на логине (TestUsernameLengthLivesInsideItsRule).
func TestPasswordLengthLivesInsideItsRule(t *testing.T) {
for _, subject := range passwordFields {
field, ok := subject.typ.FieldByName(subject.field) field, ok := subject.typ.FieldByName(subject.field)
if !ok { if !ok {
t.Fatalf("%s: поле не найдено", subject.name) t.Fatalf("%s: поле не найдено", subject.name)
} }
bounds := map[string]int{} rules := splitValidationRules(field.Tag.Get("validate"))
for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
name, param, found := strings.Cut(rule, "=") for _, rule := range rules {
if !found || (name != "min" && name != "max") { if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") {
continue t.Errorf(
"%s: отдельная граница %q рядом с правилом. Длина принадлежит adminPassword: "+
"тег умеет считать только символы, а у пароля есть ещё граница в байтах",
subject.name, rule,
)
} }
value, err := strconv.Atoi(param)
if err != nil {
t.Fatalf("%s: граница %q не число", subject.name, rule)
}
bounds[name] = value
} }
if bounds["min"] != service.AdminPasswordMinLength { var hasPasswordRule bool
t.Errorf("%s: min=%d, а контракт — %d", subject.name, bounds["min"], service.AdminPasswordMinLength) for _, rule := range rules {
if rule == "adminPassword" {
hasPasswordRule = true
}
}
if !hasPasswordRule {
t.Errorf("%s: проверяется правилами %v — среди них нет adminPassword", subject.name, rules)
}
}
}
// Все поля пароля проверяются ТЕМ ЖЕ правилом, что и bootstrap-установка.
//
// Проверка идёт через production-валидатор на граничных значениях: совпадения
// имён тегов мало, потому что именно расхождение реализаций за одинаковыми
// именами и было исходным дефектом.
func TestEveryPasswordFieldFollowsTheContract(t *testing.T) {
samples := []string{
strings.Repeat("a", credential.AdminPasswordMinLength),
strings.Repeat("a", credential.AdminPasswordMinLength-1),
strings.Repeat("a", credential.AdminPasswordMaxLength),
strings.Repeat("я", 36),
strings.Repeat("я", 37),
strings.Repeat("😀", 18),
strings.Repeat("😀", 19),
strings.Repeat("я", credential.AdminPasswordMaxLength),
"abcde ",
"abcde\n",
}
for _, subject := range passwordFields {
field, _ := subject.typ.FieldByName(subject.field)
rules := strings.Join(splitValidationRules(field.Tag.Get("validate")), ",")
rules = strings.ReplaceAll(rules, "required,", "")
for _, sample := range samples {
expected := credential.IsValidAdminPassword(sample)
if got := validate.Var(sample, rules) == nil; got != expected {
t.Errorf(
"%s: значение из %d символов / %d байт принято=%v, контракт=%v",
subject.name, len([]rune(sample)), len(sample), got, expected,
)
} }
if bounds["max"] != service.AdminPasswordMaxLength {
t.Errorf("%s: max=%d, а контракт — %d", subject.name, bounds["max"], service.AdminPasswordMaxLength)
} }
} }
} }
@@ -291,15 +340,7 @@ func TestPasswordBoundsAreOneContract(t *testing.T) {
// который сервер принял бы. Панель обязана держаться этого же контракта, и // который сервер принял бы. Панель обязана держаться этого же контракта, и
// проверка существует, чтобы правило не «навели» обратно. // проверка существует, чтобы правило не «навели» обратно.
func TestPasswordHasNoCharsetRule(t *testing.T) { func TestPasswordHasNoCharsetRule(t *testing.T) {
for _, subject := range []struct { for _, subject := range passwordFields {
name string
typ reflect.Type
field string
}{
{"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"},
{"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"},
{"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"},
} {
field, _ := subject.typ.FieldByName(subject.field) field, _ := subject.typ.FieldByName(subject.field)
for _, rule := range splitValidationRules(field.Tag.Get("validate")) { for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
if rule == "credentialStr" || rule == "peerName" { if rule == "credentialStr" || rule == "peerName" {
@@ -556,7 +597,7 @@ func TestLoginEndpointDoesNotTrimPassword(t *testing.T) {
// Ровно этот путь был закрыт: форма входа ограничивала пароль 32 символами при // Ровно этот путь был закрыт: форма входа ограничивала пароль 32 символами при
// серверном пределе в 64, и оператор терял доступ после штатной смены пароля. // серверном пределе в 64, и оператор терял доступ после штатной смены пароля.
func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) { func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) {
password := strings.Repeat("a", service.AdminPasswordMaxLength) password := strings.Repeat("a", credential.AdminPasswordMaxLength)
newAuthTestDB(t, "hy2xsadmin", password) newAuthTestDB(t, "hy2xsadmin", password)
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password}) _, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
@@ -565,6 +606,57 @@ func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) {
} }
} }
// Пароль, упирающийся в предел bcrypt ровно, обязан работать целиком: он
// назначается формой смены пароля и вводится формой входа.
//
// 36 кириллических букв — это ровно 72 байта, то есть последнее значение,
// которое GenerateFromPassword принимает. Проверяется весь путь: хеширование
// при создании учётной записи и вход этим же значением.
func TestLoginEndpointAcceptsPasswordAtBcryptByteLimit(t *testing.T) {
password := strings.Repeat("я", 36)
if len(password) != credential.AdminPasswordMaxBytes {
t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes)
}
newAuthTestDB(t, "hy2xsadmin", password)
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
if result.Code != constant.CodeSuccess {
t.Fatalf("пароль в 72 байта отклонён: %+v", result)
}
}
// А на символ длиннее — отказ ПРАВИЛА, с причиной на поле, а не системная
// ошибка из bcrypt.
//
// Ровно этого не было: 37 кириллических букв (74 байта) укладывались в 64
// символа, проходили все проверки и упирались в ErrPasswordTooLong уже внутри
// хеширования. На форме смены пароля оператор видел «system error».
func TestLoginEndpointRejectsPasswordOverBcryptByteLimitWithFieldReason(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
password := strings.Repeat("я", 37)
if len(password) <= credential.AdminPasswordMaxBytes {
t.Fatalf("подготовка: %d байт, ожидалось больше %d", len(password), credential.AdminPasswordMaxBytes)
}
status, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
if status != http.StatusOK {
t.Fatalf("HTTP %d: обработчик обязан отвечать конвертом", status)
}
if result.Code != constant.CodeInvalidError {
t.Fatalf("код ответа %d, ожидался отказ проверки данных %d: %+v", result.Code, constant.CodeInvalidError, result)
}
var hasReason bool
for _, reason := range result.Errors {
if reason.Field == "pass" && reason.Code == constant.ErrCodeAdminPassword {
hasReason = true
}
}
if !hasReason {
t.Fatalf("отказ не назван причиной %q на поле pass: %+v", constant.ErrCodeAdminPassword, result)
}
}
// Отметка о входе обязана писаться: колонка есть в схеме, а писать её было // Отметка о входе обязана писаться: колонка есть в схеме, а писать её было
// некому — UpdateAdminLastLoginAt не вызывался ниоткуда. // некому — UpdateAdminLastLoginAt не вызывался ниоткуда.
func TestLoginEndpointRecordsLastLoginAt(t *testing.T) { func TestLoginEndpointRecordsLastLoginAt(t *testing.T) {
+41 -5
View File
@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"hy2xs-admin/credential"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/dto" "hy2xs-admin/model/dto"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
@@ -33,6 +34,7 @@ func init() {
mustRegister("peerName", validatePeerName) mustRegister("peerName", validatePeerName)
mustRegister("credentialStr", validateCredentialStr) mustRegister("credentialStr", validateCredentialStr)
mustRegister("adminPassword", validateAdminPassword)
} }
func mustRegister(tag string, fn validator.Func) { func mustRegister(tag string, fn validator.Func) {
@@ -74,7 +76,23 @@ func validatePeerName(f validator.FieldLevel) bool {
// его в «не задано», и получает отказ `required` — то есть ту причину, которая // его в «не задано», и получает отказ `required` — то есть ту причину, которая
// у него на самом деле. // у него на самом деле.
func validateCredentialStr(f validator.FieldLevel) bool { func validateCredentialStr(f validator.FieldLevel) bool {
return service.IsValidAdminUsername(f.Field().String()) return credential.IsValidAdminUsername(f.Field().String())
}
// validateAdminPassword — единственное правило пароля администратора.
//
// Здесь стояли `min=6,max=64` прямо в теге, и это была та же конструкция, от
// которой уже отказались на логине: границы жили РЯДОМ с правилом, а не внутри
// него. У пароля она вдобавок была неполна. Тег `max=64` считает символы
// Unicode, а bcrypt считает байты, поэтому пароль из 64 кириллических букв —
// 128 байт — проходил проверку целиком и падал только в хешировании, уже как
// системная ошибка на штатной смене пароля.
//
// Границу в байтах тегом не выразить: у валидатора нет правила длины в байтах.
// Поэтому всё правило целиком переехало в contract-функцию, и теперь у формы
// входа, формы смены пароля, bootstrap-установки и `reset-admin` она одна.
func validateAdminPassword(f validator.FieldLevel) bool {
return credential.IsValidAdminPassword(f.Field().String())
} }
// validateField разбирает запрос, приводит его к каноничному виду и проверяет // validateField разбирает запрос, приводит его к каноничному виду и проверяет
@@ -208,13 +226,31 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
// отказ по длине этой фразой описывался как отказ по символам. // отказ по длине этой фразой описывался как отказ по символам.
described.Code = constant.ErrCodeCredentialStr described.Code = constant.ErrCodeCredentialStr
described.Params = map[string]string{ described.Params = map[string]string{
"min": fmt.Sprintf("%d", service.AdminUsernameMinLength), "min": fmt.Sprintf("%d", credential.AdminUsernameMinLength),
"max": fmt.Sprintf("%d", service.AdminUsernameMaxLength), "max": fmt.Sprintf("%d", credential.AdminUsernameMaxLength),
"charset": service.AdminUsernameCharset, "charset": credential.AdminUsernameCharset,
} }
described.Message = fmt.Sprintf( described.Message = fmt.Sprintf(
"поле %q: от %d до %d символов из набора %s", "поле %q: от %d до %d символов из набора %s",
field, service.AdminUsernameMinLength, service.AdminUsernameMaxLength, service.AdminUsernameCharset, field, credential.AdminUsernameMinLength, credential.AdminUsernameMaxLength, credential.AdminUsernameCharset,
)
case "adminPassword":
// Причина называет ОБЕ границы, потому что их две и они в разных
// единицах. Отказ «не длиннее 64 символов» на пароле из 40 эмодзи был
// бы неправдой: символов там 40, а байтов 160, и оператор, послушав
// такую причину, сокращал бы пароль, который отвергнут не за это.
described.Code = constant.ErrCodeAdminPassword
described.Params = map[string]string{
"min": fmt.Sprintf("%d", credential.AdminPasswordMinLength),
"max": fmt.Sprintf("%d", credential.AdminPasswordMaxLength),
"maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes),
}
described.Message = fmt.Sprintf(
"поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов",
field,
credential.AdminPasswordMinLength,
credential.AdminPasswordMaxLength,
credential.AdminPasswordMaxBytes,
) )
default: default:
described.Code = constant.ErrCodeRuleUnknown described.Code = constant.ErrCodeRuleUnknown
+47 -79
View File
@@ -4,96 +4,64 @@ import (
"strings" "strings"
"testing" "testing"
"hy2xs-admin/credential"
"hy2xs-admin/service" "hy2xs-admin/service"
) )
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством. // Правила валидатора обязаны спрашивать КОНТРАКТ, а не собственную копию.
// //
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный // Прежние версии этих проверок обращались к регекспу внутри слоя контроллеров и
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`. // потому не увидели бы расхождения между копией и правилом — а именно так этот
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя // класс дефектов и появлялся: набор символов логина существовал в четырёх
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила // местах, и все четыре разошлись. Сам контракт проверяется в пакете credential;
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать // здесь проверяется ПРОВОДКА: тег валидатора и функция контракта отвечают
// оператора в панель. // одинаково на одних и тех же значениях.
// func TestCredentialStrTagDelegatesToContract(t *testing.T) {
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в for _, candidate := range []string{
// классе символов уронит его, а не вход администратора на живом сервере. "hy2xsadmin",
// "admin.ops",
// Спрашивается service.IsValidAdminUsername, а не регексп рядом: правило "admin+1",
// объявлено там один раз, и проверка обязана спрашивать ровно то, что работает "admi",
// в бою. Прежняя версия обращалась к копии внутри слоя контроллеров — то есть "админ12",
// не увидела бы расхождения между копией и правилом. "admin пробел",
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) { strings.Repeat("a", credential.AdminUsernameMaxLength+1),
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
for _, symbol := range strings.Split(historical, "") {
candidate := "admin" + symbol
if !service.IsValidAdminUsername(candidate) {
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
}
}
for _, rejected := range []string{
"", // не задано
"admi", // короче шести символов
strings.Repeat("a", 33), // длиннее тридцати двух
"admin пробел", // пробел
"админ1", // кириллица
"admin\n1", // перевод строки
"admin'1", // апостроф вне набора
} { } {
if service.IsValidAdminUsername(rejected) { expected := credential.IsValidAdminUsername(candidate)
t.Errorf("значение %q принято логином, ожидался отказ", rejected) if got := validate.Var(candidate, "credentialStr") == nil; got != expected {
t.Errorf("тег credentialStr на %q дал %v, контракт — %v", candidate, got, expected)
} }
} }
} }
// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор // Тот же вопрос про пароль, и он важнее.
// обязан описывать тот же класс, что и регексп.
// //
// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию: // Границы пароля стояли прямо в теге (`min=6,max=64`), и тег НЕ МОГ выразить
// оператор читает набор, которого правило не принимает. // предел bcrypt в байтах — правила длины в байтах у go-playground/validator
func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) { // нет. Поэтому пароль из 64 кириллических букв проходил валидацию формы и
if service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMinLength-1)) { // падал в хешировании, возвращаясь оператору системной ошибкой. Проверка
t.Errorf("логин длиной %d принят при минимуме %d", service.AdminUsernameMinLength-1, service.AdminUsernameMinLength) // закрепляет, что правило теперь одно и что оно то же самое, которым
// пользуются bootstrap-установка и `reset-admin`.
func TestAdminPasswordTagDelegatesToContract(t *testing.T) {
for _, candidate := range []string{
"bootstrap-password",
"abcde ",
strings.Repeat("a", credential.AdminPasswordMinLength-1),
strings.Repeat("a", credential.AdminPasswordMaxLength),
strings.Repeat("a", credential.AdminPasswordMaxLength+1),
strings.Repeat("я", 36),
strings.Repeat("я", 37),
strings.Repeat("😀", 18),
strings.Repeat("😀", 19),
strings.Repeat("я", credential.AdminPasswordMaxLength),
"abcde\n",
} {
expected := credential.IsValidAdminPassword(candidate)
if got := validate.Var(candidate, "adminPassword") == nil; got != expected {
t.Errorf(
"тег adminPassword на значении из %d символов / %d байт дал %v, контракт — %v",
len([]rune(candidate)), len(candidate), got, expected,
)
} }
if !service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMinLength)) {
t.Errorf("логин длиной ровно %d отклонён", service.AdminUsernameMinLength)
}
if !service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMaxLength)) {
t.Errorf("логин длиной ровно %d отклонён", service.AdminUsernameMaxLength)
}
if service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMaxLength+1)) {
t.Errorf("логин длиной %d принят при максимуме %d", service.AdminUsernameMaxLength+1, service.AdminUsernameMaxLength)
}
// Человекочитаемый набор перечисляет ровно те же знаки препинания, что и
// класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ.
punctuation := strings.Fields(service.AdminUsernameCharset)
for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") {
if !service.IsValidAdminUsername("admin" + symbol) {
t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol)
}
}
}
// Длина пароля считается в РУНАХ — ровно так её считает go-playground/validator
// в правилах `min`/`max` на строке.
//
// Расхождение означало бы, что пароль с кириллицей проходит проверку на одной
// двери и отказывается на другой: 32 кириллические буквы — это 64 БАЙТА.
func TestAdminPasswordLengthIsCountedInRunes(t *testing.T) {
if !service.IsValidAdminPassword(strings.Repeat("я", service.AdminPasswordMaxLength)) {
t.Errorf("пароль из %d кириллических букв отклонён: длина считается в байтах", service.AdminPasswordMaxLength)
}
if service.IsValidAdminPassword(strings.Repeat("я", service.AdminPasswordMaxLength+1)) {
t.Error("пароль длиннее максимума принят")
}
if service.IsValidAdminPassword(strings.Repeat("a", service.AdminPasswordMinLength-1)) {
t.Error("пароль короче минимума принят")
}
if !service.IsValidAdminPassword(strings.Repeat("a", service.AdminPasswordMinLength)) {
t.Error("пароль длиной ровно в минимум отклонён")
} }
} }
+173
View File
@@ -0,0 +1,173 @@
// Package credential объявляет контракт учётных данных администратора — ОДИН
// на весь продукт.
//
// Почему это отдельный пакет, а не файл в service. Контракт нужен четырём
// слоям сразу, и два из них лежат НИЖЕ service по графу импортов:
//
// controller -> service -> util (хеширование пароля)
// dao -> util (создание первой учётной записи)
// cmd -> dao, util (reset-admin)
//
// Пока контракт жил в service, `util.HashPassword` не мог его позвать — импорт
// был бы циклическим, — и завёл собственную проверку `len(TrimSpace(p)) < 6`.
// Эта третья копия правила разошлась с остальными ровно тем способом, ради
// предотвращения которого контракт и объявлен: LoginDto считал `"abcde "`
// корректным паролем, а HashPassword отказывался его хешировать. Пакет-лист
// ниже всех потребителей — единственная запись, при которой у правила
// физически не может появиться второго экземпляра.
package credential
import (
"regexp"
"strconv"
"strings"
"unicode/utf8"
)
// Контракт учётных данных администратора.
//
// Про логин и пароль администратора одновременно знали четыре места: регексп в
// слое контроллеров, границы длины в тегах LoginDto, правило формы входа в
// панели и правило формы смены пароля в ней же. Ни одно из них не ссылалось на
// остальные, и все четыре успели разойтись:
//
// - тег `validateStr` на Username пережил переименование правила и остался
// ссылкой на несуществующую функцию — валидатор паниковал на КАЖДОМ входе,
// а gin.Recovery превращал панику в HTTP 500;
// - форма входа ограничивала пароль 32 символами, форма смены пароля — 64,
// сервер — тоже 64. То есть пароль, назначенный штатной формой смены,
// форма входа отказывалась отправлять: панель запирала оператора снаружи
// после операции, которую сама же и предложила;
// - оркестратор не проверял HY2XS_ADMIN_USER вовсе и по умолчанию писал в
// hy2xs.env `admin` — пять символов при минимуме в шесть. Установка
// завершалась успешно, а войти в панель было нельзя.
//
// Общее у всех трёх — контракт существовал только как совпадение литералов,
// которое ничто не проверяло. Теперь он объявлен здесь, слой контроллеров зовёт
// IsValidAdminUsername и IsValidAdminPassword, а расхождение с панелью и
// оркестратором ловится тестами, читающими этот же файл.
const (
AdminUsernameMinLength = 6
AdminUsernameMaxLength = 32
// Границы пароля в СИМВОЛАХ Unicode (code points).
AdminPasswordMinLength = 6
AdminPasswordMaxLength = 64
// AdminPasswordMaxBytes — предел, установленный bcrypt.
//
// Он объявлен ОТДЕЛЬНО от AdminPasswordMaxLength, потому что это ДРУГАЯ
// единица измерения, и подменять одну другой нельзя.
//
// Здесь стоял ровно этот дефект. Верхняя граница в 64 символа была выбрана
// «заведомо ниже 72 байт», и обоснование выглядело убедительно, пока пароль
// оставался ASCII. Но `golang.org/x/crypto/bcrypt` считает БАЙТЫ:
//
// 64 x "a" = 64 байта -> GenerateFromPassword принимает
// 64 x "я" = 128 байт -> ErrPasswordTooLong
// 19 x "😀" = 76 байт -> ErrPasswordTooLong
//
// причём отказ приходил НЕ на форме, а из хеширования: смена пароля на 64
// кириллические буквы проходила все проверки продукта и заканчивалась
// системной ошибкой на штатной операции. Комментарий в прежней редакции
// вдобавок утверждал, что bcrypt «молча отбрасывает остаток» — так вело
// себя издание пакета до v0.28; действующее (v0.55.0, bcrypt.go:96)
// отвечает ошибкой:
//
// if len(password) > 72 { return nil, ErrPasswordTooLong }
AdminPasswordMaxBytes = 72
)
// 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 — единственное правило пароля администратора.
//
// Правило состоит из трёх частей и НИ ОДНА из них не является набором
// символов:
//
// 6-64 code points — граница, которую видит оператор;
// <= 72 UTF-8 bytes — граница, которую ставит bcrypt;
// без управляющих — граница, которую ставит транспорт.
//
// Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает
// go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её
// видит оператор: «пароль из 64 символов» обязано означать одно и то же для
// латиницы и для кириллицы.
//
// Предел в БАЙТАХ существует одновременно с ним, потому что это ограничение
// другого слоя — хранилища пароля, — и выразить его через символы нельзя: у
// 64 символов длина от 64 до 256 байт в зависимости от алфавита.
//
// Управляющие символы запрещены, и это единственное ограничение НАБОРА в
// контракте. Оно введено не ради строгости: первый пароль администратора
// уезжает в /etc/hy2xs/hy2xs.env, который systemd читает как EnvironmentFile, и
// у перевода строки там нет представления, переживающего запись и чтение. При
// этом такой пароль всё равно невозможно ввести в однострочное поле формы
// входа, то есть он умеет ровно одно — запереть оператора снаружи. Запрет
// делает транспорт ПОЛНЫМ: всякий пароль, принятый этим правилом, доезжает до
// bcrypt побайтово тем же, каким его задал оператор.
//
// Набор символов НЕ ограничивается ничем сверх этого. Пароль назначает
// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, —
// и сервер нигде его набор не проверяет. Ограничение на форме входа не
// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает
// пароль, который сервер принял бы.
//
// Пробелы по краям — ЧАСТЬ пароля и не снимаются нигде: ни здесь, ни в
// нормализации DTO, ни при чтении bootstrap-окружения.
func IsValidAdminPassword(password string) bool {
runes := utf8.RuneCountInString(password)
if runes < AdminPasswordMinLength || runes > AdminPasswordMaxLength {
return false
}
if len(password) > AdminPasswordMaxBytes {
return false
}
return !hasControlRune(password)
}
// hasControlRune сообщает, есть ли в строке управляющий символ.
//
// Проверяются C0 (U+0000-U+001F) и DEL (U+007F). Управляющие символы Unicode
// вне ASCII сюда не попадают намеренно: у systemd границей строки является
// байт `\n` или `\r`, и U+2028 для него — три обычных байта, а не перевод
// строки. Правило описывает ровно то, что ломает транспорт, и ни символом
// больше.
func hasControlRune(value string) bool {
for _, r := range value {
if r < 0x20 || r == 0x7F {
return true
}
}
return false
}
+178
View File
@@ -0,0 +1,178 @@
package credential
import (
"strings"
"testing"
"unicode/utf8"
)
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством.
//
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
// оператора в панель.
//
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
// классе символов уронит его, а не вход администратора на живом сервере.
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) {
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
for _, symbol := range strings.Split(historical, "") {
candidate := "admin" + symbol
if !IsValidAdminUsername(candidate) {
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
}
}
for _, rejected := range []string{
"", // не задано
"admi", // короче шести символов
strings.Repeat("a", 33), // длиннее тридцати двух
"admin пробел", // пробел
"админ1", // кириллица
"admin\n1", // перевод строки
"admin'1", // апостроф вне набора
} {
if IsValidAdminUsername(rejected) {
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
}
}
}
// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор
// обязан описывать тот же класс, что и регексп.
//
// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию:
// оператор читает набор, которого правило не принимает.
func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) {
if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength-1)) {
t.Errorf("логин длиной %d принят при минимуме %d", AdminUsernameMinLength-1, AdminUsernameMinLength)
}
if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength)) {
t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMinLength)
}
if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength)) {
t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMaxLength)
}
if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength+1)) {
t.Errorf("логин длиной %d принят при максимуме %d", AdminUsernameMaxLength+1, AdminUsernameMaxLength)
}
// Человекочитаемый набор перечисляет ровно те же знаки препинания, что и
// класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ.
punctuation := strings.Fields(AdminUsernameCharset)
for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") {
if !IsValidAdminUsername("admin" + symbol) {
t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol)
}
}
}
// Длина пароля считается В СИМВОЛАХ, а предел bcrypt — В БАЙТАХ, и это разные
// границы.
//
// Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72
// байт», и это верно ТОЛЬКО для ASCII. Тест, закреплявший поведение, вдобавок
// требовал приёма 64 кириллических букв — то есть 128 байт — и тем самым
// фиксировал как ожидаемое ровно то значение, на котором продукт ломался:
// оркестратор его пропускал, DTO пропускало, а bcrypt отвечал
// ErrPasswordTooLong уже при создании учётной записи.
func TestAdminPasswordBoundaries(t *testing.T) {
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, а «😀» —
// четырёхбайтовым, случаи ниже проверяли бы не то, что написано.
if got := len(strings.Repeat("я", 36)); got != AdminPasswordMaxBytes {
t.Fatalf("подготовка: 36 x «я» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes)
}
if got := len(strings.Repeat("😀", 18)); got != AdminPasswordMaxBytes {
t.Fatalf("подготовка: 18 x «😀» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes)
}
cases := []struct {
name string
password string
accepted bool
}{
{"ровно минимум", strings.Repeat("a", AdminPasswordMinLength), true},
{"на символ короче минимума", strings.Repeat("a", AdminPasswordMinLength-1), false},
{"ровно максимум в ASCII", strings.Repeat("a", AdminPasswordMaxLength), true},
{"на символ длиннее максимума", strings.Repeat("a", AdminPasswordMaxLength+1), false},
// Граница bcrypt проходит ВНУТРИ разрешённого диапазона символов.
{"36 x «я» = 72 байта", strings.Repeat("я", 36), true},
{"37 x «я» = 74 байта", strings.Repeat("я", 37), false},
{"18 x «😀» = 72 байта", strings.Repeat("😀", 18), true},
{"19 x «😀» = 76 байт", strings.Repeat("😀", 19), false},
// Исходный дефект: 64 символа, 128 байт. Прежний тест требовал ПРИЁМА.
{"64 x «я» = 128 байт", strings.Repeat("я", AdminPasswordMaxLength), false},
// Пробелы по краям — часть пароля, а не мусор ввода.
{"пробел на конце", "abcde ", true},
{"пробел в начале", " abcde", true},
{"только пробелы", strings.Repeat(" ", AdminPasswordMinLength), true},
// Набор символов не ограничен ничем, кроме управляющих.
{"кириллица", "пароль-администратора", true},
{"знаки препинания", `p@$$w0rd"\'#;`, true},
{"перевод строки", "abcde\n", false},
{"возврат каретки", "abcde\r", false},
{"табуляция", "abcde\t", false},
{"нулевой байт", "abcde\x00", false},
{"DEL", "abcde\x7f", false},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
if got := IsValidAdminPassword(testCase.password); got != testCase.accepted {
t.Errorf(
"IsValidAdminPassword = %v, ожидалось %v (%d символов, %d байт)",
got, testCase.accepted,
utf8.RuneCountInString(testCase.password), len(testCase.password),
)
}
})
}
}
// Предел в байтах не выводится из предела в символах, и обратное неверно тоже.
//
// Проверка защищает от «упрощения», при котором одну из двух констант объявят
// производной от другой: у 64 символов длина от 64 до 256 байт.
func TestAdminPasswordLimitsAreIndependent(t *testing.T) {
if AdminPasswordMaxBytes != 72 {
t.Fatalf(
"предел bcrypt изменён на %d: GenerateFromPassword отвечает ErrPasswordTooLong строго при len > 72",
AdminPasswordMaxBytes,
)
}
// Пароль, укладывающийся в символы и не укладывающийся в байты, обязан
// существовать — иначе байтовая граница недостижима и ничего не проверяет.
long := strings.Repeat("я", AdminPasswordMaxLength)
if utf8.RuneCountInString(long) > AdminPasswordMaxLength {
t.Fatal("подготовка: значение длиннее предела в символах, проверяется не та граница")
}
if IsValidAdminPassword(long) {
t.Fatal("пароль в 128 байт принят: байтовая граница не проверяется")
}
}
// Пароль НЕ триммится нигде, включая сам контракт.
//
// Пока `util.HashPassword` вёл собственную проверку через strings.TrimSpace,
// значение `"abcde "` считалось корректным всеми дверями продукта и не могло
// быть захешировано.
func TestAdminPasswordKeepsSurroundingWhitespace(t *testing.T) {
// Шесть символов, из которых последний — пробел. По длине без пробела это
// пять символов, то есть прежняя проверка отказала бы.
const password = "abcde "
if utf8.RuneCountInString(strings.TrimSpace(password)) >= AdminPasswordMinLength {
t.Fatal("подготовка: значение проходит и после тримминга, проверяется не то")
}
if !IsValidAdminPassword(password) {
t.Error("пароль с краевым пробелом отклонён: пробел объявлен частью значения")
}
}
+104
View File
@@ -0,0 +1,104 @@
package dao
import (
"path/filepath"
"strings"
"testing"
"hy2xs-admin/credential"
"hy2xs-admin/util"
)
// Bootstrap-путь администратора обязан подчиняться ОБЩЕМУ контракту пароля.
//
// Здесь было два расхождения, и оба ломали установку молча.
//
// Первое: `strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Продукт
// объявляет пробел по краям частью пароля и не снимает его ни на форме входа,
// ни при смене, ни в оркестраторе — а здесь снимал. Учётная запись создавалась
// с паролем, отличным от того, который оператор записал в hy2xs.env и которым
// он потом пытался войти.
//
// Второе: контракт не проверялся вовсе, поэтому непригодный для bcrypt пароль
// (64 кириллические буквы = 128 байт) доходил до GenerateFromPassword, и старт
// службы падал с «bcrypt: password length exceeds 72 bytes» — сообщением, по
// которому нельзя понять ни что чинить, ни где.
func startAdminBootstrap(t *testing.T, password string) error {
t.Helper()
t.Setenv("HY2XS_ADMIN_USER", "hy2xsadmin")
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", password)
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
err := InitSqlAt(filepath.Join(t.TempDir(), "hy2xs-admin-test.db"))
t.Cleanup(func() { _ = CloseSqliteDB() })
return err
}
// Пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом.
func TestBootstrapAdminKeepsSurroundingWhitespaceInPassword(t *testing.T) {
const password = "bootstrap-admin-password "
if err := startAdminBootstrap(t, password); err != nil {
t.Fatalf("запуск сервиса не удался: %v", err)
}
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
if err != nil {
t.Fatalf("учётная запись администратора не создана: %v", err)
}
if admin.PasswordHash == nil {
t.Fatal("у созданной учётной записи нет хеша пароля")
}
if !util.VerifyPassword(password, *admin.PasswordHash) {
t.Error("вход исходным значением невозможен: пароль обрезан при создании учётной записи")
}
if util.VerifyPassword(strings.TrimSpace(password), *admin.PasswordHash) {
t.Error("вход обрезанным значением возможен: пробел по краям снят вопреки контракту")
}
}
// Пароль, не проходящий контракт, роняет старт с ПРИЧИНОЙ, а не с ошибкой
// bcrypt, и учётной записи после этого не остаётся.
func TestBootstrapAdminRefusesPasswordOutsideTheContract(t *testing.T) {
// 64 символа — в границе; 128 байт — за пределом bcrypt.
password := strings.Repeat("я", credential.AdminPasswordMaxLength)
if credential.IsValidAdminPassword(password) {
t.Fatal("подготовка: контракт принимает значение, проверяется не то")
}
err := startAdminBootstrap(t, password)
if err == nil {
t.Fatal("старт с непригодным паролем завершился успешно")
}
if !strings.Contains(err.Error(), "HY2XS_ADMIN_INITIAL_PASSWORD") {
t.Errorf("отказ не называет переменную, которую надо чинить: %v", err)
}
if !strings.Contains(err.Error(), "hy2xs.env") {
t.Errorf("отказ не называет файл, в котором чинится причина: %v", err)
}
}
// Пароль ровно в предел bcrypt (72 байта) — законное значение, и установка с
// ним обязана проходить целиком.
func TestBootstrapAdminAcceptsPasswordAtBcryptByteLimit(t *testing.T) {
password := strings.Repeat("я", 36)
if len(password) != credential.AdminPasswordMaxBytes {
t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes)
}
if err := startAdminBootstrap(t, password); err != nil {
t.Fatalf("установка с паролем в 72 байта не удалась: %v", err)
}
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
if err != nil {
t.Fatalf("учётная запись администратора не создана: %v", err)
}
if !util.VerifyPassword(password, *admin.PasswordHash) {
t.Error("пароль в 72 байта не проходит проверку собственным хешем")
}
}
+33 -1
View File
@@ -3,11 +3,13 @@ package dao
import ( import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
"gorm.io/gorm/schema" "gorm.io/gorm/schema"
"hy2xs-admin/credential"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"hy2xs-admin/util" "hy2xs-admin/util"
@@ -132,7 +134,14 @@ func ensureSecureBootstrapAdmin() error {
return nil return nil
} }
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")) // Пароль читается КАК ЕСТЬ.
//
// Здесь стоял strings.TrimSpace, и он противоречил контракту продукта:
// пробел по краям объявлен частью пароля и не снимается ни на форме входа,
// ни при смене пароля, ни в оркестраторе. То есть bootstrap-путь
// гарантированно создавал учётную запись НЕ с тем паролем, который оператор
// записал в hy2xs.env, а войти в неё он пытался бы исходным значением.
adminPassword := os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")
if adminPassword == "" { if adminPassword == "" {
return errors.New( return errors.New(
"HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" + "HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" +
@@ -144,6 +153,28 @@ func ensureSecureBootstrapAdmin() error {
"`hy2xs-orchestrator repair --allow-partial-state`.", "`hy2xs-orchestrator repair --allow-partial-state`.",
) )
} }
// Контракт проверяется ЗДЕСЬ, а не только при хешировании.
//
// Отказ bcrypt на непригодном пароле выглядел бы как «bcrypt: password
// length exceeds 72 bytes» в journal — сообщение, по которому оператор не
// поймёт ни что чинить, ни где. Причина же чинится одной строкой в
// hy2xs.env, и назвать её обязан тот, кто знает про этот файл.
if !credential.IsValidAdminPassword(adminPassword) {
return fmt.Errorf(
"HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели: "+
"от %d до %d символов, не более %d байт в UTF-8, без управляющих символов.\n"+
"Набор символов не ограничен, пробелы по краям являются частью пароля.\n"+
"Учётная запись администратора с таким значением создана НЕ будет: "+
"установка завершилась бы успешно, а войти в панель было бы нельзя.\n"+
"Исправьте значение в /etc/hy2xs/hy2xs.env и запустите\n"+
"`hy2xs-orchestrator repair --allow-partial-state`.",
credential.AdminPasswordMinLength,
credential.AdminPasswordMaxLength,
credential.AdminPasswordMaxBytes,
)
}
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1) forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
status := int64(1) status := int64(1)
tokenVersion := int64(1) tokenVersion := int64(1)
@@ -709,6 +740,7 @@ func tableExists(tableName string) bool {
} }
return count > 0 return count > 0
} }
// ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ // ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ
// придумывает его сам. // придумывает его сам.
// //
+93 -4
View File
@@ -15,7 +15,7 @@
* принял бы, — панель была строже сервера там, где она не имеет на это права. * принял бы, — панель была строже сервера там, где она не имеет на это права.
* *
* Правило теперь одно на обе формы, и оно сверяется с Go-контрактом * Правило теперь одно на обе формы, и оно сверяется с Go-контрактом
* (apps/service/admin_credentials.go) тестом tools/test/frontend-contract.test.ts. * (apps/credential/admin.go) тестом tools/test/frontend-contract.test.ts.
*/ */
export const ADMIN_USERNAME_MIN_LENGTH = 6; export const ADMIN_USERNAME_MIN_LENGTH = 6;
@@ -41,9 +41,98 @@ export const ADMIN_USERNAME_PATTERN = new RegExp(
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<="; export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
/** /**
* Границы пароля. Набора символов у пароля НЕТ: сервер его не ограничивает ни * Границы пароля в СИМВОЛАХ Unicode. Набора символов у пароля НЕТ: сервер его
* при установке, ни при смене, и панель не имеет права отвергать значение, * не ограничивает ни при установке, ни при смене, и панель не имеет права
* которое сервер принял бы. * отвергать значение, которое сервер принял бы.
*/ */
export const ADMIN_PASSWORD_MIN_LENGTH = 6; export const ADMIN_PASSWORD_MIN_LENGTH = 6;
export const ADMIN_PASSWORD_MAX_LENGTH = 64; export const ADMIN_PASSWORD_MAX_LENGTH = 64;
/**
* Предел bcrypt — в БАЙТАХ, и он существует одновременно с границей в символах.
*
* `golang.org/x/crypto/bcrypt` отвечает `ErrPasswordTooLong` на пароль длиннее
* 72 байт. Через границу в символах это не выражается: у 64 символов длина от
* 64 до 256 байт в зависимости от алфавита.
*/
export const ADMIN_PASSWORD_MAX_BYTES = 72;
/**
* Единственное правило пароля на стороне панели.
*
* Почему не встроенные `min`/`max` Element Plus. Правила формы Element Plus
* делегирует библиотеке `async-validator`, а она сравнивает `min`/`max` строки
* с `String.prototype.length`, то есть с числом единиц UTF-16. Go и оркестратор
* считают code points. Расхождение не теоретическое:
*
* "😀😀😀" -> Go: 3 руны -> сервер отказывает (минимум 6)
* JS: value.length = 6 -> форма считает минимум достигнутым
*
* то есть панель отправляла бы заведомо отвергаемый пароль и не могла бы
* объяснить отказ. Обратная сторона того же — пароль из 40 эмодзи: 40 code
* points укладываются в границу символов и не укладываются в 72 байта.
*
* Поэтому правило одно и считает ровно то же, что сервер: code points через
* итератор строки (он идёт по code points, а не по единицам UTF-16) и байты
* через `TextEncoder`, который кодирует именно в UTF-8.
*
* Управляющие символы (C0 и DEL) — единственное ограничение НАБОРА в
* контракте, и оно принадлежит не форме, а транспорту: первый пароль
* администратора уезжает в EnvironmentFile systemd, где у перевода строки нет
* представления, переживающего запись и чтение. Проверяются они перебором кодов,
* а не классом регекспа: литеральные управляющие символы в исходнике невидимы,
* и такое правило нельзя проверить глазами при ревью.
*/
export function isValidAdminPassword(value: string): boolean {
let characters = 0;
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) {
return false;
}
characters += 1;
}
if (
characters < ADMIN_PASSWORD_MIN_LENGTH ||
characters > ADMIN_PASSWORD_MAX_LENGTH
) {
return false;
}
return new TextEncoder().encode(value).length <= ADMIN_PASSWORD_MAX_BYTES;
}
/**
* Правило пароля в том виде, в каком его принимает `el-form`.
*
* Собрано здесь, а не в каждой форме, по той же причине, по которой здесь живёт
* сам контракт: у формы входа и формы смены пароля правило обязано быть одним и
* тем же объектом кода, а не двумя совпадающими записями. Пока они были
* двумя, они разошлись — 32 против 64 — и панель заперла оператора снаружи
* после штатной смены пароля.
*
* Пустое значение пропускается: о нём отчитывается правило `required`, и без
* этого оператор, отправивший пустую форму, читал бы про границы длины вместо
* «поле обязательно». Сервер ведёт себя ровно так же — DTO приводит пустой
* пароль к «не задано» до проверки правил.
*
* `message` передаётся аргументом: фраза принадлежит словарю локализации, а
* `t()` доступен только внутри компонента.
*/
export function adminPasswordFormRule(message: string) {
return {
validator: (
_rule: unknown,
value: string,
callback: (error?: Error) => void
) => {
if ((value ?? "") === "" || isValidAdminPassword(value)) {
callback();
return;
}
callback(new Error(message));
},
trigger: ["change", "blur"] as string[],
};
}
+4 -1
View File
@@ -26,7 +26,8 @@ export default {
// смены пароля. См. комментарий в ru.ts. // смены пароля. См. комментарий в ru.ts.
credentials: { credentials: {
usernameFormat: "Username: {min} to {max} characters from {charset}", usernameFormat: "Username: {min} to {max} characters from {charset}",
passwordLength: "Password: {min} to {max} characters", passwordFormat:
"Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters",
}, },
dashboard: { dashboard: {
stale: "Dashboard data is stale. Retrying automatically...", stale: "Dashboard data is stale. Retrying automatically...",
@@ -166,6 +167,8 @@ export default {
peer_name: peer_name:
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed", "“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
credential_format: "“{field}”: {min} to {max} characters from {charset}", credential_format: "“{field}”: {min} to {max} characters from {charset}",
admin_password_format:
"“{field}”: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters",
rule_violated: "“{field}”: value is not acceptable", rule_violated: "“{field}”: value is not acceptable",
validation_failed: "Validation failed", validation_failed: "Validation failed",
body_invalid: body_invalid:
+8 -1
View File
@@ -31,7 +31,12 @@ export default {
// приходит из hy2xs.env, и посмотреть его в панели негде. // приходит из hy2xs.env, и посмотреть его в панели негде.
credentials: { credentials: {
usernameFormat: "Логин: от {min} до {max} символов из набора {charset}", usernameFormat: "Логин: от {min} до {max} символов из набора {charset}",
passwordLength: "Пароль: от {min} до {max} символов", // Фраза называет ОБЕ границы, потому что их две и они в разных единицах.
// Пароль из 40 эмодзи укладывается в 64 символа и не укладывается в 72
// байта; сообщение «не длиннее {max} символов» отправило бы оператора
// сокращать пароль, отвергнутый не за это.
passwordFormat:
"Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов",
}, },
dashboard: { dashboard: {
stale: stale:
@@ -183,6 +188,8 @@ export default {
// словами отказ по ДЛИНЕ: правило одно, и оно проверяет и то, и другое. // словами отказ по ДЛИНЕ: правило одно, и оно проверяет и то, и другое.
credential_format: credential_format:
"«{field}»: от {min} до {max} символов из набора {charset}", "«{field}»: от {min} до {max} символов из набора {charset}",
admin_password_format:
"«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов",
rule_violated: "«{field}»: значение не подходит", rule_violated: "«{field}»: значение не подходит",
validation_failed: "Проверка данных не пройдена", validation_failed: "Проверка данных не пройдена",
body_invalid: "Запрос не разобран: проверьте формат и типы полей", body_invalid: "Запрос не разобран: проверьте формат и типы полей",
+5
View File
@@ -44,6 +44,11 @@ export const ERR_CODE = {
ruleViolated: "rule_violated", ruleViolated: "rule_violated",
peerName: "peer_name", peerName: "peer_name",
credentialFormat: "credential_format", credentialFormat: "credential_format",
// Правило пароля администратора целиком: длина в символах, длина в байтах
// (предел bcrypt) и запрет управляющих символов. Отдельный код нужен потому,
// что min_length/max_length не умеют назвать отказ по БАЙТАМ, а он бывает у
// пароля, укладывающегося в границу символов.
adminPasswordFormat: "admin_password_format",
peerNameTaken: "peer_name_taken", peerNameTaken: "peer_name_taken",
peerNameReserved: "peer_name_reserved", peerNameReserved: "peer_name_reserved",
peerBootstrapLocked: "peer_bootstrap_identity_locked", peerBootstrapLocked: "peer_bootstrap_identity_locked",
@@ -34,8 +34,10 @@ import { useRoute, useRouter } from "vue-router";
import { adminChangePasswordApi } from "@/api/admin"; import { adminChangePasswordApi } from "@/api/admin";
import { useAdminStore } from "@/store/modules/admin"; import { useAdminStore } from "@/store/modules/admin";
import { import {
ADMIN_PASSWORD_MAX_BYTES,
ADMIN_PASSWORD_MAX_LENGTH, ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH, ADMIN_PASSWORD_MIN_LENGTH,
adminPasswordFormRule,
} from "@/constants/credentials"; } from "@/constants/credentials";
const { t } = useI18n(); const { t } = useI18n();
@@ -60,18 +62,22 @@ const form = reactive({
// Границы совпадают с формой входа не случайно: пока они расходились, длинный // Границы совпадают с формой входа не случайно: пока они расходились, длинный
// пароль, назначенный здесь, невозможно было ввести там. // пароль, назначенный здесь, невозможно было ввести там.
// //
// Правило — собственное, а не встроенные `min`/`max` Element Plus: те
// делегируются `async-validator`, который считает длину строки в единицах
// UTF-16, тогда как сервер считает code points и вдобавок ограничивает длину в
// БАЙТАХ пределом bcrypt. Пароль из 64 кириллических букв проходил здесь и
// возвращался с сервера системной ошибкой хеширования.
//
// Комментарий записан строчными `//`, а не блоком: скан релизных гейтов // Комментарий записан строчными `//`, а не блоком: скан релизных гейтов
// отбрасывает только их, и объяснение, называющее убранную конструкцию по // отбрасывает только их, и объяснение, называющее убранную конструкцию по
// имени, иначе роняет проверку «этой конструкции здесь больше нет». // имени, иначе роняет проверку «этой конструкции здесь больше нет».
const passwordRule = { const passwordRule = adminPasswordFormRule(
t("credentials.passwordFormat", {
min: ADMIN_PASSWORD_MIN_LENGTH, min: ADMIN_PASSWORD_MIN_LENGTH,
max: ADMIN_PASSWORD_MAX_LENGTH, max: ADMIN_PASSWORD_MAX_LENGTH,
message: t("credentials.passwordLength", { maxBytes: ADMIN_PASSWORD_MAX_BYTES,
min: ADMIN_PASSWORD_MIN_LENGTH, })
max: ADMIN_PASSWORD_MAX_LENGTH, );
}),
trigger: ["change", "blur"] as string[],
};
const rules: FormRules = { const rules: FormRules = {
oldPassword: [ oldPassword: [
+14 -7
View File
@@ -84,12 +84,14 @@ import { useAdminStore } from "@/store/modules/admin";
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router"; import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
import { AdminLoginDto } from "@/api/admin/types"; import { AdminLoginDto } from "@/api/admin/types";
import { import {
ADMIN_PASSWORD_MAX_BYTES,
ADMIN_PASSWORD_MAX_LENGTH, ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH, ADMIN_PASSWORD_MIN_LENGTH,
ADMIN_USERNAME_CHARSET, ADMIN_USERNAME_CHARSET,
ADMIN_USERNAME_MAX_LENGTH, ADMIN_USERNAME_MAX_LENGTH,
ADMIN_USERNAME_MIN_LENGTH, ADMIN_USERNAME_MIN_LENGTH,
ADMIN_USERNAME_PATTERN, ADMIN_USERNAME_PATTERN,
adminPasswordFormRule,
} from "@/constants/credentials"; } from "@/constants/credentials";
const adminStore = useAdminStore(); const adminStore = useAdminStore();
@@ -129,6 +131,13 @@ const loginForm = ref<AdminLoginDto>({
* символов, из-за чего форма входа отказывалась отправлять пароль, который * символов, из-за чего форма входа отказывалась отправлять пароль, который
* сервер принимает: набор пароля сервер не ограничивает нигде. Проверка, * сервер принимает: набор пароля сервер не ограничивает нигде. Проверка,
* которая умеет только запереть оператора и ничего не защищает, — не проверка. * которая умеет только запереть оператора и ничего не защищает, — не проверка.
*
* Правило пароля — собственное, а не встроенные `min`/`max` Element Plus.
* Element Plus делегирует правила формы библиотеке `async-validator`, а она
* сравнивает `min`/`max` строки с `String.prototype.length`, то есть считает
* единицы UTF-16, тогда как сервер считает code points. Пароль из трёх эмодзи
* имел `value.length === 6` и проходил минимум формы, а сервер видел три руны и
* отказывал. См. isValidAdminPassword.
*/ */
const loginRules = { const loginRules = {
username: [ username: [
@@ -153,15 +162,13 @@ const loginRules = {
message: t("common.required"), message: t("common.required"),
trigger: ["change", "blur"], trigger: ["change", "blur"],
}, },
{ adminPasswordFormRule(
t("credentials.passwordFormat", {
min: ADMIN_PASSWORD_MIN_LENGTH, min: ADMIN_PASSWORD_MIN_LENGTH,
max: ADMIN_PASSWORD_MAX_LENGTH, max: ADMIN_PASSWORD_MAX_LENGTH,
message: t("credentials.passwordLength", { maxBytes: ADMIN_PASSWORD_MAX_BYTES,
min: ADMIN_PASSWORD_MIN_LENGTH, })
max: ADMIN_PASSWORD_MAX_LENGTH, ),
}),
trigger: ["change", "blur"],
},
], ],
}; };
+9
View File
@@ -52,6 +52,15 @@ const (
ErrCodeGreaterThan string = "gt" ErrCodeGreaterThan string = "gt"
ErrCodePeerName string = "peer_name" ErrCodePeerName string = "peer_name"
ErrCodeCredentialStr string = "credential_format" ErrCodeCredentialStr string = "credential_format"
// ErrCodeAdminPassword — пароль администратора не удовлетворяет контракту.
//
// Отдельный код, а не пара min_length/max_length, потому что правило одно и
// проверяет три вещи сразу: длину в символах, длину в байтах (предел
// bcrypt) и отсутствие управляющих символов. Пока границы стояли двумя
// тегами, отказ по байтам было нечем назвать — его просто не существовало,
// и пароль из 64 кириллических букв доходил до хеширования, где становился
// системной ошибкой.
ErrCodeAdminPassword string = "admin_password_format"
ErrCodeRuleUnknown string = "rule_violated" ErrCodeRuleUnknown string = "rule_violated"
// Доменные коды: правило соблюдено, но операция всё равно невозможна. // Доменные коды: правило соблюдено, но операция всё равно невозможна.
+27 -7
View File
@@ -1,17 +1,37 @@
package dto package dto
// Границы пароля здесь и в LoginDto обязаны совпадать. // Правило пароля здесь и в LoginDto обязано быть ОДНИМ И ТЕМ ЖЕ.
// //
// Пока они расходились, панель запирала оператора снаружи после операции, // Пока границы расходились, панель запирала оператора снаружи после операции,
// которую сама же и предлагала: форма смены пароля принимала 64 символа, а // которую сама же и предлагала: форма смены пароля принимала 64 символа, а
// форма входа — 32, и назначенный длинный пароль становился паролем, с которым // форма входа — 32, и назначенный длинный пароль становился паролем, с которым
// невозможно войти. Числа в тегах сверяются с service.AdminPassword*Length в // невозможно войти.
// TestAdminPasswordBoundsAreOneContract — в теге структуры константу не //
// подставить. // Числа из тегов ушли внутрь правила `adminPassword`. Записанные тегом
// `min=6,max=64` они, кроме дублирования, были ещё и неполны: тег считает
// символы Unicode, а bcrypt — байты, поэтому новый пароль из 64 кириллических
// букв проходил проверку формы, доходил до util.HashPassword и возвращался
// оператору системной ошибкой. Отказ по байтам тегом валидатора выразить
// нечем — правила длины в байтах у него нет.
// //
// Набора символов здесь нет и не было: пароль принадлежит оператору целиком. // Набора символов здесь нет и не было: пароль принадлежит оператору целиком.
type AdminChangePasswordDto struct { type AdminChangePasswordDto struct {
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,min=6,max=64"` OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,adminPassword"`
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,min=6,max=64"` NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,adminPassword"`
}
// Normalize: пароль НЕ триммится ни в одном из полей.
//
// Пустое поле обязано выглядеть незаполненным и получить отказ `required`: у
// поля-указателя `required` считает значение заданным по самому факту
// ненулевого указателя, поэтому `{"oldPassword":""}` иначе получило бы отказ по
// длине вместо «поле обязательно».
//
// Пробел по краям при этом остаётся частью значения. Форма входа ведёт себя так
// же (LoginDto.Normalize), и иначе быть не может: пароль, назначенный здесь,
// вводится там.
func (d *AdminChangePasswordDto) Normalize() {
emptyToNil(&d.OldPassword)
emptyToNil(&d.NewPassword)
} }
+11 -7
View File
@@ -18,17 +18,21 @@ package dto
// и отвергалось другим, а оператор видел «invalid». Здесь та же конструкция // и отвергалось другим, а оператор видел «invalid». Здесь та же конструкция
// была бы той же ошибкой. // была бы той же ошибкой.
// //
// У пароля набора символов НЕТ, и это осознанно: сервер нигде его не // У пароля границы длины живут внутри правила `adminPassword` по той же
// причине. Здесь стояло `min=6,max=64`, и эта запись была не только вторым
// правилом на одном поле, но и НЕПОЛНОЙ: `max` считает символы Unicode, а
// bcrypt — байты, поэтому 64 кириллические буквы (128 байт) проходили проверку
// и падали в хешировании. Границу в байтах тегом валидатора не выразить.
//
// Набора символов у пароля НЕТ, и это осознанно: сервер нигде его не
// ограничивает — ни при установке, ни при смене пароля. Ограничение на входе // ограничивает — ни при установке, ни при смене пароля. Ограничение на входе
// не защищало бы ничего (значение всё равно сравнивается с хешем) и умело // не защищало бы ничего (значение всё равно сравнивается с хешем) и умело
// только запирать оператора с паролем, который сервер принял бы. // только запирать оператора с паролем, который сервер принял бы. Единственное
// // исключение — управляющие символы, и оно принадлежит контракту, а не форме:
// Числа в тегах обязаны совпадать с service.AdminPassword*Length; за этим // см. credential.IsValidAdminPassword.
// следит TestLoginPasswordBoundsMatchContract — в теге структуры константу не
// подставить.
type LoginDto struct { type LoginDto struct {
Username *string `json:"username" form:"username" validate:"required,credentialStr"` Username *string `json:"username" form:"username" validate:"required,credentialStr"`
Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=64"` Pass *string `json:"pass" form:"pass" validate:"required,adminPassword"`
} }
// Normalize: незаполненное поле обязано выглядеть незаполненным. // Normalize: незаполненное поле обязано выглядеть незаполненным.
+52
View File
@@ -119,4 +119,56 @@ func TestNormalizableIsImplemented(t *testing.T) {
var _ Normalizable = (*PeerPageDto)(nil) var _ Normalizable = (*PeerPageDto)(nil)
var _ Normalizable = (*BaseDto)(nil) var _ Normalizable = (*BaseDto)(nil)
var _ Normalizable = (*LogDto)(nil) var _ Normalizable = (*LogDto)(nil)
var _ Normalizable = (*LoginDto)(nil)
var _ Normalizable = (*AdminChangePasswordDto)(nil)
}
// Пароль НЕ триммится ни на одной из двух форм.
//
// Набор его символов не ограничен, пробел по краям — часть значения, и
// молчаливое его удаление означало бы, что на сервер уезжает не то, что ввёл
// оператор. Логин при этом триммится: скопированное из bootstrap-admin.secret
// значение регулярно приезжает с пробелом или переводом строки на конце.
func TestPasswordIsNeverTrimmedByNormalization(t *testing.T) {
login := LoginDto{Username: strPtr(" hy2xsadmin\n"), Pass: strPtr(" пароль ")}
login.Normalize()
if login.Username == nil || *login.Username != "hy2xsadmin" {
t.Fatalf("логин не приведён к каноничному виду: %v", login.Username)
}
if login.Pass == nil || *login.Pass != " пароль " {
t.Fatalf("пароль изменён нормализацией: %q", *login.Pass)
}
change := AdminChangePasswordDto{
OldPassword: strPtr("abcde "),
NewPassword: strPtr(" abcdef"),
}
change.Normalize()
if change.OldPassword == nil || *change.OldPassword != "abcde " {
t.Fatalf("старый пароль изменён нормализацией: %v", change.OldPassword)
}
if change.NewPassword == nil || *change.NewPassword != " abcdef" {
t.Fatalf("новый пароль изменён нормализацией: %v", change.NewPassword)
}
}
// Пустой пароль обязан выглядеть НЕЗАДАННЫМ.
//
// У поля-указателя `required` считает значение заданным по самому факту
// ненулевого указателя, поэтому без этого шага оператор, отправивший пустую
// форму, получал отказ по длине вместо «поле обязательно».
func TestEmptyPasswordBecomesAbsent(t *testing.T) {
login := LoginDto{Username: strPtr("hy2xsadmin"), Pass: strPtr("")}
login.Normalize()
if login.Pass != nil {
t.Fatalf("пустой пароль остался заданным: %q", *login.Pass)
}
change := AdminChangePasswordDto{OldPassword: strPtr(""), NewPassword: strPtr("")}
change.Normalize()
if change.OldPassword != nil || change.NewPassword != nil {
t.Fatal("пустые пароли формы смены остались заданными")
}
} }
-90
View File
@@ -1,90 +0,0 @@
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
}
+31 -2
View File
@@ -13,6 +13,7 @@ import (
"strings" "strings"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"hy2xs-admin/credential"
) )
// Функции SHA224String здесь больше нет, как и ветки сравнения паролей по ней. // Функции SHA224String здесь больше нет, как и ветки сравнения паролей по ней.
@@ -32,9 +33,37 @@ import (
// осталась запасным путём проверки пароля со слабым алгоритмом — в обработчике // осталась запасным путём проверки пароля со слабым алгоритмом — в обработчике
// логина, доступном без аутентификации. // логина, доступном без аутентификации.
// HashPassword проверяет пароль ОБЩИМ контрактом и хеширует его.
//
// Здесь стояла собственная проверка:
//
// if len(strings.TrimSpace(password)) < 6 { … }
//
// и она была третьей копией правила о пароле — вдобавок расходящейся с двумя
// остальными сразу по двум признакам.
//
// Первое: `TrimSpace`. Контракт продукта прямо объявляет пробел по краям частью
// пароля и не снимает его нигде — ни в нормализации DTO, ни при чтении
// bootstrap-окружения. Пароль `"abcde "` считался корректным формой входа,
// формой смены и оркестратором, а здесь превращался в `"abcde"` и получал
// отказ. То есть значение, принятое всеми дверями продукта, невозможно было
// захешировать.
//
// Второе: у проверки не было верхней границы вовсе, поэтому предел bcrypt в 72
// БАЙТА всплывал ошибкой хеширования уже после того, как все проверки пройдены.
//
// Теперь правило одно на весь продукт. Возможность позвать его отсюда — прямая
// причина, по которой контракт живёт в пакете `credential`, а не в `service`:
// `service` импортирует `util`, и обратный импорт был бы циклическим.
func HashPassword(password string) (string, error) { func HashPassword(password string) (string, error) {
if len(strings.TrimSpace(password)) < 6 { if !credential.IsValidAdminPassword(password) {
return "", errors.New("password too short") return "", fmt.Errorf(
"пароль администратора не удовлетворяет контракту: от %d до %d символов, "+
"не более %d байт в UTF-8, без управляющих символов",
credential.AdminPasswordMinLength,
credential.AdminPasswordMaxLength,
credential.AdminPasswordMaxBytes,
)
} }
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
+76 -5
View File
@@ -5,6 +5,8 @@ import (
"encoding/hex" "encoding/hex"
"strings" "strings"
"testing" "testing"
"hy2xs-admin/credential"
) )
// Здесь был TestSHA224String, который печатал хеш в stdout и ничего не // Здесь был TestSHA224String, который печатал хеш в stdout и ничего не
@@ -41,12 +43,81 @@ func TestHashPasswordIsSalted(t *testing.T) {
} }
} }
func TestHashPasswordRejectsShortPassword(t *testing.T) { // HashPassword проверяет ОБЩИЙ контракт, а не собственную копию правила.
if _, err := HashPassword("12345"); err == nil { //
t.Fatal("слишком короткий пароль принят") // Здесь стояло `len(strings.TrimSpace(password)) < 6`, и это была третья копия
// правила о пароле, расходящаяся с двумя остальными. Тест закреплял её обе
// ошибки: он требовал ОТКАЗА на шести пробелах — то есть на значении, которое
// контракт продукта принимает, — и не проверял верхнюю границу вовсе.
//
// Шесть пробелов принимаются намеренно. Пробел объявлен частью пароля, набор
// символов не ограничен, и «слабый пароль» здесь не тема: значение назначает
// оператор, а хеширование не является местом, где оценивают стойкость.
func TestHashPasswordFollowsTheCredentialContract(t *testing.T) {
cases := []struct {
name string
password string
}{
{"ровно минимум", strings.Repeat("a", credential.AdminPasswordMinLength)},
{"на символ короче минимума", strings.Repeat("a", credential.AdminPasswordMinLength-1)},
{"шесть пробелов", strings.Repeat(" ", credential.AdminPasswordMinLength)},
{"краевой пробел", "abcde "},
{"ровно максимум в ASCII", strings.Repeat("a", credential.AdminPasswordMaxLength)},
{"на символ длиннее максимума", strings.Repeat("a", credential.AdminPasswordMaxLength+1)},
{"ровно предел bcrypt", strings.Repeat("я", 36)},
{"на букву длиннее предела bcrypt", strings.Repeat("я", 37)},
{"эмодзи ровно в предел", strings.Repeat("😀", 18)},
{"эмодзи за пределом", strings.Repeat("😀", 19)},
{"64 кириллические буквы", strings.Repeat("я", credential.AdminPasswordMaxLength)},
{"перевод строки", "abcde\n"},
{"пусто", ""},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
_, err := HashPassword(testCase.password)
accepted := err == nil
expected := credential.IsValidAdminPassword(testCase.password)
if accepted != expected {
t.Fatalf(
"HashPassword принял=%v, контракт=%v (%d символов, %d байт): %v",
accepted, expected,
len([]rune(testCase.password)), len(testCase.password), err,
)
}
})
}
}
// Контракт обязан быть НЕ ШИРЕ того, что умеет bcrypt.
//
// Это и было исходным дефектом: контракт принимал 64 символа любого алфавита, а
// bcrypt.GenerateFromPassword отвечает ErrPasswordTooLong строго при длине
// больше 72 БАЙТ. Проверяются граничные значения, на которых расхождение
// проявляется: всё, что контракт принял, обязано хешироваться.
func TestEveryPasswordAcceptedByContractCanBeHashed(t *testing.T) {
candidates := []string{
strings.Repeat("a", credential.AdminPasswordMinLength),
strings.Repeat("a", credential.AdminPasswordMaxLength),
strings.Repeat("я", 36),
strings.Repeat("😀", 18),
strings.Repeat(" ", credential.AdminPasswordMinLength),
"пароль-администратора",
`p@$$w0rd"\'#;`,
"abcde ",
}
for _, password := range candidates {
if !credential.IsValidAdminPassword(password) {
t.Fatalf("подготовка: контракт отверг значение из %d байт", len(password))
}
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("контракт принял пароль из %d байт, а хеширование отказало: %v", len(password), err)
}
if !VerifyPassword(password, hash) {
t.Fatalf("пароль из %d байт не проходит проверку собственным хешем", len(password))
} }
if _, err := HashPassword(" "); err == nil {
t.Fatal("пароль из пробелов принят")
} }
} }
+12
View File
@@ -1117,6 +1117,18 @@ B входит со своим устаревшим 0 -> lastOnline снова 0
Если переменной нет, а создавать учётную запись нужно, админка **отказывает в Если переменной нет, а создавать учётную запись нужно, админка **отказывает в
старте** с сообщением, называющим причину и способ починки. старте** с сообщением, называющим причину и способ починки.
То же и при значении вне контракта пароля: `HY2XS_ADMIN_INITIAL_PASSWORD`
проверяется против того же правила, что и форма входа
(`apps/credential/admin.go`), и непригодное значение роняет старт с внятным
текстом, а не доходит до `bcrypt.GenerateFromPassword`, чтобы вернуться оттуда
строкой `password length exceeds 72 bytes`. Учётная запись при этом не
создаётся: установка иначе завершилась бы успешно, а войти было бы нельзя.
Пароль читается **как есть**: пробелы по краям объявлены его частью и не
снимаются ни здесь, ни при хешировании, ни на форме входа. Раньше bootstrap
делал `strings.TrimSpace`, и учётная запись создавалась не с тем паролем,
который оператор записал в `hy2xs.env`.
Раньше она в этом случае придумывала пароль сама и печатала его двумя Раньше она в этом случае придумывала пароль сама и печатала его двумя
`logrus.Warnf` — открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, то есть в `logrus.Warnf` — открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, то есть в
файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо
+79 -12
View File
@@ -270,24 +270,33 @@ control plane показывал всех пиров офлайн. Теперь
### Где живёт контракт ### Где живёт контракт
Требования к логину и паролю администратора объявлены **один раз**, в Требования к логину и паролю администратора объявлены **один раз**, в
`apps/service/admin_credentials.go`: `apps/credential/admin.go`:
| Что | Значение | Владелец | | Что | Значение | Владелец |
| --- | --- | --- | | --- | --- | --- |
| Длина логина | 6-32 символа | `AdminUsernameMinLength` / `AdminUsernameMaxLength` | | Длина логина | 6-32 символа | `AdminUsernameMinLength` / `AdminUsernameMaxLength` |
| Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` | | Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` |
| Длина пароля | 6-64 символа | `AdminPasswordMinLength` / `AdminPasswordMaxLength` | | Длина пароля | 6-64 символа Unicode | `AdminPasswordMinLength` / `AdminPasswordMaxLength` |
| Набор символов пароля | не ограничен | — | | Размер пароля | не более 72 байт в UTF-8 | `AdminPasswordMaxBytes` |
| Набор символов пароля | не ограничен, кроме управляющих | — |
| Пробелы по краям пароля | часть значения, не снимаются | — |
Остальные три стороны продукта только повторяют этот контракт, и каждая копия Контракт живёт в отдельном **leaf-пакете**, а не в `service`, и это не
вкусовщина. Его зовут `util.HashPassword` и слой данных при создании первой
учётной записи, а `service` импортирует `util` — обратный импорт был бы
циклическим. Пока контракт лежал в `service`, `HashPassword` завёл собственную
проверку `len(strings.TrimSpace(password)) < 6`, и она разошлась с остальным
продуктом.
Остальные стороны продукта только повторяют этот контракт, и каждая копия
сверяется с оригиналом тестом, читающим Go-исходник: сверяется с оригиналом тестом, читающим Go-исходник:
* панель — `apps/frontend/src/constants/credentials.ts` * панель — `apps/frontend/src/constants/credentials.ts`
(`tools/test/frontend-contract.test.ts`); (`tools/test/frontend-contract.test.ts`);
* оркестратор — `orchestrator/src/config/profile.ts` * оркестратор — `orchestrator/src/config/profile.ts`
(`orchestrator/test/admin-credentials.test.ts`); (`orchestrator/test/admin-credentials.test.ts`);
* правило валидатора — `credentialStr` в `apps/controller/validator.go`, длина * правила валидатора — `credentialStr` и `adminPassword` в
живёт ВНУТРИ него. `apps/controller/validator.go`, длина живёт ВНУТРИ них.
### Почему у пароля нет набора символов ### Почему у пароля нет набора символов
@@ -296,12 +305,64 @@ control plane показывал всех пиров офлайн. Теперь
bcrypt-хешем. Ограничение набора на форме не защищает ничего и умеет только bcrypt-хешем. Ограничение набора на форме не защищает ничего и умеет только
отвергнуть пароль, который сервер принял бы. отвергнуть пароль, который сервер принял бы.
Верхняя граница в 64 символа выбрана не круглым числом: bcrypt читает первые 72 Единственное исключение — **управляющие символы** (C0 и DEL). Они запрещены не
БАЙТА и молча отбрасывает остаток, поэтому предел обязан быть заведомо ниже. формой, а транспортом: первый пароль администратора уезжает в
`/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и у
перевода строки там нет представления, переживающего запись и чтение. Такой
пароль всё равно невозможно ввести в однострочное поле формы входа, то есть он
умеет ровно одно — запереть оператора снаружи.
Длина считается в **символах**, а не в байтах: `go-playground/validator` считает ### Почему границ у пароля две
`min`/`max` на строке в рунах, и проверка по байтам отвергла бы пароль из 32
кириллических букв, который сервер принимает. Их две потому, что они в **разных единицах**, и вывести одну из другой нельзя.
Граница в символах — та, которую видит оператор. Она считается в code points, а
не в байтах и не в единицах UTF-16: `go-playground/validator` считает `min`/`max`
на строке через `utf8.RuneCountInString`, и «пароль из 64 символов» обязано
означать одно и то же для латиницы и для кириллицы.
Граница в байтах — та, которую ставит bcrypt. `golang.org/x/crypto/bcrypt`
отвечает `ErrPasswordTooLong` на пароль длиннее **72 байт**
(`GenerateFromPassword`, `bcrypt.go:96`). У 64 символов длина от 64 до 256 байт:
```text
64 x "a" = 64 байта -> принимается
36 x "я" = 72 байта -> принимается (граница)
37 x "я" = 74 байта -> отвергается
18 x "😀" = 72 байта -> принимается (граница)
19 x "😀" = 76 байт -> отвергается
64 x "я" = 128 байт -> отвергается
```
Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72
байт» — верно только для ASCII, — а сопровождающий текст утверждал, что bcrypt
«молча отбрасывает остаток». Так вела себя редакция пакета до v0.28;
действующая отвечает ошибкой. Следствие: пароль из 64 кириллических букв
проходил панель, оркестратор и DTO, а отказ приходил из хеширования — системной
ошибкой на штатной смене пароля, а после установки — отсутствием администратора
вовсе.
### Панель считает длину так же, как сервер
Встроенных `min`/`max` Element Plus у пароля **нет**. Правила формы Element Plus
делегирует библиотеке `async-validator`, а та сравнивает `min`/`max` строки с
`String.prototype.length`, то есть считает единицы UTF-16:
```text
"😀😀😀" Go: 3 руны -> сервер отказывает (минимум 6)
JS: length === 6 -> форма считала минимум достигнутым
```
Панель отправляла бы заведомо отвергаемый пароль и не могла бы объяснить отказ.
Поэтому у обеих форм одно общее правило `adminPasswordFormRule`, и оно считает
code points итератором строки, а байты — через `TextEncoder`.
### Границы обеих форм обязаны совпадать
Форма входа и форма смены пароля предъявляют к паролю **одно и то же**
требование. Расхождение здесь запирает оператора снаружи после операции,
которую панель ему же и предложила: пароль длиннее предела формы входа
назначается успешно и после этого не вводится.
### Границы обеих форм обязаны совпадать ### Границы обеих форм обязаны совпадать
@@ -337,7 +398,7 @@ Element Plus рисует состояние отказа на `el-input__wrappe
### Требование называется, а не нарушается ### Требование называется, а не нарушается
Фразы `credentials.usernameFormat` и `credentials.passwordLength` перечисляют Фразы `credentials.usernameFormat` и `credentials.passwordFormat` перечисляют
границы и набор символов. Набор логина приходит из `HY2XS_ADMIN_USER`, и границы и набор символов. Набор логина приходит из `HY2XS_ADMIN_USER`, и
посмотреть его в панели больше негде — сообщение «Неверный формат логина» не посмотреть его в панели больше негде — сообщение «Неверный формат логина» не
давало оператору ни одного способа узнать, что от него хотят. давало оператору ни одного способа узнать, что от него хотят.
@@ -346,3 +407,9 @@ Element Plus рисует состояние отказа на `el-input__wrappe
(`min`, `max`, `charset`), и фраза панели обязана их использовать: правило одно (`min`, `max`, `charset`), и фраза панели обязана их использовать: правило одно
и проверяет и длину, и набор, поэтому описывать его только через символы — и проверяет и длину, и набор, поэтому описывать его только через символы —
значит описывать отказ по длине неверно. значит описывать отказ по длине неверно.
У пароля причина отдельная — `admin_password_format` с `params`
(`min`, `max`, `maxBytes`), — и фраза обязана называть **обе** границы. Пароль
из 40 эмодзи укладывается в 64 символа и не укладывается в 72 байта: сообщение
«не длиннее 64 символов» отправило бы оператора сокращать пароль, отвергнутый
не за это.
@@ -541,6 +541,56 @@ grep -c '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret
оркестратору, он же приводит `hy2xs.env` и `bootstrap-admin.secret` в оркестратору, он же приводит `hy2xs.env` и `bootstrap-admin.secret` в
согласованное состояние. согласованное состояние.
### `hy2xs-admin` не стартует: «HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели»
Значение задано, но панель его не приняла бы на форме входа, поэтому учётная
запись администратора с ним не создаётся: установка иначе завершилась бы
успешно, а войти было бы нельзя.
Контракт пароля — **две** границы в разных единицах и один запрет:
| Требование | Кто его ставит |
| --- | --- |
| 6-64 символа Unicode | форма входа и форма смены пароля |
| не более 72 байт в UTF-8 | bcrypt (`ErrPasswordTooLong`) |
| без управляющих символов | формат `KEY=VALUE`, который читает systemd |
Границы независимы: у 64 символов длина от 64 до 256 байт. Пароль из 64
кириллических букв — это 128 байт, и он отвергается, хотя в границу символов
укладывается. Практический предел: 36 кириллических букв или 18 эмодзи.
Набор символов не ограничен ничем сверх этого, а пробелы по краям являются
частью пароля. Именно поэтому такое значение записывается в `hy2xs.env` в
двойных кавычках:
```text
HY2XS_ADMIN_INITIAL_PASSWORD="пароль с пробелом на конце "
```
Без кавычек пробелы по краям срежет **systemd** — файл объявлен
`EnvironmentFile=` в юните, — и админка получит не то значение, которое вы
записали.
Починка: исправьте значение в `/etc/hy2xs/hy2xs.env` и выполните
`hy2xs-orchestrator repair --allow-partial-state`.
### Как посмотреть bootstrap-пароль
`/etc/hy2xs/bootstrap-admin.secret` — файл того же формата `KEY=VALUE`, и
значения в нём могут быть закавычены. Читать их `cut -d= -f2-` нельзя: кавычки
уедут в пароль. `source` и `eval` тоже не годятся — shell выполнит подстановку
команд внутри двойных кавычек, чего сам systemd не делает.
```bash
read_bootstrap_field() {
sudo sed -n "s/^$1=//p" /etc/hy2xs/bootstrap-admin.secret | head -n1 \
| sed -e 's/^"//' -e 's/"$//' -e 's/\\\(["\\]\)/\1/g'
}
read_bootstrap_field ADMIN_USER
read_bootstrap_field ADMIN_INITIAL_PASSWORD
```
Аналогичное сообщение про `HY2XS_ADMIN_CON_PASS` относится к пиру установщика. Аналогичное сообщение про `HY2XS_ADMIN_CON_PASS` относится к пиру установщика.
Его секрет продублирован в `bootstrap-admin.secret`, откуда его читает проверка Его секрет продублирован в `bootstrap-admin.secret`, откуда его читает проверка
machine-auth, поэтому придуманный секрет разошёлся бы с файлом и первая же machine-auth, поэтому придуманный секрет разошёлся бы с файлом и первая же
+60 -1
View File
@@ -542,6 +542,50 @@ production-профилем, а не ищет подстроки. Проверя
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
уходит в логи и в diagnostics-бандл. уходит в логи и в diagnostics-бандл.
## Формат env-файлов: у него два читателя
`/etc/hy2xs/hy2xs.env` разбирает не только оркестратор. Файл объявлен
`EnvironmentFile=` в юните `hy2xs-admin`, то есть его читает **systemd**, и
формат обязан совпадать у обоих. Пока значения писались интерполяцией
(`` `HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}` ``), а читались
построчным `split("=")` с `trim()`, форматом это не являлось: совпадение
поведения держалось на том, что в значениях не встречалось ни пробелов по краям,
ни кавычек, ни обратных слешей. Продукт при этом обещает оператору, что набор
символов пароля не ограничен, а краевой пробел — часть значения.
Запись и разбор живут в `orchestrator/src/lib/envFile.ts` и повторяют конечный
автомат `parse_env_file_internal` из systemd (`src/basic/env-file.c`).
Существенны четыре его свойства:
1. у **незакавыченного** значения срезаются пробелы в конце, `\` уводит в
escape, а `\<перевод строки>` склеивает строки;
2. в **одинарных** кавычках всё literal до закрывающей кавычки — escape там
нет (отличие от `sh`);
3. в **двойных** кавычках `\` уводит в escape, и обратный слеш снимается только
перед `"`, `\`, `` ` `` и `$` (`SHELL_NEED_ESCAPE`); перед любым другим
символом он СОХРАНЯЕТСЯ;
4. подстановки переменных в env-файле нет вовсе: `$` внутри значения — обычный
символ.
Из (3) и (4) следует кодирование, которое переживает любое издание systemd:
двойные кавычки и экранирование **только** `\` и `"`. Оба входят в
`SHELL_NEED_ESCAPE` и разворачиваются одинаково в действующей редакции и в тех,
где escape в двойных кавычках снимался безусловно.
Кавычки ставятся только там, где они нужны: обычные значения (порты, пути,
домены, `50 mbps`, base64url-секреты) остаются побайтово прежними, поэтому
релизные гейты и инструкции оператора вида `grep '^HY2XS_UI_PORT=8080$'`
продолжают работать. Тем же кодировщиком пишется `bootstrap-admin.secret`.
Одно намеренное расхождение с systemd: строка без `=`**отказ**, а не
пропуск. systemd такую строку молча отбрасывает; молчаливая потеря строки из
`hy2xs.env` означала бы установку с настройкой, которую оператор задал, а
продукт не увидел. Расхождение в сторону отказа останавливает установку там, где
её можно починить.
Управляющие символы формат не несёт — перевод строки является границей записи, а
не данными, — поэтому они запрещены и контрактом пароля.
## Smoke проверяет, что панель ВПУСКАЕТ ## Smoke проверяет, что панель ВПУСКАЕТ
Открытый порт — это не работающая панель. Открытый порт — это не работающая панель.
@@ -557,7 +601,7 @@ production-профилем, а не ищет подстроки. Проверя
| Проба | Когда | Что требуется | | Проба | Когда | Что требуется |
| --- | --- | --- | | --- | --- | --- |
| заведомо неверные учётные данные | всегда | HTTP 200 с конвертом отказа | | настоящий логин + СЛУЧАЙНЫЙ пароль | всегда | `code: 50000`, причина `invalid_credentials`, `accessToken` отсутствует |
| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` | | bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` |
Детали, которые здесь существенны: Детали, которые здесь существенны:
@@ -565,6 +609,21 @@ production-профилем, а не ищет подстроки. Проверя
- **успех определяется конвертом, а не кодом HTTP.** Админка отвечает `200 OK` и - **успех определяется конвертом, а не кодом HTTP.** Админка отвечает `200 OK` и
на отказ тоже — причина живёт в поле `code`. Проверка «HTTP 200» приняла бы за на отказ тоже — причина живёт в поле `code`. Проверка «HTTP 200» приняла бы за
успешный вход любой отказ, то есть не проверяла бы ничего; успешный вход любой отказ, то есть не проверяла бы ничего;
- **отказ определяется конвертом по той же причине.** Отрицательная проба
сверяла `%{http_code}` с `200` и доказывала ровно одно — что запрос не
закончился пятисоткой. Теперь требуются три признака сразу: код конверта
`50000` (отказ операции, а не успех и не отказ валидации, который означал бы
негодный запрос), доменная причина `invalid_credentials` и ОТСУТСТВИЕ
`accessToken`;
- **пароль отрицательной пробы генерируется**, а не записан литералом. Записанное
в исходнике значение теоретически может оказаться настоящим паролем — и тогда
проверка «неверные данные отвергаются» отчиталась бы об успешном входе. На
`install`, где настоящий пароль известен, дополнительно утверждается, что
проба ему не равна;
- **bootstrap-секрет читается парсером формата**, а не `grep … | cut -d= -f2-`.
Набор символов пароля не ограничен, пробелы по краям являются его частью, и
шелл-конвейер срезал бы их — положительная проба взяла бы не тот пароль и
объявила бы рабочую установку сломанной;
- **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы - **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы
панель, которая пускает и не выдаёт сессию; панель, которая пускает и не выдаёт сессию;
- **тело собирается `JSON.stringify`**, а не интерполяцией в строку: пароль - **тело собирается `JSON.stringify`**, а не интерполяцией в строку: пароль
+45 -3
View File
@@ -140,7 +140,7 @@ anycast. Отсутствие A-записи фатально при любом
Важно: Важно:
- `HY2XS_ADMIN_INITIAL_PASSWORD` используется только для первичного bootstrap seed; - `HY2XS_ADMIN_INITIAL_PASSWORD` используется только для первичного bootstrap seed;
- `HY2XS_ADMIN_CON_PASS` — отдельная runtime-сущность для Hysteria auth/smoke; - `HY2XS_ADMIN_CON_PASS` — отдельная runtime-сущность для Hysteria auth/smoke;
- bootstrap secret хранится в явном формате `KEY=VALUE` (`ADMIN_USER`, `ADMIN_INITIAL_PASSWORD`, `ADMIN_CON_PASS`), права `0600`; - bootstrap secret хранится в том же формате `KEY=VALUE` (`ADMIN_USER`, `ADMIN_INITIAL_PASSWORD`, `ADMIN_CON_PASS`), права `0600`; значения с пробелами по краям, кавычками или обратными слешами записываются в двойных кавычках — читать файл следует парсером формата, а не `cut -d= -f2-`;
- `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован); - `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован);
- после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`. - после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`.
@@ -148,12 +148,12 @@ anycast. Отсутствие A-записи фатально при любом
`HY2XS_ADMIN_USER` и `HY2XS_ADMIN_INITIAL_PASSWORD` — это значения, которые `HY2XS_ADMIN_USER` и `HY2XS_ADMIN_INITIAL_PASSWORD` — это значения, которые
потом принимает **форма входа в панель**. Оркестратор проверяет их против того потом принимает **форма входа в панель**. Оркестратор проверяет их против того
же контракта, что и админка (`apps/service/admin_credentials.go`): же контракта, что и админка (`apps/credential/admin.go`):
| Переменная | Требование | Значение по умолчанию | | Переменная | Требование | Значение по умолчанию |
| --- | --- | --- | | --- | --- | --- |
| `HY2XS_ADMIN_USER` | 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `hy2xsadmin` | | `HY2XS_ADMIN_USER` | 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `hy2xsadmin` |
| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа, набор не ограничен | генерируется | | `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа Unicode **и** не более 72 байт в UTF-8; набор не ограничен, кроме управляющих символов | генерируется |
Значение вне контракта **роняет установку** с явным текстом, называющим границы Значение вне контракта **роняет установку** с явным текстом, называющим границы
и набор. Так и должно быть: отказ, пришедший установщику, чинится одной строкой и набор. Так и должно быть: отказ, пришедший установщику, чинится одной строкой
@@ -162,16 +162,58 @@ anycast. Отсутствие A-записи фатально при любом
Проверяется и сгенерированный пароль, а не только заданный оператором: Проверяется и сгенерированный пароль, а не только заданный оператором:
генератор — такой же источник значения. генератор — такой же источник значения.
Границ у пароля **две**, и они в разных единицах. Предел в 72 байта ставит
bcrypt: `GenerateFromPassword` отвечает `ErrPasswordTooLong` на пароль длиннее
72 байт, а у 64 символов длина от 64 до 256 байт в зависимости от алфавита.
Пока байтовой границы здесь не было, `HY2XS_ADMIN_INITIAL_PASSWORD` из 64
кириллических букв (128 байт) проходил установку целиком, а первая учётная
запись администратора не создавалась вовсе — админка падала при старте. Подробно
границы описаны в `docs/admin/15-ui-contracts.md`.
Окружающие пробелы у `HY2XS_ADMIN_USER` снимаются. Иначе они уезжали бы в имя Окружающие пробелы у `HY2XS_ADMIN_USER` снимаются. Иначе они уезжали бы в имя
учётной записи в SQLite, и вход отказывал бы «неверным логином или паролем» — учётной записи в SQLite, и вход отказывал бы «неверным логином или паролем» —
отказом, который невозможно связать с причиной. отказом, который невозможно связать с причиной.
У `HY2XS_ADMIN_INITIAL_PASSWORD` пробелы по краям, наоборот, **являются частью
пароля** и не снимаются нигде — ни оркестратором, ни админкой, ни хешированием.
Чтобы такое значение пережило запись и чтение, оно записывается **в двойных
кавычках** с экранированием `\` и `"`:
```text
HY2XS_ADMIN_INITIAL_PASSWORD="пароль с пробелом на конце "
```
Кавычки здесь не украшение. Файл читает не только оркестратор, но и systemd —
он объявлен `EnvironmentFile=` в юните `hy2xs-admin`, — а у **незакавыченного**
значения systemd срезает пробелы по краям и трактует `\` как escape. Если вы
правите `hy2xs.env` руками и в пароле есть пробел по краям, кавычка или обратный
слеш, закавычьте значение тем же способом. Обычные значения (порты, пути,
домены) кавычек не требуют и записываются как раньше.
Управляющие символы (перевод строки, табуляция) в пароле запрещены контрактом:
формат `KEY=VALUE` их не несёт, а ввести такой пароль в форму входа всё равно
нельзя.
Значение по умолчанию совпадает в трёх местах и обязано совпадать: Значение по умолчанию совпадает в трёх местах и обязано совпадать:
`package/config/hy2xs.env`, `orchestrator/src/config/env.ts` и запасное `package/config/hy2xs.env`, `orchestrator/src/config/env.ts` и запасное
значение в `apps/dao/sqlite.go`. Раньше оркестратор писал `admin` — пять значение в `apps/dao/sqlite.go`. Раньше оркестратор писал `admin` — пять
символов при минимуме панели в шесть, — и установка завершалась символов при минимуме панели в шесть, — и установка завершалась
`INSTALL EXIT CODE: 0`, оставляя панель, в которую невозможно войти. `INSTALL EXIT CODE: 0`, оставляя панель, в которую невозможно войти.
### Backlog: секреты в окружении
Документация systemd отдельно рекомендует **не** передавать секреты через
переменные окружения и предлагает для них `LoadCredential=` /
`LoadCredentialEncrypted=`: окружение процесса видно шире, чем файл с правами
`0600`.
HY2XS v1 этим не пользуется, и это осознанное решение по срокам, а не
недосмотр: переход затрагивает модель секретов всего продукта (машинный токен
Hysteria, obfs-пароль, `con_pass`), а не только пароль администратора.
Действующая защита — права `0600`, владелец `root:root` и отсутствие доступа у
служебных пользователей (`hy2xs-admin` и `hysteria` файл прочитать не могут, что
проверяет smoke). Пункт остаётся в backlog как отдельная работа.
### Immutable-bootstrap контракт ### Immutable-bootstrap контракт
- `/etc/hy2xs/bootstrap-admin.secret` создаётся оркестратором только при первичной установке. - `/etc/hy2xs/bootstrap-admin.secret` создаётся оркестратором только при первичной установке.
+86 -1
View File
@@ -89,6 +89,32 @@
Отдельно — round-trip `parse(render(config)) == config`. Этот тест ловит класс ошибок «в рендер runtime-конфига попал литерал вместо значения из конфигурации». Отдельно — round-trip `parse(render(config)) == config`. Этот тест ловит класс ошибок «в рендер runtime-конфига попал литерал вместо значения из конфигурации».
### A4a. Формат env-файла совпадает с systemd
`orchestrator/test/env-file.test.ts`. У `hy2xs.env` два читателя, и один из них
не наш: файл объявлен `EnvironmentFile=` в юните `hy2xs-admin`. Поэтому
проверяется не «работает на наших данных», а совпадение с правилами systemd
(`src/basic/env-file.c`) на тех значениях, где прежний разбор расходился:
| Вход | Ожидание |
| --- | --- |
| `KEY= value ` | `value` — незакавыченное значение теряет краевые пробелы, как и у systemd |
| `KEY=" value "` | ` value ` — в кавычках сохраняются |
| `KEY="a\"b"` | `a"b` |
| `KEY="a\\b"` | `a\b` |
| `KEY="a\nb"` | `a\nb``n` не входит в `SHELL_NEED_ESCAPE`, слеш сохраняется |
| `KEY="$HOME"` | `$HOME` — подстановок в env-файле нет |
| `KEY='a\b'` | `a\b` — в одинарных кавычках escape нет вовсе |
| строка без `=` | отказ (единственное намеренное расхождение: systemd её отбрасывает молча) |
| незакрытая кавычка | отказ |
И обратимость: любое значение — с краевыми пробелами, кавычками, обратными
слешами, `$`, `` ` ``, `#`, эмодзи — переживает `render -> parse` побайтово, а
обычные значения (`8080`, `/etc/hysteria/server.crt`, `50 mbps`) остаются без
кавычек, чтобы релизные гейты и инструкции оператора продолжали работать.
Управляющий символ в значении — отказ ЗАПИСИ, а не потеря части секрета.
Рендер конфига (`orchestrator/test/render-config.test.ts`): Рендер конфига (`orchestrator/test/render-config.test.ts`):
- Gecko рендерит **только** gecko-подблок; - Gecko рендерит **только** gecko-подблок;
@@ -508,7 +534,66 @@ wildcard-маршрутом фронтенда или дублирующая р
- `HashPassword` выдаёт bcrypt и солит: два хеша одного пароля различаются; - `HashPassword` выдаёт bcrypt и солит: два хеша одного пароля различаются;
- вход по несолёному SHA-224 (формат предыдущего поколения) **невозможен**; - вход по несолёному SHA-224 (формат предыдущего поколения) **невозможен**;
- любая не-bcrypt строка в поле хеша отклоняется. - любая не-bcrypt строка в поле хеша отклоняется;
- `HashPassword` следует ОБЩЕМУ контракту, а не собственной проверке. Раньше
здесь стояло `len(strings.TrimSpace(password)) < 6` — третья копия правила, и
она расходилась с двумя остальными: значение `abcde ` считалось корректным
всеми дверями продукта и не могло быть захешировано, а верхней границы у
копии не было вовсе;
- **всё, что контракт принял, обязано хешироваться.** Проверяются граничные
значения (36 x `я` = 72 байта, 18 x `😀` = 72 байта): именно здесь
расхождение с bcrypt и проявлялось.
## A9c1. Контракт учётных данных администратора (unit)
Контракт объявлен один раз в `apps/credential/admin.go` — в leaf-пакете, потому
что его зовут и `util.HashPassword`, и слой данных при создании первой учётной
записи, а `service` импортирует `util`.
`apps/credential/admin_test.go` — сам контракт:
- набор символов логина закреплён ФАКТИЧЕСКИМ множеством: сужение уронит тест, а
не вход администратора на живом сервере;
- границы пароля проверяются таблицей, и она включает случаи, где границы в
символах и в байтах расходятся:
| значение | символов | байт | результат |
| --- | --- | --- | --- |
| 64 x `a` | 64 | 64 | принят |
| 36 x `я` | 36 | 72 | принят (граница bcrypt) |
| 37 x `я` | 37 | 74 | **отвергнут** |
| 18 x `😀` | 18 | 72 | принят (граница bcrypt) |
| 19 x `😀` | 19 | 76 | **отвергнут** |
| 64 x `я` | 64 | 128 | **отвергнут** |
последняя строка — исходный дефект: прежний тест требовал ПРИЁМА этого
значения, то есть закреплял как ожидаемое ровно то, на чём продукт ломался;
- пробел по краям — часть пароля, шесть пробелов являются корректным паролем;
- управляющие символы (`\n`, `\r`, `\t`, `NUL`, `DEL`) отвергаются.
`apps/controller/validator_test.go` — ПРОВОДКА, а не контракт: теги
`credentialStr` и `adminPassword` прогоняются через production-валидатор и
обязаны отвечать так же, как функции контракта, на тех же граничных значениях.
`apps/controller/auth_test.go`:
- ни один тег валидации ни в одном DTO не ссылается на незарегистрированное
правило (обход исходников, а не проверка одного экземпляра);
- границы пароля **не стоят рядом** с правилом: тег умеет считать только
символы, а у пароля есть ещё граница в байтах, которую тегом не выразить;
- пароль в 72 байта пускает в панель, а на символ длиннее — получает конверт
валидации с причиной `admin_password_format` на поле `pass`, а не системную
ошибку из bcrypt;
- пароль не триммится: `bootstrap-password ` и `bootstrap-password` — разные
пароли.
`apps/dao/bootstrap_admin_test.go` — bootstrap-путь на настоящей SQLite:
- пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом, и вход
обрезанным значением невозможен;
- пароль вне контракта роняет старт с текстом, называющим переменную и файл, а
не сообщением bcrypt;
- пароль ровно в 72 байта проходит установку целиком.
`apps/util/rand_test.go` — отсутствие modulo bias: на выборке 200 000 символов `apps/util/rand_test.go` — отсутствие modulo bias: на выборке 200 000 символов
частоты первых восьми символов алфавита не отличаются от остальных более чем на частоты первых восьми символов алфавита не отличаются от остальных более чем на
+31 -6
View File
@@ -52,9 +52,10 @@
32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd 32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd
33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON 33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON
34. страница конфигурации показывает фактические значения `/etc/hysteria/config.yaml`, перечисляет секции вне production-профиля и не содержит паролей и токенов 34. страница конфигурации показывает фактические значения `/etc/hysteria/config.yaml`, перечисляет секции вне production-профиля и не содержит паролей и токенов
35. **оператор входит в панель**: `POST /api/auth/login` с bootstrap-учётными данными из `/etc/hy2xs/bootstrap-admin.secret` отвечает `code: 20000` и непустым `accessToken`. Заведомо неверные учётные данные дают HTTP 200 с конвертом отказа, а не 500 35. **оператор входит в панель**: `POST /api/auth/login` с bootstrap-учётными данными из `/etc/hy2xs/bootstrap-admin.secret` отвечает `code: 20000` и непустым `accessToken`. Заведомо неверные учётные данные (со СЛУЧАЙНЫМ паролем, а не с литералом) дают HTTP 200 с конвертом отказа: `code: 50000`, причина `invalid_credentials` и отсутствие `accessToken`
36. пароль предельной длины (64 символа), назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными 36. пароль предельной длины, назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными. Границ **две** — 6-64 символа Unicode и не более 72 байт в UTF-8 (предел bcrypt): пароль из 36 кириллических букв (72 байта) принимается, из 37 (74 байта) — отвергается конвертом валидации, а не системной ошибкой
37. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки 37. `HY2XS_ADMIN_INITIAL_PASSWORD` с пробелом по краям доезжает до учётной записи неизменным: значение записано в `hy2xs.env` в двойных кавычках, и вход выполняется ровно им, а не обрезанным
38. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки
## C0. Панель обязана впускать, а не слушать порт ## C0. Панель обязана впускать, а не слушать порт
@@ -70,15 +71,39 @@ HTTP 500 на каждый запрос, потому что валидатор
Ручной эквивалент: Ручной эквивалент:
```bash ```bash
# Пароль в переменную, чтобы он не попал ни в историю shell, ни в вывод. # Значение читается ПО ФОРМАТУ, а не `cut -d= -f2-`.
read -r -s BOOTSTRAP_PASS < <(sudo grep '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret | cut -d= -f2-) #
BOOTSTRAP_USER="$(sudo grep '^ADMIN_USER=' /etc/hy2xs/bootstrap-admin.secret | cut -d= -f2-)" # Набор символов пароля не ограничен, а пробелы по краям являются его частью,
# поэтому такое значение записано в файле в двойных кавычках с экранированием
# `\` и `"`. `cut` отдал бы кавычки как часть пароля, а `read -r` вдобавок
# срезал бы пробелы — и проверка объявила бы рабочую установку сломанной.
#
# `source` и `eval` здесь НЕ годятся: внутри двойных кавычек shell выполняет
# подстановку команд, то есть пароль вида `$(...)` был бы исполнен. У самого
# systemd подстановок в EnvironmentFile нет, и снимать кавычки надо без shell.
read_bootstrap_field() {
sudo sed -n "s/^$1=//p" /etc/hy2xs/bootstrap-admin.secret | head -n1 \
| sed -e 's/^"//' -e 's/"$//' -e 's/\\\(["\\]\)/\1/g'
}
BOOTSTRAP_USER="$(read_bootstrap_field ADMIN_USER)"
BOOTSTRAP_PASS="$(read_bootstrap_field ADMIN_INITIAL_PASSWORD)"
# Положительная проба: конверт успеха и выданный токен.
curl -sS --max-time 5 -X POST \ curl -sS --max-time 5 -X POST \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
--data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$BOOTSTRAP_PASS" '{username:$u,pass:$p}')" \ --data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$BOOTSTRAP_PASS" '{username:$u,pass:$p}')" \
http://127.0.0.1:8080/api/auth/login | jq '.code, (.data.accessToken | length)' http://127.0.0.1:8080/api/auth/login | jq '.code, (.data.accessToken | length)'
# Отрицательная проба: пароль СЛУЧАЙНЫЙ, а проверяется конверт отказа целиком.
# HTTP 200 сам по себе ничего не доказывает — админка отвечает 200 и на успех.
curl -sS --max-time 5 -X POST \
-H 'Content-Type: application/json' \
--data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$(head -c 18 /dev/urandom | base64)" '{username:$u,pass:$p}')" \
http://127.0.0.1:8080/api/auth/login \
| jq '{code, reason: (.errors[0].code), token: (.data.accessToken // null)}'
# Ожидается: {"code":50000,"reason":"invalid_credentials","token":null}
unset BOOTSTRAP_PASS unset BOOTSTRAP_PASS
``` ```
+63 -56
View File
@@ -1,5 +1,6 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { FirewallMode, PublicEndpointPolicy, RuntimeConfig, TlsMode } from "../types/context"; import type { FirewallMode, PublicEndpointPolicy, RuntimeConfig, TlsMode } from "../types/context";
import { parseEnvFile, renderEnvFile } from "../lib/envFile";
import { import {
GECKO_DEFAULT_MAX_PACKET_SIZE, GECKO_DEFAULT_MAX_PACKET_SIZE,
GECKO_DEFAULT_MIN_PACKET_SIZE, GECKO_DEFAULT_MIN_PACKET_SIZE,
@@ -23,22 +24,17 @@ function valueOrGenerate(value: string | undefined): string {
return value; return value;
} }
/**
* Разбор env-файла делегирован lib/envFile.
*
* Здесь стоял построчный разбор с `value.trim()`, и он молча срезал пробелы по
* краям КАЖДОГО значения. Для пароля администратора это прямое нарушение
* контракта продукт объявляет краевой пробел частью пароля, а для
* значений с кавычками разбор вдобавок расходился с systemd, который читает
* этот же файл как EnvironmentFile.
*/
function parseEnv(content: string): EnvMap { function parseEnv(content: string): EnvMap {
const result: EnvMap = {}; return parseEnvFile(content);
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const separator = line.indexOf("=");
if (separator < 1) {
throw new Error(`invalid env line: ${rawLine}`);
}
const key = line.slice(0, separator).trim();
const value = line.slice(separator + 1).trim();
result[key] = value;
}
return result;
} }
function parsePort(name: string, raw: string, fallback: number): number { function parsePort(name: string, raw: string, fallback: number): number {
@@ -396,47 +392,58 @@ export function validateRuntimeConfig(config: RuntimeConfig): void {
} }
} }
/**
* Пишет /etc/hy2xs/hy2xs.env.
*
* Каждое значение проходит через formatEnvAssignment, а не подставляется в
* строку интерполяцией. Раньше подставлялось, и файл поэтому был форматом
* только для значений без пробелов по краям, кавычек и обратных слешей: пароль
* администратора, у которого набор символов объявлен неограниченным, не
* пережил бы обратного чтения ни нашего, ни systemd'ного.
*
* Обычные значения (порты, пути, домены, `50 mbps`) кавычек не получают и
* остаются побайтово прежними см. UNQUOTED_SAFE_VALUE в lib/envFile.
*/
export function renderRuntimeEnv(config: RuntimeConfig): string { export function renderRuntimeEnv(config: RuntimeConfig): string {
const lines = [ const entries: [string, string][] = [
"# HY2XS runtime config (editable)", ["HY2XS_CONFIG_SCHEMA_VERSION", String(config.configSchemaVersion)],
`HY2XS_CONFIG_SCHEMA_VERSION=${config.configSchemaVersion}`, ["HY2XS_IPV6_ENABLED", String(config.ipv6Enabled)],
`HY2XS_IPV6_ENABLED=${config.ipv6Enabled}`, ["HY2XS_DOMAIN", config.domain],
`HY2XS_DOMAIN=${config.domain}`, ["HY2XS_DNS_AAAA_POLICY", config.dnsAaaaPolicy],
`HY2XS_DNS_AAAA_POLICY=${config.dnsAaaaPolicy}`, ["HY2XS_PUBLIC_ENDPOINT_POLICY", config.publicEndpointPolicy],
`HY2XS_PUBLIC_ENDPOINT_POLICY=${config.publicEndpointPolicy}`, ["HY2XS_PUBLIC_HOST", config.publicHost],
`HY2XS_PUBLIC_HOST=${config.publicHost}`, ["HY2XS_PUBLIC_PORT", String(config.publicPort)],
`HY2XS_PUBLIC_PORT=${config.publicPort}`, ["HY2XS_SSH_PORT", String(config.sshPort)],
`HY2XS_SSH_PORT=${config.sshPort}`, ["HY2XS_FIREWALL_MODE", config.firewallMode],
`HY2XS_FIREWALL_MODE=${config.firewallMode}`, ["HY2XS_FIREWALL_STAGED_APPLY", String(config.firewallStagedApply)],
`HY2XS_FIREWALL_STAGED_APPLY=${config.firewallStagedApply}`, ["HY2XS_UI_BIND_HOST", config.uiBindHost],
`HY2XS_UI_BIND_HOST=${config.uiBindHost}`, ["HY2XS_UI_PUBLIC_ACCESS", String(config.uiPublicAccess)],
`HY2XS_UI_PUBLIC_ACCESS=${config.uiPublicAccess}`, ["HY2XS_UI_PORT", String(config.uiPort)],
`HY2XS_UI_PORT=${config.uiPort}`, ["HY2XS_ADMIN_USER", config.adminUser],
`HY2XS_ADMIN_USER=${config.adminUser}`, ["HY2XS_ADMIN_INITIAL_PASSWORD", config.adminInitialPassword],
`HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`, ["HY2XS_ADMIN_CON_PASS", config.adminConPass],
`HY2XS_ADMIN_CON_PASS=${config.adminConPass}`, ["HY2XS_FORCE_PASSWORD_CHANGE", String(config.forcePasswordChange)],
`HY2XS_FORCE_PASSWORD_CHANGE=${config.forcePasswordChange}`, ["HY2XS_ALLOW_SELF_SIGNED_DEV", String(config.allowSelfSignedDev)],
`HY2XS_ALLOW_SELF_SIGNED_DEV=${config.allowSelfSignedDev}`, ["HY2XS_TLS_MODE", config.tlsMode],
`HY2XS_TLS_MODE=${config.tlsMode}`, ["HY2XS_ACME_TYPE", config.acmeType],
`HY2XS_ACME_TYPE=${config.acmeType}`, ["HY2XS_ACME_EMAIL", config.acmeEmail],
`HY2XS_ACME_EMAIL=${config.acmeEmail}`, ["HY2XS_TLS_CERT_PATH", config.tlsCertPath],
`HY2XS_TLS_CERT_PATH=${config.tlsCertPath}`, ["HY2XS_TLS_KEY_PATH", config.tlsKeyPath],
`HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`, ["HY2XS_HYSTERIA_BIND_HOST", config.hysteriaBindHost],
`HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`, ["HY2XS_HYSTERIA_PORT", String(config.hysteriaPort)],
`HY2XS_HYSTERIA_PORT=${config.hysteriaPort}`, ["HY2XS_HYSTERIA_AUTH_MODE", config.hysteriaAuthMode],
`HY2XS_HYSTERIA_AUTH_MODE=${config.hysteriaAuthMode}`, ["HY2XS_HYSTERIA_TRAFFIC_STATS_HOST", config.hysteriaTrafficStatsHost],
`HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=${config.hysteriaTrafficStatsHost}`, ["HY2XS_HYSTERIA_TRAFFIC_STATS_PORT", String(config.hysteriaTrafficStatsPort)],
`HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`, ["HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", config.hysteriaTrafficStatsSecret],
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${config.hysteriaTrafficStatsSecret}`, ["HY2XS_HYSTERIA_OBFS_TYPE", config.hysteriaObfsType],
`HY2XS_HYSTERIA_OBFS_TYPE=${config.hysteriaObfsType}`, ["HY2XS_HYSTERIA_OBFS_PASSWORD", config.hysteriaObfsPassword],
`HY2XS_HYSTERIA_OBFS_PASSWORD=${config.hysteriaObfsPassword}`, ["HY2XS_HYSTERIA_BANDWIDTH_UP", config.hysteriaBandwidthUp],
`HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`, ["HY2XS_HYSTERIA_BANDWIDTH_DOWN", config.hysteriaBandwidthDown],
`HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`, ["HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH", String(config.hysteriaIgnoreClientBandwidth)],
`HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=${config.hysteriaIgnoreClientBandwidth}`, ["HY2XS_HYSTERIA_CONFIG_PATH", config.hysteriaConfigPath],
`HY2XS_HYSTERIA_CONFIG_PATH=${config.hysteriaConfigPath}`, ["HY2XS_INSTALL_DIR", config.installDir],
`HY2XS_INSTALL_DIR=${config.installDir}`, ["HY2XS_DATA_DIR", config.dataDir],
`HY2XS_DATA_DIR=${config.dataDir}`, ["HY2XS_LOG_DIR", config.logDir]
`HY2XS_LOG_DIR=${config.logDir}`
]; ];
return `${lines.join("\n")}\n`; return `# HY2XS runtime config (editable)\n${renderEnvFile(entries)}`;
} }
+60 -10
View File
@@ -60,14 +60,26 @@ export const ADMIN_LOGIN_PATH = `${ADMIN_API_BASE}/auth/login`;
* отказ установки чинится одной строкой в hy2xs.env, а неработающий вход на * отказ установки чинится одной строкой в hy2xs.env, а неработающий вход на
* готовом сервере переустановкой. * готовом сервере переустановкой.
* *
* Значения обязаны совпадать с apps/service/admin_credentials.go; сверка * Значения обязаны совпадать с apps/credential/admin.go; сверка выполняется
* выполняется тестом admin-credentials.test.ts, который читает Go-исходник. * тестом admin-credentials.test.ts, который читает Go-исходник.
*/ */
export const ADMIN_USERNAME_MIN_LENGTH = 6; export const ADMIN_USERNAME_MIN_LENGTH = 6;
export const ADMIN_USERNAME_MAX_LENGTH = 32; export const ADMIN_USERNAME_MAX_LENGTH = 32;
export const ADMIN_PASSWORD_MIN_LENGTH = 6; export const ADMIN_PASSWORD_MIN_LENGTH = 6;
export const ADMIN_PASSWORD_MAX_LENGTH = 64; export const ADMIN_PASSWORD_MAX_LENGTH = 64;
/**
* Предел bcrypt в БАЙТАХ.
*
* Он существует одновременно с границей в символах и не выводится из неё: у 64
* символов длина от 64 до 256 байт в зависимости от алфавита. Пока оркестратор
* знал только границу в символах, он пропускал в hy2xs.env пароль вроде 64
* кириллических букв (128 байт), установка проходила целиком, а первая учётная
* запись администратора не создавалась вовсе bcrypt отвечал
* ErrPasswordTooLong уже внутри админки, при старте службы.
*/
export const ADMIN_PASSWORD_MAX_BYTES = 72;
/** /**
* Набор символов логина в записи регекспа. * Набор символов логина в записи регекспа.
* *
@@ -105,19 +117,57 @@ export function assertValidAdminUsername(name: string, value: string): string {
} }
/** /**
* Проверка пароля администратора против контракта панели. * Единственное правило пароля администратора на стороне оркестратора.
* *
* Набор символов НЕ проверяется: сервер его не ограничивает ни при установке, * Копия Go-контракта, и сверяется с ним по исходнику (admin-credentials.test.ts):
* ни при смене пароля. Проверяется только длина и в РУНАХ, ровно так её * оркестратор собирается отдельно от Go-бинарника и импортировать его константы
* считает валидатор админки. `String.length` считает единицы UTF-16, и пароль * не может.
* из эмодзи прошёл бы здесь и отказался бы на форме входа. *
* Правило состоит из трёх частей, и каждая закрывает свой класс отказа.
*
* Длина в CODE POINTS, а не в `String.length`. `String.length` считает единицы
* UTF-16, и пароль из трёх эмодзи имел бы здесь длину 6 прошёл бы минимум и
* получил отказ на форме входа, где сервер видит три руны.
*
* Длина в БАЙТАХ предел bcrypt. Пока его не было, пароль из 64 кириллических
* букв проходил установку целиком, а учётная запись администратора не
* создавалась: bcrypt отвечал ErrPasswordTooLong при старте админки, и оператор
* получал не отказ установки, а сервер без администратора.
*
* Запрет управляющих символов требование транспорта. Значение уезжает в
* /etc/hy2xs/hy2xs.env, который systemd читает как EnvironmentFile; границей
* строки там является байт `\n` или `\r`, и пароль с ними не имеет
* представления, переживающего запись и чтение. См. lib/envFile.ts.
*
* Набор символов сверх этого НЕ ограничивается: сервер его не ограничивает ни
* при установке, ни при смене пароля, и оркестратор не имеет права быть строже.
* Пробелы по краям часть пароля и не снимаются.
*/ */
export function isValidAdminPassword(value: string): boolean {
let characters = 0;
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) {
return false;
}
characters += 1;
}
if (characters < ADMIN_PASSWORD_MIN_LENGTH || characters > ADMIN_PASSWORD_MAX_LENGTH) {
return false;
}
return Buffer.byteLength(value, "utf8") <= ADMIN_PASSWORD_MAX_BYTES;
}
export function assertValidAdminPassword(name: string, value: string): string { export function assertValidAdminPassword(name: string, value: string): string {
const length = [...value].length; if (!isValidAdminPassword(value)) {
if (length < ADMIN_PASSWORD_MIN_LENGTH || length > ADMIN_PASSWORD_MAX_LENGTH) {
throw new Error( throw new Error(
`invalid ${name}: панель принимает пароль длиной от ${ADMIN_PASSWORD_MIN_LENGTH} ` + `invalid ${name}: панель принимает пароль длиной от ${ADMIN_PASSWORD_MIN_LENGTH} ` +
`до ${ADMIN_PASSWORD_MAX_LENGTH} символов, получено ${length}. ` + `до ${ADMIN_PASSWORD_MAX_LENGTH} символов Unicode, не длиннее ${ADMIN_PASSWORD_MAX_BYTES} байт ` +
`в UTF-8 и без управляющих символов; получено ${[...value].length} символов ` +
`и ${Buffer.byteLength(value, "utf8")} байт. ` +
`Набор символов не ограничен, пробелы по краям являются частью пароля. ` +
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.` `Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
); );
} }
+322
View File
@@ -0,0 +1,322 @@
/**
* Формат файлов `KEY=VALUE`, которые продукт пишет и читает: /etc/hy2xs/hy2xs.env
* и /etc/hy2xs/bootstrap-admin.secret.
*
* Зачем этот модуль существует. У hy2xs.env ДВА читателя, и один из них не наш:
* файл объявлен `EnvironmentFile=` в юните hy2xs-admin, то есть его разбирает
* systemd. Пока оркестратор писал значения интерполяцией
*
* `HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`
*
* а читал их построчным `split("=")` с `trim()`, форматом это не являлось
* это было совпадение поведения на значениях, у которых нет ни пробелов по
* краям, ни кавычек, ни обратных слешей. Продукт при этом ОБЕЩАЕТ оператору,
* что набор символов пароля не ограничен, а пробел по краям часть значения.
* Обещание не выполнялось ни одним из двух читателей:
*
* - systemd у НЕ закавыченного значения срезает пробелы по краям и трактует
* `\` как escape (src/basic/env-file.c, состояние VALUE);
* - собственный парсер срезал пробелы своим `trim()`.
*
* То есть пароль с краевым пробелом терялся ещё до запуска админки, а пароль с
* обратным слешем приезжал изменённым.
*
* Поэтому здесь ровно две функции, и они обратны друг другу:
*
* parseEnvFile разбор по правилам systemd;
* formatEnvAssignment запись, которую systemd разберёт обратно побайтово.
*
* Правила разбора не выдуманы и не выведены из документации: они повторяют
* конечный автомат `parse_env_file_internal` из systemd/src/basic/env-file.c.
* Существенны четыре его свойства:
*
* 1. у НЕ закавыченного значения срезаются пробелы в конце, `\` уводит в
* escape, `\<перевод строки>` склеивает строки;
* 2. в одинарных кавычках всё literal до закрывающей кавычки escape там
* НЕТ (это отличие от sh);
* 3. в двойных кавычках `\` уводит в escape, и escape «разворачивается»
* только для SHELL_NEED_ESCAPE то есть для `"`, `\`, `` ` `` и `$`;
* для любого другого символа обратный слеш СОХРАНЯЕТСЯ вместе с ним;
* 4. подстановки переменных в env-файле нет вовсе: `$` внутри значения
* обычный символ.
*
* Из (3) и (4) следует кодирование, которое переживает любое издание systemd:
* двойные кавычки и экранирование ТОЛЬКО `\` и `"`. Оба входят в
* SHELL_NEED_ESCAPE, поэтому разворачиваются одинаково и в действующем
* издании, и в тех, где escape в двойных кавычках снимался безусловно.
*/
/** Символы, которые systemd считает границей строки. */
const NEWLINE = "\n\r";
/** Символы, которые systemd считает пробельными. */
const WHITESPACE = " \t\n\r";
/** Начало комментария — только в позиции, где ожидается имя переменной. */
const COMMENTS = "#;";
/**
* SHELL_NEED_ESCAPE из systemd: внутри двойных кавычек обратный слеш перед этими
* символами снимается, перед любым другим сохраняется.
*/
const SHELL_NEED_ESCAPE = '"\\`$';
type State =
| "PRE_KEY"
| "KEY"
| "PRE_VALUE"
| "VALUE"
| "VALUE_ESCAPE"
| "SINGLE_QUOTE_VALUE"
| "DOUBLE_QUOTE_VALUE"
| "DOUBLE_QUOTE_VALUE_ESCAPE"
| "COMMENT"
| "COMMENT_ESCAPE";
/**
* Разбирает содержимое env-файла ровно так, как это делает systemd.
*
* Строка без `=` ОШИБКА, а не пропуск. systemd такую строку молча
* отбрасывает, и здесь это единственное намеренное расхождение: молчаливая
* потеря строки из /etc/hy2xs/hy2xs.env означала бы установку с настройкой,
* которую оператор задал, а продукт не увидел. Расхождение в сторону отказа
* безопасно оно останавливает установку там, где её можно починить.
*/
export function parseEnvFile(content: string): Record<string, string> {
const result: Record<string, string> = {};
let state: State = "PRE_KEY";
let key = "";
let value = "";
let lastKeyWhitespace = -1;
let lastValueWhitespace = -1;
let line = 1;
const flush = (stripValueWhitespace: boolean): void => {
const name = lastKeyWhitespace < 0 ? key : key.slice(0, lastKeyWhitespace);
const raw =
stripValueWhitespace && lastValueWhitespace >= 0
? value.slice(0, lastValueWhitespace)
: value;
if (name !== "") {
result[name] = raw;
}
key = "";
value = "";
lastKeyWhitespace = -1;
lastValueWhitespace = -1;
};
for (const c of content) {
switch (state) {
case "PRE_KEY":
if (COMMENTS.includes(c)) {
state = "COMMENT";
} else if (!WHITESPACE.includes(c)) {
state = "KEY";
lastKeyWhitespace = -1;
key += c;
}
break;
case "KEY":
if (NEWLINE.includes(c)) {
// Имя без `=`. systemd молча отбрасывает такую строку; мы называем её.
throw new Error(`invalid env line ${line}: ${key.trim()}`);
} else if (c === "=") {
state = "PRE_VALUE";
lastValueWhitespace = -1;
} else {
if (!WHITESPACE.includes(c)) {
lastKeyWhitespace = -1;
} else if (lastKeyWhitespace < 0) {
lastKeyWhitespace = key.length;
}
key += c;
}
break;
case "PRE_VALUE":
if (NEWLINE.includes(c)) {
state = "PRE_KEY";
line += 1;
flush(false);
} else if (c === "'") {
state = "SINGLE_QUOTE_VALUE";
} else if (c === '"') {
state = "DOUBLE_QUOTE_VALUE";
} else if (c === "\\") {
state = "VALUE_ESCAPE";
} else if (!WHITESPACE.includes(c)) {
state = "VALUE";
value += c;
}
break;
case "VALUE":
if (NEWLINE.includes(c)) {
state = "PRE_KEY";
line += 1;
flush(true);
} else if (c === "\\") {
state = "VALUE_ESCAPE";
lastValueWhitespace = -1;
} else {
if (!WHITESPACE.includes(c)) {
lastValueWhitespace = -1;
} else if (lastValueWhitespace < 0) {
lastValueWhitespace = value.length;
}
value += c;
}
break;
case "VALUE_ESCAPE":
state = "VALUE";
// Экранированный перевод строки — склейка строк, и он съедается целиком.
if (!NEWLINE.includes(c)) {
value += c;
} else {
line += 1;
}
break;
case "SINGLE_QUOTE_VALUE":
// Escape внутри одинарных кавычек НЕТ: всё до закрывающей кавычки
// приезжает как есть. Это отличие от sh, и именно поэтому кодирование
// ниже использует двойные кавычки — в одинарных нельзя записать сам
// апостроф.
if (c === "'") {
state = "PRE_VALUE";
} else {
if (NEWLINE.includes(c)) {
line += 1;
}
value += c;
}
break;
case "DOUBLE_QUOTE_VALUE":
if (c === '"') {
state = "PRE_VALUE";
} else if (c === "\\") {
state = "DOUBLE_QUOTE_VALUE_ESCAPE";
} else {
if (NEWLINE.includes(c)) {
line += 1;
}
value += c;
}
break;
case "DOUBLE_QUOTE_VALUE_ESCAPE":
state = "DOUBLE_QUOTE_VALUE";
if (SHELL_NEED_ESCAPE.includes(c)) {
value += c;
} else if (!NEWLINE.includes(c)) {
// Обратный слеш СОХРАНЯЕТСЯ вместе с символом — «как делает
// настоящий shell», по формулировке самого systemd.
value += "\\" + c;
} else {
line += 1;
}
break;
case "COMMENT":
if (c === "\\") {
state = "COMMENT_ESCAPE";
} else if (NEWLINE.includes(c)) {
state = "PRE_KEY";
line += 1;
}
break;
case "COMMENT_ESCAPE":
state = "COMMENT";
if (NEWLINE.includes(c)) {
line += 1;
}
break;
}
}
// Хвост без перевода строки на конце файла.
switch (state) {
case "KEY":
throw new Error(`invalid env line ${line}: ${key.trim()}`);
case "PRE_VALUE":
flush(false);
break;
case "VALUE":
flush(true);
break;
case "VALUE_ESCAPE":
case "SINGLE_QUOTE_VALUE":
case "DOUBLE_QUOTE_VALUE":
case "DOUBLE_QUOTE_VALUE_ESCAPE":
// Незакрытая кавычка — испорченный файл, а не значение до конца файла.
// systemd в этом месте отдаёт то, что успел накопить; для конфигурации,
// от которой зависит доступ в панель, «что успели накопить» — не ответ.
throw new Error(`unterminated env value for ${key.trim()}`);
default:
break;
}
return result;
}
/**
* Значения, которые можно записать без кавычек.
*
* Набор намеренно узкий и не выведен из правил systemd: цель чтобы уже
* существующие строки файла (пути, порты, домены, `50 mbps`, base64url-секреты)
* остались побайтово прежними, а всё хоть сколько-нибудь необычное уезжало в
* кавычки. Одиночные пробелы ВНУТРИ значения разрешены, по краям нет: именно
* краевые systemd и срезает.
*/
const UNQUOTED_SAFE_VALUE = /^[A-Za-z0-9_\-.\/:@,=+%]+(?: [A-Za-z0-9_\-.\/:@,=+%]+)*$/;
/**
* Проверяет, что значение вообще представимо в этом формате.
*
* Управляющих символов формат не несёт: перевод строки граница записи, а не
* данные. Отказ здесь громкий намеренно молчаливая потеря части секрета
* означала бы установку, после которой невозможно войти, и причину, которой
* негде увидеться.
*/
export function assertEnvTransportable(name: string, value: string): string {
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) {
throw new Error(
`${name} contains a control character (U+${code.toString(16).toUpperCase().padStart(4, "0")}): ` +
`формат KEY=VALUE, который читают systemd и оркестратор, управляющих символов не несёт`
);
}
}
return value;
}
/**
* Собирает строку `KEY=VALUE`, которую systemd разберёт обратно побайтово.
*
* Кавычки ставятся только когда они нужны, и это не косметика: пока запись
* остаётся прежней для обычных значений, релизные гейты и инструкции оператора,
* ищущие строку `grep '^HY2XS_UI_PORT=8080$'`, продолжают работать, а изменение
* формата видно ровно там, где оно что-то чинит.
*/
export function formatEnvAssignment(key: string, value: string): string {
assertEnvTransportable(key, value);
if (value === "" || UNQUOTED_SAFE_VALUE.test(value)) {
return `${key}=${value}`;
}
// Экранируются ТОЛЬКО `\` и `"`. Оба входят в SHELL_NEED_ESCAPE, поэтому
// разворачиваются обратно одинаково во всех изданиях systemd. Backtick и `$`
// внутри двойных кавычек — обычные символы: подстановки в env-файле нет.
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `${key}="${escaped}"`;
}
/** Готовый файл из пар, каждая — через formatEnvAssignment. */
export function renderEnvFile(entries: readonly (readonly [string, string])[]): string {
return `${entries.map(([key, value]) => formatEnvAssignment(key, value)).join("\n")}\n`;
}
+15 -5
View File
@@ -1,5 +1,6 @@
import type { RuntimeContext } from "../types/context"; import type { RuntimeContext } from "../types/context";
import { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs"; import { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs";
import { renderEnvFile } from "../lib/envFile";
import { runMutatingVisible } from "../lib/process"; import { runMutatingVisible } from "../lib/process";
import { import {
BBR_PROFILE, BBR_PROFILE,
@@ -56,14 +57,23 @@ export async function writePostInstallEnv(context: RuntimeContext): Promise<void
}); });
} }
/**
* Пишет /etc/hy2xs/bootstrap-admin.secret.
*
* Тот же формат и тот же кодировщик, что и у hy2xs.env, и по той же причине: в
* файле лежит пароль администратора, набор символов которого продукт объявил
* неограниченным. Пока строки собирались интерполяцией, пароль с пробелом по
* краям, кавычкой или обратным слешем нельзя было прочитать обратно а читает
* этот файл smoke, чтобы ДОКАЗАТЬ, что в панель можно войти.
*/
export async function writeBootstrapAdminSecret(context: RuntimeContext): Promise<void> { export async function writeBootstrapAdminSecret(context: RuntimeContext): Promise<void> {
await writeTextAtomic( await writeTextAtomic(
context.config.bootstrapAdminSecretPath, context.config.bootstrapAdminSecretPath,
[ renderEnvFile([
`ADMIN_USER=${context.config.adminUser}`, ["ADMIN_USER", context.config.adminUser],
`ADMIN_INITIAL_PASSWORD=${context.config.adminInitialPassword}`, ["ADMIN_INITIAL_PASSWORD", context.config.adminInitialPassword],
`ADMIN_CON_PASS=${context.config.adminConPass}` ["ADMIN_CON_PASS", context.config.adminConPass]
].join("\n") + "\n", ]),
{ {
mode: 0o600, mode: 0o600,
owner: "root", owner: "root",
+114 -17
View File
@@ -1,6 +1,8 @@
import { randomBytes } from "node:crypto";
import type { RuntimeContext } from "../types/context"; import type { RuntimeContext } from "../types/context";
import { info } from "../lib/log"; import { info } from "../lib/log";
import { readText } from "../lib/fs"; import { readText } from "../lib/fs";
import { parseEnvFile } from "../lib/envFile";
import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process"; import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process";
import { ADMIN_LOGIN_PATH, HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile"; import { ADMIN_LOGIN_PATH, HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile";
import { assertHysteriaConfigMatchesProfile } from "./configAssertions"; import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
@@ -194,7 +196,7 @@ export async function smoke(context: RuntimeContext): Promise<void> {
} }
if (context.mode === "install") { if (context.mode === "install") {
const adminConPass = (await runReadOnlySecret`grep '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-`).trim(); const adminConPass = (await readBootstrapAdminSecret(context)).ADMIN_CON_PASS ?? "";
if (!adminConPass) { if (!adminConPass) {
throw new Error("admin connection password is empty in bootstrap secret file"); throw new Error("admin connection password is empty in bootstrap secret file");
} }
@@ -279,36 +281,55 @@ export async function smoke(context: RuntimeContext): Promise<void> {
async function assertAdminLoginWorks(context: RuntimeContext): Promise<void> { async function assertAdminLoginWorks(context: RuntimeContext): Promise<void> {
const loginUrl = `http://127.0.0.1:${context.config.uiPort}${ADMIN_LOGIN_PATH}`; const loginUrl = `http://127.0.0.1:${context.config.uiPort}${ADMIN_LOGIN_PATH}`;
// Отрицательная проба доказывает ОТКАЗ, а не отсутствие HTTP 500.
//
// Здесь стояли жёстко записанный пароль `definitely-not-the-admin-password` и
// критерий `code.trim() === "200"`. У этой пары было два изъяна. Пароль был
// литералом, то есть теоретически мог оказаться настоящим — и тогда проверка
// «неверные данные отвергаются» отчиталась бы об успешном ВХОДЕ. А критерий
// «HTTP 200» не отличал отказ от успеха вовсе: админка отвечает 200 и на то,
// и на другое, причина живёт в конверте. Проверка доказывала ровно одно —
// что запрос не закончился пятисоткой.
//
// Теперь пароль генерируется в момент пробы, а результат сверяется с
// конвертом: код 50000, причина `invalid_credentials` и ОТСУТСТВИЕ токена
// доступа. Логин берётся настоящий — тот же, что у администратора, — чтобы
// запрос шёл тем же путём, что и реальный вход, и доходил до проверки пароля.
const rejectedPassword = randomBytes(24).toString("base64url");
const rejectedPayload = JSON.stringify({ const rejectedPayload = JSON.stringify({
username: "hy2xsadmin", username: context.config.adminUser,
pass: "definitely-not-the-admin-password" pass: rejectedPassword
}); });
const rejectedStatus = await retry( const rejectedBody = await retry(
"admin login rejects wrong credentials", "admin login rejects wrong credentials",
10, 10,
1000, 1000,
async () => async () =>
runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data ${rejectedPayload} ${loginUrl}`, runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data ${rejectedPayload} ${loginUrl}`,
(code) => code.trim() === "200", (body) => isRejectedLogin(body),
(code, error) => (body, error) =>
new Error( new Error(
`admin login answered HTTP ${code ?? String(error)} for invalid credentials: ` + `admin login did not reject invalid credentials: ${describeRejectionFailure(body, error)}\n` +
`панель обязана отвечать конвертом отказа, а HTTP 500 здесь означает, что запрос ` + `Панель обязана отвечать конвертом отказа с причиной ${INVALID_CREDENTIALS_CODE}. ` +
`не доживает до проверки учётных данных` `Отсутствие такого конверта означает, что запрос не доживает до проверки учётных данных.`
) )
); );
info(`admin login rejects wrong credentials with HTTP ${rejectedStatus.trim()}`); info(`admin login rejects wrong credentials: ${describeRejection(rejectedBody)}`);
if (context.mode !== "install") { if (context.mode !== "install") {
return; return;
} }
const adminUser = ( // Файл читается ЦЕЛИКОМ и разбирается тем же парсером, которым он написан.
await runReadOnlySecret`grep '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-` //
).trim(); // Здесь стояло `grep … | cut -d= -f2-` с последующим `.trim()`, и это был
const adminPassword = ( // третий по счёту слой, срезающий пробелы у пароля, набор символов которого
await runReadOnlySecret`grep '^ADMIN_INITIAL_PASSWORD=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-` // объявлен неограниченным. Даже с починенными env-транспортом и админкой
).trim(); // проба брала бы не тот пароль и объявляла бы рабочую установку сломанной.
// Побочно исчез и прогон секрета через shell-конвейер.
const bootstrap = await readBootstrapAdminSecret(context);
const adminUser = bootstrap.ADMIN_USER ?? "";
const adminPassword = bootstrap.ADMIN_INITIAL_PASSWORD ?? "";
if (!adminUser) { if (!adminUser) {
throw new Error("admin username is empty in bootstrap secret file"); throw new Error("admin username is empty in bootstrap secret file");
} }
@@ -316,6 +337,13 @@ async function assertAdminLoginWorks(context: RuntimeContext): Promise<void> {
throw new Error("admin initial password is empty in bootstrap secret file"); throw new Error("admin initial password is empty in bootstrap secret file");
} }
// Отрицательная проба обязана быть отрицательной. Совпадение случайного
// пароля с настоящим невероятно, но проверить это здесь можно точно, и тогда
// «отвергнуто» гарантированно означает «отвергнуто», а не «не совпало».
if (rejectedPassword === adminPassword) {
throw new Error("negative login probe accidentally used the real admin password");
}
const payload = JSON.stringify({ username: adminUser, pass: adminPassword }); const payload = JSON.stringify({ username: adminUser, pass: adminPassword });
const response = await retry( const response = await retry(
"admin login with bootstrap credentials", "admin login with bootstrap credentials",
@@ -348,6 +376,75 @@ function isSuccessfulLogin(body: string): boolean {
return /"code"\s*:\s*20000/.test(body) && /"accessToken"\s*:\s*"[^"]+"/.test(body); return /"code"\s*:\s*20000/.test(body) && /"accessToken"\s*:\s*"[^"]+"/.test(body);
} }
/**
* Код причины отказа по учётным данным тот же литерал, что и
* constant.ErrCodeInvalidCredentials в админке.
*
* Он часть публичного контракта API: панель выбирает по нему локализованную
* фразу, и его значение не меняется вместе с формулировками сообщений.
*/
const INVALID_CREDENTIALS_CODE = "invalid_credentials";
/**
* Отказ определяется по КОНВЕРТУ целиком, а не по отсутствию HTTP 500.
*
* Требуются три признака сразу, и каждый закрывает свой способ ошибиться:
*
* код ответа 50000 это отказ уровня операции, а не успех (20000) и
* не отказ валидации (50001), который означал бы,
* что проба отправила негодный запрос и до проверки
* пароля он не дошёл;
* причина invalid_credentials отказ именно по учётным данным, а не,
* скажем, по недоступной базе;
* отсутствие accessToken панель не выдала сессию.
*/
function isRejectedLogin(body: string): boolean {
return (
/"code"\s*:\s*50000/.test(body) &&
new RegExp(`"code"\\s*:\\s*"${INVALID_CREDENTIALS_CODE}"`).test(body) &&
!/"accessToken"\s*:\s*"[^"]+"/.test(body)
);
}
/**
* Причина, по которой отрицательная проба не признана отказом, БЕЗ тела
* ответа.
*
* Тело сюда попасть не может по той же причине, что и в describeLoginFailure:
* если панель по недоразумению впустила, в ответе лежит токен доступа, а этот
* текст уезжает в журнал установки и в diagnostics-бандл.
*/
function describeRejectionFailure(body: string | undefined, error: unknown): string {
if (body === undefined) {
return `запрос не выполнен: ${String(error)}`;
}
if (/"accessToken"\s*:\s*"[^"]+"/.test(body)) {
return "панель ВЫДАЛА токен доступа на заведомо неверные учётные данные";
}
return describeRejection(body);
}
/** Как выглядит отказ: код конверта и код причины, без тела. */
function describeRejection(body: string): string {
const envelope = body.match(/"code"\s*:\s*(\d+)/);
const reason = body.match(/"code"\s*:\s*"([a-z_]+)"/);
return `code=${envelope ? envelope[1] : "нет"}, причина=${reason ? reason[1] : "нет"}, токен не выдан`;
}
// Читает /etc/hy2xs/bootstrap-admin.secret тем же парсером, которым он написан.
//
// Файл несёт пароль администратора, у которого набор символов не ограничен, а
// пробелы по краям являются частью значения. Прежнее чтение —
// `grep … | cut -d= -f2-` с `.trim()` — не умело ни того, ни другого, и вдобавок
// прогоняло секрет через shell-конвейер.
//
// Комментарий записан строчными `//`, а не блоком: скан релизных гейтов
// отбрасывает только их, и объяснение, называющее убранную конструкцию по
// имени, иначе роняет проверку «этой конструкции здесь больше нет».
async function readBootstrapAdminSecret(context: RuntimeContext): Promise<Record<string, string>> {
return parseEnvFile(await readText(context.config.bootstrapAdminSecretPath));
}
/** /**
* Причина отказа БЕЗ тела ответа. * Причина отказа БЕЗ тела ответа.
* *
+159 -10
View File
@@ -5,12 +5,14 @@ import path from "node:path";
import { parseRuntimeEnv } from "../src/config/env"; import { parseRuntimeEnv } from "../src/config/env";
import { import {
ADMIN_LOGIN_PATH, ADMIN_LOGIN_PATH,
ADMIN_PASSWORD_MAX_BYTES,
ADMIN_PASSWORD_MAX_LENGTH, ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH, ADMIN_PASSWORD_MIN_LENGTH,
ADMIN_USERNAME_CHARSET, ADMIN_USERNAME_CHARSET,
ADMIN_USERNAME_MAX_LENGTH, ADMIN_USERNAME_MAX_LENGTH,
ADMIN_USERNAME_MIN_LENGTH, ADMIN_USERNAME_MIN_LENGTH,
ADMIN_USERNAME_PATTERN ADMIN_USERNAME_PATTERN,
isValidAdminPassword
} from "../src/config/profile"; } from "../src/config/profile";
import { baselineConfig, envText } from "./fixtures"; import { baselineConfig, envText } from "./fixtures";
@@ -28,7 +30,7 @@ const REPO_ROOT = path.resolve(import.meta.dir, "..", "..");
function goContract(): string { function goContract(): string {
return fs.readFileSync( return fs.readFileSync(
path.join(REPO_ROOT, "apps", "service", "admin_credentials.go"), path.join(REPO_ROOT, "apps", "credential", "admin.go"),
"utf8" "utf8"
); );
} }
@@ -43,18 +45,37 @@ function goConst(name: string): string {
if (backticked) { if (backticked) {
return backticked[1]; return backticked[1];
} }
throw new Error(`константа ${name} не найдена в apps/service/admin_credentials.go`); throw new Error(`константа ${name} не найдена в apps/credential/admin.go`);
}
/**
* Содержимое файла без комментариев.
*
* Проверять положено КОД, а не упоминания: комментарий, объясняющий, почему
* убранной конструкции здесь больше нет, обязан называть её по имени, иначе он
* бесполезен, и проверка «такой конструкции в файле нет» начинает падать
* ровно на той документации, которая описывает выполненную ею же работу.
*/
function codeOf(source: string): string {
return source
.split("\n")
.filter((line) => !/^\s*(\/\/|\/?\*)/.test(line))
.join("\n");
} }
describe("контракт учётных данных совпадает с админкой", () => { describe("контракт учётных данных совпадает с админкой", () => {
// Копия контракта в TypeScript неизбежна: оркестратор собирается отдельно от // Копия контракта в TypeScript неизбежна: оркестратор собирается отдельно от
// Go-бинарника и импортировать его константы не может. Проверяется поэтому // Go-бинарника и импортировать его константы не может. Проверяется поэтому
// не отсутствие копии, а её совпадение с оригиналом — сверкой по исходнику. // не отсутствие копии, а её совпадение с оригиналом — сверкой по исходнику.
test("границы длины совпадают с service.Admin*Length", () => { test("границы совпадают с credential.Admin*", () => {
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(goConst("AdminUsernameMinLength")); expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(goConst("AdminUsernameMinLength"));
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength")); expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength"));
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength")); expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength"));
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength")); expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength"));
// Предел bcrypt — ОТДЕЛЬНАЯ константа и отдельная сверка: пока его тут не
// было, оркестратор пропускал в hy2xs.env пароль, который админка не могла
// захешировать, и установка заканчивалась сервером без администратора.
expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe(goConst("AdminPasswordMaxBytes"));
}); });
test("набор символов логина совпадает с классом из Go", () => { test("набор символов логина совпадает с классом из Go", () => {
@@ -191,13 +212,79 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо
} }
}); });
// Длина считается в РУНАХ: 32 кириллические буквы — это 64 байта, и проверка // Границ ДВЕ, и они в разных единицах.
// по байтам отвергла бы пароль, который панель принимает. //
test("длина считается в символах, а не в байтах", () => { // Здесь стоял тест «длина считается в символах, а не в байтах», требовавший
const cyrillic = "я".repeat(ADMIN_PASSWORD_MAX_LENGTH); // приёма 64 кириллических букв. Он закреплял как ожидаемое ровно то значение,
expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: cyrillic }).adminInitialPassword).toBe( // на котором продукт ломался: 64 символа — это 128 байт, а
cyrillic // bcrypt.GenerateFromPassword отвечает ErrPasswordTooLong строго при длине
// больше 72 байт. Установка проходила целиком, а первая учётная запись
// администратора не создавалась вовсе.
//
// Длина по-прежнему считается в СИМВОЛАХ — иначе оркестратор был бы строже
// панели, — но байтовая граница существует одновременно с ней.
test("границы пароля проверяются и в символах, и в байтах", () => {
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, случаи
// ниже проверяли бы не то, что написано.
expect(Buffer.byteLength("я".repeat(36), "utf8")).toBe(ADMIN_PASSWORD_MAX_BYTES);
expect(Buffer.byteLength("😀".repeat(18), "utf8")).toBe(ADMIN_PASSWORD_MAX_BYTES);
const accepted = [
"a".repeat(ADMIN_PASSWORD_MIN_LENGTH),
"a".repeat(ADMIN_PASSWORD_MAX_LENGTH),
"я".repeat(36),
"😀".repeat(18),
"abcde ",
" ".repeat(ADMIN_PASSWORD_MIN_LENGTH)
];
for (const password of accepted) {
expect(isValidAdminPassword(password)).toBe(true);
expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: `"${password}"` }).adminInitialPassword).toBe(
password
); );
}
const rejected = [
"a".repeat(ADMIN_PASSWORD_MIN_LENGTH - 1),
"a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1),
"я".repeat(37),
"😀".repeat(19),
"я".repeat(ADMIN_PASSWORD_MAX_LENGTH)
];
for (const password of rejected) {
expect(isValidAdminPassword(password)).toBe(false);
expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: `"${password}"` })).toThrow(
/invalid HY2XS_ADMIN_INITIAL_PASSWORD/
);
}
});
// Отказ обязан НАЗЫВАТЬ обе границы: пароль из 40 эмодзи укладывается в 64
// символа и не укладывается в 72 байта, и сообщение «не длиннее 64 символов»
// отправило бы оператора сокращать пароль, отвергнутый не за это.
test("отказ называет и символы, и байты", () => {
const tooManyBytes = `"${"я".repeat(ADMIN_PASSWORD_MAX_LENGTH)}"`;
expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: tooManyBytes })).toThrow(
new RegExp(`${ADMIN_PASSWORD_MAX_BYTES} байт`)
);
expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: tooManyBytes })).toThrow(
new RegExp(`${ADMIN_PASSWORD_MIN_LENGTH}.*${ADMIN_PASSWORD_MAX_LENGTH}`)
);
});
// Управляющий символ отвергается контрактом, а не теряется транспортом.
test("управляющий символ в пароле роняет установку", () => {
expect(isValidAdminPassword("abcde\n")).toBe(false);
expect(isValidAdminPassword("abcde\t")).toBe(false);
});
// Сгенерированный пароль обязан проходить контракт ЦЕЛИКОМ, включая байты:
// генератор — такой же источник значения, как и оператор.
test("сгенерированный пароль проходит контракт целиком", () => {
for (let i = 0; i < 50; i += 1) {
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_INITIAL_PASSWORD: "__GENERATE__" }));
expect(isValidAdminPassword(config.adminInitialPassword)).toBe(true);
}
}); });
}); });
@@ -277,4 +364,66 @@ describe("smoke выполняет настоящий вход в панель",
expect(rejected).toBeGreaterThan(-1); expect(rejected).toBeGreaterThan(-1);
expect(modeGate).toBeGreaterThan(rejected); expect(modeGate).toBeGreaterThan(rejected);
}); });
// Отрицательная проба обязана доказывать ОТКАЗ.
//
// Она проверяла `code.trim() === "200"`, то есть ровно одно: что запрос не
// закончился пятисоткой. Админка отвечает HTTP 200 и на успешный вход тоже,
// поэтому «неверные учётные данные отвергнуты» этой проверкой не следовало.
test("отрицательная проба сверяет конверт отказа, а не код HTTP", () => {
const source = smoke();
expect(source).toContain("isRejectedLogin");
// Код конверта отказа, доменная причина и ОТСУТСТВИЕ токена — три признака,
// и каждый закрывает свой способ ошибиться.
expect(source).toMatch(/"code"\\s\*:\\s\*50000/);
expect(source).toContain('INVALID_CREDENTIALS_CODE = "invalid_credentials"');
const start = source.indexOf("function isRejectedLogin");
const body = source.slice(start, source.indexOf("\n/**", start));
expect(body).toContain("!/\"accessToken\"");
});
// Доменный код обязан совпадать с тем, который объявляет админка.
test("код причины отказа совпадает с constant.ErrCodeInvalidCredentials", () => {
const goErrors = fs.readFileSync(
path.join(REPO_ROOT, "apps", "model", "constant", "error.go"),
"utf8"
);
const declared = goErrors.match(/ErrCodeInvalidCredentials\s+string\s*=\s*"([^"]+)"/);
expect(declared).not.toBeNull();
expect(smoke()).toContain(`INVALID_CREDENTIALS_CODE = "${declared![1]}"`);
});
// Пароль отрицательной пробы не имеет права быть литералом: записанное в
// исходнике значение теоретически может оказаться настоящим паролем, и тогда
// проверка «неверные данные отвергаются» отчиталась бы об успешном ВХОДЕ.
test("пароль отрицательной пробы генерируется, а не записан в исходнике", () => {
const source = smoke();
// Считается КОД, а не упоминания: комментарий, объясняющий, почему прежнего
// литерала здесь больше нет, обязан называть его по имени — иначе он
// бесполезен. Тот же урок уже записан в code_without_comments в
// acceptance.sh и в codeOf во frontend-contract.test.ts.
expect(codeOf(source)).not.toContain("definitely-not-the-admin-password");
expect(source).toContain('randomBytes(24).toString("base64url")');
expect(source).toContain("negative login probe accidentally used the real admin password");
});
// Bootstrap-секрет читается ПАРСЕРОМ того же формата, которым он написан.
//
// Прежнее `grep … | cut -d= -f2-` с `.trim()` было третьим по счёту слоем,
// срезающим краевые пробелы у пароля, набор символов которого объявлен
// неограниченным: положительная проба брала не тот пароль и объявляла бы
// рабочую установку сломанной.
test("bootstrap-секрет читается парсером формата, а не шелл-конвейером", () => {
const source = smoke();
expect(source).toContain("readBootstrapAdminSecret");
expect(source).toContain("parseEnvFile(await readText(context.config.bootstrapAdminSecretPath))");
// Извлечения значения шеллом не остаётся ни одного. Проверки НАЛИЧИЯ
// ключей (`grep -q '^ADMIN_USER='`) при этом законны и остаются: они
// отвечают на другой вопрос — цел ли файл, — и значения не читают.
expect(codeOf(source)).not.toContain("cut -d= -f2-");
});
}); });
+191
View File
@@ -0,0 +1,191 @@
import { describe, expect, test } from "bun:test";
import {
assertEnvTransportable,
formatEnvAssignment,
parseEnvFile,
renderEnvFile
} from "../src/lib/envFile";
import { parseRuntimeEnv, renderRuntimeEnv } from "../src/config/env";
import { baselineConfig, envText } from "./fixtures";
/**
* Формат /etc/hy2xs/hy2xs.env.
*
* У файла ДВА читателя, и один из них не наш: он объявлен `EnvironmentFile=` в
* юните hy2xs-admin, то есть его разбирает systemd. Пока значения писались
* интерполяцией, а читались `split("=")` с `trim()`, форматом это не являлось
* совпадение поведения держалось на том, что в значениях не встречалось ни
* пробелов по краям, ни кавычек, ни обратных слешей. Продукт при этом обещает
* оператору, что набор символов пароля не ограничен, а краевой пробел часть
* значения.
*
* Проверяется поэтому не «работает на наших данных», а совпадение с правилами
* systemd (src/basic/env-file.c) на тех самых значениях, где прежний разбор
* расходился.
*/
describe("разбор повторяет правила systemd", () => {
test("у незакавыченного значения срезаются краевые пробелы", () => {
// Ровно это делает состояние VALUE: `value[last_value_whitespace] = 0`.
// Отсюда и требование закавычивать пароль: иначе его хвост теряет systemd,
// а не наш парсер.
expect(parseEnvFile("KEY= value \n")).toEqual({ KEY: "value" });
});
test("в двойных кавычках краевые пробелы сохраняются", () => {
expect(parseEnvFile('KEY=" value "\n')).toEqual({ KEY: " value " });
});
test("в двойных кавычках снимается escape только у SHELL_NEED_ESCAPE", () => {
// systemd: `если символ из тех, что нуждаются в экранировании, — снять
// экранирование; иначе оставить обратный слеш на месте, как делает shell`.
expect(parseEnvFile('KEY="a\\"b"\n')).toEqual({ KEY: 'a"b' });
expect(parseEnvFile('KEY="a\\\\b"\n')).toEqual({ KEY: "a\\b" });
expect(parseEnvFile('KEY="a\\$b"\n')).toEqual({ KEY: "a$b" });
expect(parseEnvFile('KEY="a\\`b"\n')).toEqual({ KEY: "a`b" });
// `n` в SHELL_NEED_ESCAPE не входит: обратный слеш сохраняется.
expect(parseEnvFile('KEY="a\\nb"\n')).toEqual({ KEY: "a\\nb" });
});
test("подстановки переменных в значении нет", () => {
// env-файл не является shell-скриптом: `$` — обычный символ.
expect(parseEnvFile('KEY="$HOME ${OTHER}"\n')).toEqual({ KEY: "$HOME ${OTHER}" });
});
test("в одинарных кавычках всё literal, escape там нет", () => {
expect(parseEnvFile("KEY='a\\b\"c $d'\n")).toEqual({ KEY: 'a\\b"c $d' });
});
test("незакавыченное значение обрабатывает обратный слеш как escape", () => {
expect(parseEnvFile("KEY=a\\ b\n")).toEqual({ KEY: "a b" });
// Экранированный перевод строки склеивает строки.
expect(parseEnvFile("KEY=a\\\nb\n")).toEqual({ KEY: "ab" });
});
test("комментарии и пустые строки пропускаются", () => {
expect(parseEnvFile("# comment\n\n; other\n KEY=value\n")).toEqual({ KEY: "value" });
});
test("пустое значение остаётся пустой строкой", () => {
expect(parseEnvFile("KEY=\n")).toEqual({ KEY: "" });
});
test("значение без перевода строки в конце файла не теряется", () => {
expect(parseEnvFile("KEY=value")).toEqual({ KEY: "value" });
});
// Единственное намеренное расхождение с systemd: он такую строку молча
// отбрасывает. Молчаливая потеря строки из hy2xs.env означала бы установку с
// настройкой, которую оператор задал, а продукт не увидел.
test("строка без знака равенства — отказ, а не пропуск", () => {
expect(() => parseEnvFile("HY2XS_UI_PORT\n")).toThrow(/invalid env line/);
});
test("незакрытая кавычка — отказ, а не значение до конца файла", () => {
expect(() => parseEnvFile('KEY="value\n')).toThrow(/unterminated env value/);
});
});
describe("запись обратима разбором", () => {
// Значения, на которых прежняя запись теряла или искажала содержимое. Каждое
// из них — законный пароль администратора: набор символов не ограничен.
const values = [
"simple-value",
" краевые пробелы ",
"trailing space ",
" leading space",
'кавычка " внутри',
"обратный слеш \\ внутри",
"и то и другое \\\" вместе",
"доллар $HOME и backtick `id`",
"апостроф ' внутри",
"решётка # и точка с запятой ;",
"50 mbps",
"равно = внутри значения",
"пароль-администратора",
"😀😀😀😀😀😀"
];
test("любое значение переживает render -> parse побайтово", () => {
for (const value of values) {
const rendered = renderEnvFile([["HY2XS_ADMIN_INITIAL_PASSWORD", value]]);
expect(parseEnvFile(rendered).HY2XS_ADMIN_INITIAL_PASSWORD).toBe(value);
}
});
test("кавычки ставятся только там, где они нужны", () => {
// Обычные значения обязаны остаться побайтово прежними: релизные гейты и
// инструкции оператора ищут строки вида `^HY2XS_UI_PORT=8080$`.
expect(formatEnvAssignment("HY2XS_UI_PORT", "8080")).toBe("HY2XS_UI_PORT=8080");
expect(formatEnvAssignment("HY2XS_ADMIN_USER", "hy2xsadmin")).toBe("HY2XS_ADMIN_USER=hy2xsadmin");
expect(formatEnvAssignment("HY2XS_HYSTERIA_BANDWIDTH_UP", "50 mbps")).toBe(
"HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps"
);
expect(formatEnvAssignment("HY2XS_TLS_CERT_PATH", "/etc/hysteria/server.crt")).toBe(
"HY2XS_TLS_CERT_PATH=/etc/hysteria/server.crt"
);
expect(formatEnvAssignment("HY2XS_DOMAIN", "")).toBe("HY2XS_DOMAIN=");
// А необычные — получают кавычки и экранирование.
expect(formatEnvAssignment("K", "trailing ")).toBe('K="trailing "');
expect(formatEnvAssignment("K", 'a"b')).toBe('K="a\\"b"');
expect(formatEnvAssignment("K", "a\\b")).toBe('K="a\\\\b"');
});
test("экранируются ТОЛЬКО обратный слеш и двойная кавычка", () => {
// Оба входят в SHELL_NEED_ESCAPE, поэтому разворачиваются обратно
// одинаково во всех изданиях systemd — в том числе в тех, где escape в
// двойных кавычках снимался безусловно. `$` и backtick трогать нельзя:
// подстановки в env-файле нет, и лишний слеш стал бы частью значения.
expect(formatEnvAssignment("K", "a$b`c")).toBe('K="a$b`c"');
});
test("управляющий символ — отказ записи, а не потеря части значения", () => {
expect(() => assertEnvTransportable("HY2XS_ADMIN_INITIAL_PASSWORD", "a\nb")).toThrow(
/control character/
);
expect(() => formatEnvAssignment("HY2XS_ADMIN_INITIAL_PASSWORD", "a\tb")).toThrow(
/control character/
);
});
});
describe("пароль администратора доезжает до админки неизменным", () => {
// Главный барьер файла. Продукт объявляет краевой пробел частью пароля;
// прежняя цепочка теряла его дважды — при записи (systemd срезал бы хвост
// незакавыченного значения) и при чтении (собственный `trim()`).
const passwords = [
"abcde ",
" abcde",
'пароль "в кавычках"',
"back\\slash",
"d$llar and `tick`",
"апостроф ' и решётка #",
" "
];
test("render -> parse сохраняет значение целиком", () => {
for (const password of passwords) {
const config = baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: null });
const rendered = renderRuntimeEnv({ ...config, adminInitialPassword: password });
expect(parseRuntimeEnv(rendered).adminInitialPassword).toBe(password);
}
});
test("значение, записанное оператором в кавычках, читается как есть", () => {
// Оператор правит hy2xs.env руками: закавыченная запись обязана работать и
// у нас, и у systemd одинаково.
const config = parseRuntimeEnv(
envText({ HY2XS_ADMIN_INITIAL_PASSWORD: '"пароль с пробелом на конце "' })
);
expect(config.adminInitialPassword).toBe("пароль с пробелом на конце ");
});
test("незакавыченное значение теряет краевые пробелы — как и у systemd", () => {
// Это НЕ дефект, а точное повторение чужих правил: оператор, записавший
// пароль без кавычек, получит ровно тот пароль, который увидит админка.
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_INITIAL_PASSWORD: "abcdef " }));
expect(config.adminInitialPassword).toBe("abcdef");
});
});
+90 -2
View File
@@ -799,16 +799,79 @@ run_admin_login_acceptance() {
|| fail "acceptance: барьер перестал обходить исходники модели" || fail "acceptance: барьер перестал обходить исходники модели"
log_step "Acceptance: the admin credential contract has a single owner" log_step "Acceptance: the admin credential contract has a single owner"
[ -f apps/service/admin_credentials.go ] \ # Контракт живёт в LEAF-пакете, а не в service. Это не вкусовщина: его зовут
# util.HashPassword и dao (bootstrap), а `service` импортирует `util` —
# обратный импорт был бы циклическим. Пока контракт лежал в service,
# HashPassword завёл собственную проверку `len(TrimSpace(p)) < 6`, и она
# разошлась с остальным продуктом.
[ -f apps/credential/admin.go ] \
|| fail "acceptance: контракт учётных данных администратора отсутствует" || fail "acceptance: контракт учётных данных администратора отсутствует"
code_has apps/controller/validator.go -F -- 'service.IsValidAdminUsername' \ [ ! -f apps/service/admin_credentials.go ] \
|| fail "acceptance: контракт вернулся в service, откуда его не может позвать util.HashPassword"
code_has apps/controller/validator.go -F -- 'credential.IsValidAdminUsername' \
|| fail "acceptance: правило логина снова несёт собственную копию набора символов" || fail "acceptance: правило логина снова несёт собственную копию набора символов"
code_has apps/controller/validator.go -F -- 'credential.IsValidAdminPassword' \
|| fail "acceptance: правило пароля снова несёт собственную копию границ"
# Длина логина живёт ВНУТРИ правила. Два правила длины на одном поле уже # Длина логина живёт ВНУТРИ правила. Два правила длины на одном поле уже
# приводили к отказу, который невозможно объяснить оператору. # приводили к отказу, который невозможно объяснить оператору.
! code_has apps/model/dto/auth.go -E -- 'validate:"[^"]*(min|max)=[0-9]+[^"]*credentialStr' \ ! code_has apps/model/dto/auth.go -E -- 'validate:"[^"]*(min|max)=[0-9]+[^"]*credentialStr' \
|| fail "acceptance: на логине снова стоит отдельная граница длины рядом с правилом" || fail "acceptance: на логине снова стоит отдельная граница длины рядом с правилом"
log_step "Acceptance: the password contract knows the bcrypt byte limit"
# Границ у пароля ДВЕ и они в разных единицах. bcrypt читает 72 БАЙТА и
# отвечает ErrPasswordTooLong; 64 символа — это от 64 до 256 байт. Пока
# байтовой границы не было, пароль из 64 кириллических букв проходил
# оркестратор, DTO и панель, а падал в хешировании — системной ошибкой на
# штатной смене пароля и отсутствием администратора после установки.
code_has apps/credential/admin.go -F -- 'AdminPasswordMaxBytes = 72' \
|| fail "acceptance: предел bcrypt в байтах не объявлен в контракте"
code_has orchestrator/src/config/profile.ts -F -- 'ADMIN_PASSWORD_MAX_BYTES = 72' \
|| fail "acceptance: оркестратор снова не знает про предел bcrypt в байтах"
code_has apps/frontend/src/constants/credentials.ts -F -- 'ADMIN_PASSWORD_MAX_BYTES = 72' \
|| fail "acceptance: панель снова не знает про предел bcrypt в байтах"
# Границы пароля живут ВНУТРИ правила: тег валидатора умеет считать только
# символы, а границу в байтах им не выразить вовсе.
local password_dto
for password_dto in apps/model/dto/auth.go apps/model/dto/admin_user.go; do
code_has "$password_dto" -F -- 'adminPassword' \
|| fail "acceptance: $password_dto больше не проверяет пароль общим правилом"
! code_has "$password_dto" -E -- 'validate:"[^"]*(min|max)=[0-9]+[^"]*"' \
|| fail "acceptance: в $password_dto вернулась граница длины рядом с правилом пароля"
done
log_step "Acceptance: the password is never trimmed on its way to bcrypt"
# Пробел по краям объявлен ЧАСТЬЮ пароля. Пока HashPassword триммил значение,
# пароль `abcde ` принимали все двери продукта и не мог захешировать никто; а
# пока bootstrap триммил HY2XS_ADMIN_INITIAL_PASSWORD, учётная запись
# создавалась не с тем паролем, который оператор записал в hy2xs.env.
code_has apps/util/encrypt.go -F -- 'credential.IsValidAdminPassword(password)' \
|| fail "acceptance: HashPassword снова проверяет пароль собственным правилом"
! code_has apps/util/encrypt.go -F -- 'strings.TrimSpace(password)' \
|| fail "acceptance: в HashPassword вернулся TrimSpace — пароль с краевым пробелом снова нельзя захешировать"
! code_has apps/dao/sqlite.go -F -- 'strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))' \
|| fail "acceptance: bootstrap снова обрезает пароль первого администратора"
code_has apps/dao/sqlite.go -F -- 'credential.IsValidAdminPassword(adminPassword)' \
|| fail "acceptance: bootstrap снова не проверяет пароль против контракта"
log_step "Acceptance: hy2xs.env is a format, not a lucky interpolation"
# У файла ДВА читателя, и один из них — systemd (EnvironmentFile=). Пока
# значения писались интерполяцией, а читались split('=') с trim(), пароль с
# краевым пробелом, кавычкой или обратным слешем терялся ещё до запуска
# админки — при том, что продукт обещает неограниченный набор символов.
[ -f orchestrator/src/lib/envFile.ts ] \
|| fail "acceptance: модуль формата env-файлов отсутствует"
code_has orchestrator/src/config/env.ts -F -- 'parseEnvFile(content)' \
|| fail "acceptance: разбор hy2xs.env снова расходится с systemd"
code_has orchestrator/src/config/env.ts -F -- 'renderEnvFile(entries)' \
|| fail "acceptance: запись hy2xs.env снова идёт интерполяцией"
code_has orchestrator/src/steps/env.ts -F -- 'renderEnvFile(' \
|| fail "acceptance: bootstrap-admin.secret снова пишется интерполяцией"
# Экранируются ТОЛЬКО те символы, которые systemd разворачивает обратно.
code_has orchestrator/src/lib/envFile.ts -F -- 'SHELL_NEED_ESCAPE' \
|| fail "acceptance: кодировщик env потерял ссылку на правила systemd"
log_step "Acceptance: the panel is never stricter than the server" log_step "Acceptance: the panel is never stricter than the server"
[ -f apps/frontend/src/constants/credentials.ts ] \ [ -f apps/frontend/src/constants/credentials.ts ] \
|| fail "acceptance: контракт учётных данных панели отсутствует" || fail "acceptance: контракт учётных данных панели отсутствует"
@@ -821,6 +884,12 @@ run_admin_login_acceptance() {
# серверным только потому, что обе стороны несли одну опечатку. # серверным только потому, что обе стороны несли одну опечатку.
! code_has "$form" -F -- '_+-=' \ ! code_has "$form" -F -- '_+-=' \
|| fail "acceptance: $form вернул класс символов, записанный диапазоном по опечатке" || fail "acceptance: $form вернул класс символов, записанный диапазоном по опечатке"
# Встроенные min/max Element Plus делегируются async-validator, а он считает
# длину строки в единицах UTF-16: пароль из трёх эмодзи имел length 6,
# проходил минимум формы и получал отказ сервера, который панель не могла
# объяснить. Правило пароля обязано быть общим и считать code points.
code_has "$form" -F -- 'adminPasswordFormRule(' \
|| fail "acceptance: $form снова проверяет пароль встроенными min/max async-validator"
done done
log_step "Acceptance: install proves that the panel lets an operator in" log_step "Acceptance: install proves that the panel lets an operator in"
@@ -849,6 +918,25 @@ run_admin_login_acceptance() {
! grep -q 'runReadOnly`curl' <<<"$login_body" \ ! grep -q 'runReadOnly`curl' <<<"$login_body" \
|| fail "acceptance: запрос входа выполняется раннером, кладущим команду в текст ошибки" || fail "acceptance: запрос входа выполняется раннером, кладущим команду в текст ошибки"
log_step "Acceptance: the negative login probe proves a rejection"
# Проверка `code.trim() === "200"` доказывала ровно одно: что запрос не
# закончился пятисоткой. Админка отвечает HTTP 200 и на успешный вход тоже,
# поэтому «неверные учётные данные отвергнуты» из неё не следовало.
code_has orchestrator/src/steps/smoke.ts -F -- 'isRejectedLogin' \
|| fail "acceptance: отрицательная проба снова определяет отказ по коду HTTP"
code_has orchestrator/src/steps/smoke.ts -F -- 'INVALID_CREDENTIALS_CODE = "invalid_credentials"' \
|| fail "acceptance: отрицательная проба не сверяет доменный код отказа"
# Литерал в исходнике теоретически может оказаться настоящим паролем — тогда
# проверка «данные отвергнуты» отчиталась бы об успешном ВХОДЕ.
! code_has orchestrator/src/steps/smoke.ts -F -- 'definitely-not-the-admin-password' \
|| fail "acceptance: пароль отрицательной пробы снова записан литералом"
# Bootstrap-секрет читается парсером формата: `cut -d= -f2-` с `trim()` был
# третьим слоем, срезающим краевые пробелы у пароля.
! code_has orchestrator/src/steps/smoke.ts -F -- 'cut -d= -f2-' \
|| fail "acceptance: bootstrap-секрет снова извлекается шелл-конвейером"
code_has orchestrator/src/steps/smoke.ts -F -- 'readBootstrapAdminSecret' \
|| fail "acceptance: bootstrap-секрет читается не парсером собственного формата"
log_step "Acceptance: the orchestrator validates what it generates" log_step "Acceptance: the orchestrator validates what it generates"
code_has orchestrator/src/config/env.ts -F -- 'assertValidAdminUsername(' \ code_has orchestrator/src/config/env.ts -F -- 'assertValidAdminUsername(' \
|| fail "acceptance: HY2XS_ADMIN_USER снова не проверяется против контракта панели" || fail "acceptance: HY2XS_ADMIN_USER снова не проверяется против контракта панели"
+113 -9
View File
@@ -8,12 +8,14 @@ import {
FLAMY_URL, FLAMY_URL,
} from "../../apps/frontend/src/constants/branding"; } from "../../apps/frontend/src/constants/branding";
import { import {
ADMIN_PASSWORD_MAX_BYTES,
ADMIN_PASSWORD_MAX_LENGTH, ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH, ADMIN_PASSWORD_MIN_LENGTH,
ADMIN_USERNAME_CHARSET, ADMIN_USERNAME_CHARSET,
ADMIN_USERNAME_MAX_LENGTH, ADMIN_USERNAME_MAX_LENGTH,
ADMIN_USERNAME_MIN_LENGTH, ADMIN_USERNAME_MIN_LENGTH,
ADMIN_USERNAME_PATTERN, ADMIN_USERNAME_PATTERN,
isValidAdminPassword,
} from "../../apps/frontend/src/constants/credentials"; } from "../../apps/frontend/src/constants/credentials";
import ru from "../../apps/frontend/src/lang/package/ru"; import ru from "../../apps/frontend/src/lang/package/ru";
import en from "../../apps/frontend/src/lang/package/en"; import en from "../../apps/frontend/src/lang/package/en";
@@ -689,7 +691,7 @@ describe("правдивая диагностика", () => {
describe("контракт учётных данных администратора", () => { describe("контракт учётных данных администратора", () => {
const goContract = () => const goContract = () =>
fs.readFileSync( fs.readFileSync(
path.join(REPO_ROOT, "apps", "service", "admin_credentials.go"), path.join(REPO_ROOT, "apps", "credential", "admin.go"),
"utf8" "utf8"
); );
@@ -703,7 +705,7 @@ describe("контракт учётных данных администрато
if (backticked) { if (backticked) {
return backticked[1]; return backticked[1];
} }
throw new Error(`константа ${name} не найдена в admin_credentials.go`); throw new Error(`константа ${name} не найдена в apps/credential/admin.go`);
} }
const loginForm = () => readSource("views", "login", "index.vue"); const loginForm = () => readSource("views", "login", "index.vue");
@@ -718,6 +720,12 @@ describe("контракт учётных данных администрато
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength")); expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength"));
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength")); expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength"));
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength")); expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength"));
// Предел bcrypt — отдельная константа и отдельная сверка. Пока панель знала
// только границу в символах, она предлагала оператору назначить пароль из
// 64 кириллических букв (128 байт), сервер его принимал всеми проверками, а
// хеширование отказывало — и оператор получал системную ошибку на штатной
// смене пароля.
expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe(goConst("AdminPasswordMaxBytes"));
expect(ADMIN_USERNAME_CHARSET).toBe(goConst("AdminUsernameCharset")); expect(ADMIN_USERNAME_CHARSET).toBe(goConst("AdminUsernameCharset"));
const declared = goConst("adminUsernameCharacterClass"); const declared = goConst("adminUsernameCharacterClass");
@@ -756,20 +764,108 @@ describe("контракт учётных данных администрато
// серверном пределе в 64, а форма смены пароля назначала до 64. Пароль, // серверном пределе в 64, а форма смены пароля назначала до 64. Пароль,
// назначенный штатной операцией, становился паролем, с которым невозможно // назначенный штатной операцией, становился паролем, с которым невозможно
// войти. // войти.
test("границы пароля на обеих формах — одни и те же", () => { test("правило пароля на обеих формах — одно и то же", () => {
for (const form of [loginForm(), changePasswordForm()]) { for (const form of [loginForm(), changePasswordForm()]) {
const code = vueCodeOf(form); const code = vueCodeOf(form);
expect(code).toContain("min: ADMIN_PASSWORD_MIN_LENGTH"); expect(code).toContain("adminPasswordFormRule(");
expect(code).toContain("max: ADMIN_PASSWORD_MAX_LENGTH"); expect(code).toContain("credentials.passwordFormat");
} }
}); });
// Регрессия RC2-UI-04. Element Plus делегирует правила формы библиотеке
// `async-validator`, а она сравнивает `min`/`max` строки с
// `String.prototype.length`, то есть считает единицы UTF-16. Сервер считает
// code points. Пароль из трёх эмодзи имел `value.length === 6`, проходил
// минимум формы и получал отказ сервера, который панель не могла объяснить.
//
// Встроенных границ на пароле поэтому быть не должно вовсе: длина
// принадлежит правилу, а не соседям по объекту.
test("на пароле нет встроенных min/max от async-validator", async () => {
const { adminPasswordFormRule } = await import(
"../../apps/frontend/src/constants/credentials"
);
// Правило одно на обе формы, и границ как СВОЙСТВ правила у него нет: длина
// живёт внутри валидатора, где её можно посчитать правильно.
const rule = adminPasswordFormRule("format") as Record<string, unknown>;
expect(typeof rule.validator).toBe("function");
expect("min" in rule).toBe(false);
expect("max" in rule).toBe(false);
// И ни одна форма не заводит собственных границ рядом с ним. Числа в
// сообщении не считаются: фраза обязана НАЗЫВАТЬ требование, поэтому они
// подставляются в t() и вырезаются здесь перед проверкой.
for (const form of [loginForm(), changePasswordForm()]) {
const withoutMessages = vueCodeOf(form).replace(
/t\("credentials\.passwordFormat",[\s\S]*?\n\s*\}\)/g,
't("credentials.passwordFormat")'
);
expect(withoutMessages).not.toContain("min: ADMIN_PASSWORD_MIN_LENGTH");
expect(withoutMessages).not.toContain("max: ADMIN_PASSWORD_MAX_LENGTH");
}
});
// Правило панели обязано отвечать так же, как Go-контракт, на тех значениях,
// где расходятся способы счёта длины.
test("правило пароля считает code points и байты, а не UTF-16", () => {
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, случаи
// ниже проверяли бы не то, что написано.
expect(new TextEncoder().encode("я".repeat(36)).length).toBe(ADMIN_PASSWORD_MAX_BYTES);
expect(new TextEncoder().encode("😀".repeat(18)).length).toBe(ADMIN_PASSWORD_MAX_BYTES);
for (const accepted of [
"a".repeat(ADMIN_PASSWORD_MIN_LENGTH),
"a".repeat(ADMIN_PASSWORD_MAX_LENGTH),
"я".repeat(36),
"😀".repeat(18),
"abcde ",
" ".repeat(ADMIN_PASSWORD_MIN_LENGTH),
]) {
expect(isValidAdminPassword(accepted)).toBe(true);
}
for (const rejected of [
"a".repeat(ADMIN_PASSWORD_MIN_LENGTH - 1),
"a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1),
"я".repeat(37),
"😀".repeat(19),
"я".repeat(ADMIN_PASSWORD_MAX_LENGTH),
"abcde\n",
"abcde\t",
]) {
expect(isValidAdminPassword(rejected)).toBe(false);
}
// Именно тот случай, ради которого встроенные min/max убраны: три эмодзи —
// это 3 code points и 6 единиц UTF-16.
expect("😀😀😀".length).toBe(6);
expect(isValidAdminPassword("😀😀😀")).toBe(false);
});
// Пустое значение — забота правила `required`, а не правила формата: иначе
// оператор, отправивший пустую форму, читает про границы длины вместо «поле
// обязательно». Сервер ведёт себя так же (DTO приводит пустой пароль к «не
// задано» до проверки правил).
test("пустой пароль остаётся на правиле required", async () => {
const { adminPasswordFormRule } = await import(
"../../apps/frontend/src/constants/credentials"
);
const rule = adminPasswordFormRule("format");
const errors: (Error | undefined)[] = [];
rule.validator(null, "", (error?: Error) => errors.push(error));
rule.validator(null, "abc", (error?: Error) => errors.push(error));
expect(errors[0]).toBeUndefined();
expect(errors[1]).toBeInstanceOf(Error);
});
// Набор символов пароля сервером не проверяется НИГДЕ, поэтому его проверка в // Набор символов пароля сервером не проверяется НИГДЕ, поэтому его проверка в
// панели умеет только отвергнуть пароль, который сервер принял бы. // панели умеет только отвергнуть пароль, который сервер принял бы.
test("на пароль не навешен набор символов", () => { test("на пароль не навешен набор символов", () => {
for (const form of [loginForm(), changePasswordForm()]) { for (const form of [loginForm(), changePasswordForm()]) {
const code = vueCodeOf(form); const code = vueCodeOf(form);
expect(code).not.toContain("ADMIN_USERNAME_PATTERN,\n message: t(\"credentials.passwordLength\""); expect(code).not.toContain("ADMIN_USERNAME_PATTERN,\n message: t(\"credentials.passwordFormat\"");
expect(code).not.toMatch(/passwordPattern/); expect(code).not.toMatch(/passwordPattern/);
} }
// Правило логина — единственное место, где вообще упоминается набор. // Правило логина — единственное место, где вообще упоминается набор.
@@ -782,13 +878,17 @@ describe("контракт учётных данных администрато
for (const dictionary of [ru, en] as const) { for (const dictionary of [ru, en] as const) {
const credentials = (dictionary as any).credentials ?? {}; const credentials = (dictionary as any).credentials ?? {};
expect(typeof credentials.usernameFormat).toBe("string"); expect(typeof credentials.usernameFormat).toBe("string");
expect(typeof credentials.passwordLength).toBe("string"); expect(typeof credentials.passwordFormat).toBe("string");
for (const placeholder of ["{min}", "{max}", "{charset}"]) { for (const placeholder of ["{min}", "{max}", "{charset}"]) {
expect(credentials.usernameFormat).toContain(placeholder); expect(credentials.usernameFormat).toContain(placeholder);
} }
for (const placeholder of ["{min}", "{max}"]) { // Границ у пароля ДВЕ, и они в разных единицах: пароль из 40 эмодзи
expect(credentials.passwordLength).toContain(placeholder); // укладывается в 64 символа и не укладывается в 72 байта. Фраза «не
// длиннее {max} символов» отправила бы оператора сокращать пароль,
// отвергнутый не за это.
for (const placeholder of ["{min}", "{max}", "{maxBytes}"]) {
expect(credentials.passwordFormat).toContain(placeholder);
} }
// Причина отказа от сервера несёт те же params и обязана их использовать. // Причина отказа от сервера несёт те же params и обязана их использовать.
@@ -796,6 +896,10 @@ describe("контракт учётных данных администрато
for (const placeholder of ["{min}", "{max}", "{charset}"]) { for (const placeholder of ["{min}", "{max}", "{charset}"]) {
expect(reason).toContain(placeholder); expect(reason).toContain(placeholder);
} }
const passwordReason = (dictionary as any).error?.code?.admin_password_format ?? "";
for (const placeholder of ["{min}", "{max}", "{maxBytes}"]) {
expect(passwordReason).toContain(placeholder);
}
} }
}); });
}); });