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:
@@ -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`, то есть в
|
||||
файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо
|
||||
|
||||
@@ -270,24 +270,33 @@ control plane показывал всех пиров офлайн. Теперь
|
||||
### Где живёт контракт
|
||||
|
||||
Требования к логину и паролю администратора объявлены **один раз**, в
|
||||
`apps/service/admin_credentials.go`:
|
||||
`apps/credential/admin.go`:
|
||||
|
||||
| Что | Значение | Владелец |
|
||||
| --- | --- | --- |
|
||||
| Длина логина | 6-32 символа | `AdminUsernameMinLength` / `AdminUsernameMaxLength` |
|
||||
| Набор символов логина | `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-исходник:
|
||||
|
||||
* панель — `apps/frontend/src/constants/credentials.ts`
|
||||
(`tools/test/frontend-contract.test.ts`);
|
||||
* оркестратор — `orchestrator/src/config/profile.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-хешем. Ограничение набора на форме не защищает ничего и умеет только
|
||||
отвергнуть пароль, который сервер принял бы.
|
||||
|
||||
Верхняя граница в 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`, и
|
||||
посмотреть его в панели больше негде — сообщение «Неверный формат логина» не
|
||||
давало оператору ни одного способа узнать, что от него хотят.
|
||||
@@ -346,3 +407,9 @@ Element Plus рисует состояние отказа на `el-input__wrappe
|
||||
(`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-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` относится к пиру установщика.
|
||||
Его секрет продублирован в `bootstrap-admin.secret`, откуда его читает проверка
|
||||
machine-auth, поэтому придуманный секрет разошёлся бы с файлом и первая же
|
||||
|
||||
@@ -542,6 +542,50 @@ production-профилем, а не ищет подстроки. Проверя
|
||||
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
|
||||
уходит в логи и в 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 проверяет, что панель ВПУСКАЕТ
|
||||
|
||||
Открытый порт — это не работающая панель.
|
||||
@@ -557,7 +601,7 @@ production-профилем, а не ищет подстроки. Проверя
|
||||
|
||||
| Проба | Когда | Что требуется |
|
||||
| --- | --- | --- |
|
||||
| заведомо неверные учётные данные | всегда | HTTP 200 с конвертом отказа |
|
||||
| настоящий логин + СЛУЧАЙНЫЙ пароль | всегда | `code: 50000`, причина `invalid_credentials`, `accessToken` отсутствует |
|
||||
| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` |
|
||||
|
||||
Детали, которые здесь существенны:
|
||||
@@ -565,6 +609,21 @@ production-профилем, а не ищет подстроки. Проверя
|
||||
- **успех определяется конвертом, а не кодом HTTP.** Админка отвечает `200 OK` и
|
||||
на отказ тоже — причина живёт в поле `code`. Проверка «HTTP 200» приняла бы за
|
||||
успешный вход любой отказ, то есть не проверяла бы ничего;
|
||||
- **отказ определяется конвертом по той же причине.** Отрицательная проба
|
||||
сверяла `%{http_code}` с `200` и доказывала ровно одно — что запрос не
|
||||
закончился пятисоткой. Теперь требуются три признака сразу: код конверта
|
||||
`50000` (отказ операции, а не успех и не отказ валидации, который означал бы
|
||||
негодный запрос), доменная причина `invalid_credentials` и ОТСУТСТВИЕ
|
||||
`accessToken`;
|
||||
- **пароль отрицательной пробы генерируется**, а не записан литералом. Записанное
|
||||
в исходнике значение теоретически может оказаться настоящим паролем — и тогда
|
||||
проверка «неверные данные отвергаются» отчиталась бы об успешном входе. На
|
||||
`install`, где настоящий пароль известен, дополнительно утверждается, что
|
||||
проба ему не равна;
|
||||
- **bootstrap-секрет читается парсером формата**, а не `grep … | cut -d= -f2-`.
|
||||
Набор символов пароля не ограничен, пробелы по краям являются его частью, и
|
||||
шелл-конвейер срезал бы их — положительная проба взяла бы не тот пароль и
|
||||
объявила бы рабочую установку сломанной;
|
||||
- **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы
|
||||
панель, которая пускает и не выдаёт сессию;
|
||||
- **тело собирается `JSON.stringify`**, а не интерполяцией в строку: пароль
|
||||
|
||||
@@ -140,7 +140,7 @@ anycast. Отсутствие A-записи фатально при любом
|
||||
Важно:
|
||||
- `HY2XS_ADMIN_INITIAL_PASSWORD` используется только для первичного bootstrap seed;
|
||||
- `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 пока не реализован);
|
||||
- после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`.
|
||||
|
||||
@@ -148,12 +148,12 @@ anycast. Отсутствие A-записи фатально при любом
|
||||
|
||||
`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_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` снимаются. Иначе они уезжали бы в имя
|
||||
учётной записи в 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` и запасное
|
||||
значение в `apps/dao/sqlite.go`. Раньше оркестратор писал `admin` — пять
|
||||
символов при минимуме панели в шесть, — и установка завершалась
|
||||
`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 контракт
|
||||
|
||||
- `/etc/hy2xs/bootstrap-admin.secret` создаётся оркестратором только при первичной установке.
|
||||
|
||||
@@ -89,6 +89,32 @@
|
||||
|
||||
Отдельно — 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`):
|
||||
|
||||
- Gecko рендерит **только** gecko-подблок;
|
||||
@@ -508,7 +534,66 @@ wildcard-маршрутом фронтенда или дублирующая р
|
||||
|
||||
- `HashPassword` выдаёт bcrypt и солит: два хеша одного пароля различаются;
|
||||
- вход по несолёному 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 символов
|
||||
частоты первых восьми символов алфавита не отличаются от остальных более чем на
|
||||
|
||||
@@ -52,9 +52,10 @@
|
||||
32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd
|
||||
33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON
|
||||
34. страница конфигурации показывает фактические значения `/etc/hysteria/config.yaml`, перечисляет секции вне production-профиля и не содержит паролей и токенов
|
||||
35. **оператор входит в панель**: `POST /api/auth/login` с bootstrap-учётными данными из `/etc/hy2xs/bootstrap-admin.secret` отвечает `code: 20000` и непустым `accessToken`. Заведомо неверные учётные данные дают HTTP 200 с конвертом отказа, а не 500
|
||||
36. пароль предельной длины (64 символа), назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными
|
||||
37. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки
|
||||
35. **оператор входит в панель**: `POST /api/auth/login` с bootstrap-учётными данными из `/etc/hy2xs/bootstrap-admin.secret` отвечает `code: 20000` и непустым `accessToken`. Заведомо неверные учётные данные (со СЛУЧАЙНЫМ паролем, а не с литералом) дают HTTP 200 с конвертом отказа: `code: 50000`, причина `invalid_credentials` и отсутствие `accessToken`
|
||||
36. пароль предельной длины, назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными. Границ **две** — 6-64 символа Unicode и не более 72 байт в UTF-8 (предел bcrypt): пароль из 36 кириллических букв (72 байта) принимается, из 37 (74 байта) — отвергается конвертом валидации, а не системной ошибкой
|
||||
37. `HY2XS_ADMIN_INITIAL_PASSWORD` с пробелом по краям доезжает до учётной записи неизменным: значение записано в `hy2xs.env` в двойных кавычках, и вход выполняется ровно им, а не обрезанным
|
||||
38. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки
|
||||
|
||||
## C0. Панель обязана впускать, а не слушать порт
|
||||
|
||||
@@ -70,15 +71,39 @@ HTTP 500 на каждый запрос, потому что валидатор
|
||||
Ручной эквивалент:
|
||||
|
||||
```bash
|
||||
# Пароль в переменную, чтобы он не попал ни в историю shell, ни в вывод.
|
||||
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 -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 \
|
||||
-H 'Content-Type: application/json' \
|
||||
--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 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
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user