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:
+94
-1
@@ -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.
|
||||||
|
|
||||||
### Исправлено — правило доступа
|
### Исправлено — правило доступа
|
||||||
|
|
||||||
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
|
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
+131
-39
@@ -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,13 +247,8 @@ 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
|
||||||
@@ -256,30 +256,79 @@ func TestPasswordBoundsAreOneContract(t *testing.T) {
|
|||||||
{"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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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("пароль длиной ровно в минимум отклонён")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("пароль с краевым пробелом отклонён: пробел объявлен частью значения")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 с окружением и НЕ
|
||||||
// придумывает его сам.
|
// придумывает его сам.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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: "Запрос не разобран: проверьте формат и типы полей",
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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"],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
// Доменные коды: правило соблюдено, но операция всё равно невозможна.
|
// Доменные коды: правило соблюдено, но операция всё равно невозможна.
|
||||||
|
|||||||
@@ -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
@@ -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: незаполненное поле обязано выглядеть незаполненным.
|
||||||
|
|||||||
@@ -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("пустые пароли формы смены остались заданными")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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("пароль из пробелов принят")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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-бандл. Помимо
|
||||||
|
|||||||
@@ -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, поэтому придуманный секрет разошёлся бы с файлом и первая же
|
||||||
|
|||||||
@@ -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`**, а не интерполяцией в строку: пароль
|
||||||
|
|||||||
@@ -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` создаётся оркестратором только при первичной установке.
|
||||||
|
|||||||
@@ -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 символов
|
||||||
частоты первых восьми символов алфавита не отличаются от остальных более чем на
|
частоты первых восьми символов алфавита не отличаются от остальных более чем на
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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,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")} байт. ` +
|
||||||
|
`Набор символов не ограничен, пробелы по краям являются частью пароля. ` +
|
||||||
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
|
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Причина отказа БЕЗ тела ответа.
|
* Причина отказа БЕЗ тела ответа.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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-");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 снова не проверяется против контракта панели"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user