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

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

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

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

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

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

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

Что сделано:

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 18:38:04 +05:00
parent a8407cf16b
commit 65042ee335
38 changed files with 2646 additions and 400 deletions
+12
View File
@@ -1117,6 +1117,18 @@ B входит со своим устаревшим 0 -> lastOnline снова 0
Если переменной нет, а создавать учётную запись нужно, админка **отказывает в
старте** с сообщением, называющим причину и способ починки.
То же и при значении вне контракта пароля: `HY2XS_ADMIN_INITIAL_PASSWORD`
проверяется против того же правила, что и форма входа
(`apps/credential/admin.go`), и непригодное значение роняет старт с внятным
текстом, а не доходит до `bcrypt.GenerateFromPassword`, чтобы вернуться оттуда
строкой `password length exceeds 72 bytes`. Учётная запись при этом не
создаётся: установка иначе завершилась бы успешно, а войти было бы нельзя.
Пароль читается **как есть**: пробелы по краям объявлены его частью и не
снимаются ни здесь, ни при хешировании, ни на форме входа. Раньше bootstrap
делал `strings.TrimSpace`, и учётная запись создавалась не с тем паролем,
который оператор записал в `hy2xs.env`.
Раньше она в этом случае придумывала пароль сама и печатала его двумя
`logrus.Warnf` — открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, то есть в
файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо
+79 -12
View File
@@ -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, поэтому придуманный секрет разошёлся бы с файлом и первая же
+60 -1
View File
@@ -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`**, а не интерполяцией в строку: пароль
+45 -3
View File
@@ -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` создаётся оркестратором только при первичной установке.
+86 -1
View File
@@ -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 символов
частоты первых восьми символов алфавита не отличаются от остальных более чем на
+31 -6
View File
@@ -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
```