9 Commits

Author SHA1 Message Date
Crimson b7e478f014 fix(rc5): harden frontend runtime and diagnostics 2026-09-08 05:22:06 +05:00
Crimson 079094591b fix(installer): harden admin smoke and rollback cleanup 2026-09-07 22:39:30 +05:00
Crimson bf10810cfc fix(build): remove pipefail-unsafe matchers 2026-09-07 20:34:50 +05:00
Crimson 6c286d1ff5 fix(build): align runtime env acceptance gate 2026-09-07 20:21:37 +05:00
Crimson a3445f79c5 fix(security): harden diagnostics storage boundary 2026-09-07 19:52:11 +05:00
Crimson af9f476658 fix(utf8): закрыть lossy-границы конфигурации и API 2026-09-07 17:25:27 +05:00
Crimson ab788725cf fix(env): контракт был шире домена, который принимает systemd
Разбор предыдущего прохода со сверкой по исходникам systemd v257.13 — той самой
линии, что стоит на Debian 13. Тема та же и слоем глубже: контракт, объявленный
шире, чем его принимает чужая сторона. Прошлый проход сделал транспорт lossless
для значений, которые systemd принимает, но не спросил, какие значения он
принимает вообще.

1. Домен значений файла окружения

Перед тем как принять пару, systemd прогоняет ключ и значение через
utf8_is_valid (src/basic/env-file.c, check_utf8ness_and_warn), и отказ там
возвращает -EINVAL — то есть НЕзагруженный EnvironmentFile= и юнит, который не
стартует, а не предупреждение. unichar_is_valid (src/basic/utf8.c) отвергает
суррогаты, U+FDD0..U+FDEF и все code points вида *FFFE/*FFFF, а сам
utf8_is_valid — встроенный NUL и невалидный UTF-8.

Пароль "abcde" + U+FDD0 — шесть символов, восемь байт, ни одного управляющего —
проходил панель, оркестратор, DTO и хеширование, записывался в hy2xs.env, и
после этого админка не поднималась. Тот же класс дефекта, ради уничтожения
которого контракт и существует, только слоем ниже.

Введён IsEnvTransportableText (Go) / isEnvTransportable (TS), повторяющий
множество systemd точно — не шире и не уже. Отдельно отвергаются одиночные
суррогаты: строка JavaScript вправе их содержать, а TextEncoder молча заменяет
непарный суррогат на U+FFFD, то есть без проверки в файл уехал бы ДРУГОЙ
секрет, а не отказ.

Заодно разделены домен транспорта и политика продукта. Проверка отвергала C0 и
DEL с формулировкой «формат управляющих символов не несёт» — неправда: внутри
двойных кавычек перевод строки накапливается как обычный байт и переживает
round-trip. Именно эта подмена и позволила проверке не знать про noncharacters.
Политика HY2XS теперь запрещает категорию Cc целиком (была шире кода ровно на
C1) плюс U+FEFF — последний отдельным решением продукта, а не форматом:
0xFEFF & 0xFFFE это 0xFEFE, и systemd такое значение принимает.

2. Рецепт восстановления выполнял env-файл как код

В docs/operations/12, раздел «Забыт пароль администратора», стояло
`set -a; . /etc/hy2xs/hy2xs.env; set +a`. Строка стала опасной ровно тогда,
когда файл научился нести произвольные значения. Для systemd
HY2XS_ADMIN_INITIAL_PASSWORD="$(...)" — буквальное значение: подстановок в
EnvironmentFile= нет вовсе. Но `.` обрабатывает файл bash, а bash внутри
двойных кавычек выполняет подстановку команд — от root, прямо в рецепте
восстановления доступа. Соседний раздел той же страницы при этом уже правильно
запрещал source/eval для bootstrap-admin.secret: документ запрещал действие и
тут же его предлагал.

Рецепт читает нужные значения как ДАННЫЕ. Поставлен гейт приёмки, запрещающий
возврат source/./eval над этими файлами в командах документации и в скриптах;
гейт смотрит только внутрь ```-блоков, чтобы объяснение, называющее убранную
конструкцию по имени, его не роняло.

3. Отказ приходил после мутаций хоста

Проверка транспорта жила только внутри renderRuntimeEnv, то есть срабатывала на
шаге «write runtime env» — уже после bootstrap оркестратора, установки пакетов
и раскладки файловой системы, — а read-only preflight-install говорил PASS: он
зовёт parseRuntimeEnv и ничего не рендерит. Детерминированно известная ошибка
конфигурации роняла операцию, оставив за собой изменённый хост, что прямо
противоречит контракту PHASE 0.

validateRuntimeEnvTransport вызывается теперь из parseRuntimeEnv и проходит по
ВСЕМ парам runtimeEnvEntries: ограничение принадлежит формату, а не полю
пароля, и HY2XS_ADMIN_CON_PASS сломал бы загрузку юнита так же.

4. Точность порта автомата и его описания

- в состоянии DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не
  проверку на любой перевод строки (в VALUE_ESCAPE — наоборот,
  strchr(NEWLINE, c)). Порт съедал и \<LF>, и \<CR>;
- комментарий обещал одно намеренное расхождение с systemd, а их два: кроме
  строки без `=`, HY2XS отказывает и на незакрытой кавычке в конце файла.
  Оба fail-closed и теперь названы оба.

Тесты: граничная таблица во всех слоях дополнена значениями вне домена
(U+FDD0, U+FDEF, U+FFFE, U+FFFF, U+1FFFF, U+10FFFF, невалидный UTF-8),
соседями диапазонов (U+FDCF, U+FDF0, U+FFFD, U+10FFFD), C1 и U+FEFF, одиночным
суррогатом. Добавлены TestEnvTransportDomainMatchesSystemd (домен не шире и не
уже) и TestProductPolicyIsWiderThanTransportDomain (домен и политика
различимы), а также проверки fail-closed порядка: parseRuntimeEnv отвергает
непригодную конфигурацию, проверяются все значения файла, запись и проверка
ходят по одному списку пар.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:38:57 +05:00
Crimson 65042ee335 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>
2026-09-06 18:38:04 +05:00
founder a8407cf16b fix(admin): вход в панель падал на теге правила, пережившего переименование
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью
недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с
таким именем не регистрировалось: при переименовании в `credentialStr` правка
не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на
несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при
разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery
превращал панику в HTTP 500 на каждый POST /api/auth/login.

Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники
apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его
валидатору отдельно — незарегистрированное правило паникует так же, как в бою,
но на сборке. Барьер проверен возвратом исходного тега.

Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал
панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz
отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход
bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду
HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба
идёт в любом режиме операции и от актуальности пароля не зависит.

Рядом лежали три расхождения того же класса, найденные при разборе.

Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по
умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая
установка проходила целиком, создавая учётную запись, под которой невозможно
войти. Про одно имя существовало три расходящихся умолчания. Оба значения
теперь проверяются при разборе окружения — той стороной, которая их порождает:
отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход
на готовом сервере — переустановкой.

Панель была строже сервера. Форма входа ограничивала пароль 32 символами при
серверном пределе в 64, а форма смены пароля назначала до 64: пароль,
назначенный штатной операцией, после этого не вводился. Набор символов на
пароле отвергал значение, которое сервер принял бы, — сервер его не
ограничивает нигде. Контракт учётных данных объявлен один раз в
service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним
тестами, читающими Go-исходник.

Класс символов логина был записан диапазоном по опечатке: неэкранированный
дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным
набором это совпадало только потому, что обе стороны несли одну опечатку. Набор
записан явно и НЕ сужен — он уже действует на установленных серверах.

Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus
рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а
форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся
иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх,
проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина
начиналась после иконки, у пароля обрывалась перед «глазом». Индикация
перенесена на элемент, который оператор и видит полем; чужая тень гасится
селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, —
конкретностью, а не !important. Остальные формы панели проверены: собственная
рамка на el-form-item есть только на форме входа.

Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому —
UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login
сразу после успешной проверки пароля; отказ записи вход не отменяет, но
попадает в журнал. Обработчик входа переехал из controller/peer.go в
controller/auth.go: стек в journal указывал на управление пирами.

Требование теперь называется, а не сообщается фактом нарушения. «Неверный
формат логина» и «Некорректное значение» не давали оператору способа узнать,
что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не
показан. Фразы форм и серверная причина credential_format перечисляют границы
и набор.

Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления —
по той же причине, что и гейт детектора гонок. Каждое из его утверждений
проверено мутационной пробой на реальный отказ; две первые редакции оказались
вакуумными и переписаны.

Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов
панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки
целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это
релизный гейт сборщика.

Прогон задокументирован в
docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
2026-09-04 02:32:50 +05:00
82 changed files with 8398 additions and 532 deletions
+299
View File
@@ -8,6 +8,56 @@
## [Unreleased]
Runtime-разбор RC4 закрыл три дефекта панели и один связанный риск
сопровождения. Набор знаков имени пира больше не хранится как сырой `@` внутри
message format: общий frontend-контракт передаёт его в RU/EN-подсказку через
named interpolation и сверяется с Go-константами. Builder после frozen install
компилирует реальным `vue-i18n` каждую строку обоих словарей, перехватывая как
исключения, так и compiler diagnostics в `console.error`; удалить этот gate и
при этом получить `tests_gate=true` нельзя. Сам `vue-i18n` переведён с
неподдерживаемой ветки 9.14.5 на поддерживаемую 11.4.10.
Попутный аудит package manager устранил ложную декларацию security overrides:
pnpm предупреждал, что больше не читает настройки из поля `pnpm` в
`package.json`. Правила перенесены в `apps/frontend/pnpm-workspace.yaml`, то
есть теперь не только сохранены в старом lock-файле, но и действительно
участвуют в следующем разрешении графа.
Dashboard использует `value` у `el-radio-button` вместо deprecated-семантики
«`label` как значение», а общий frontend-контракт запрещает вернуть старый API
в любом Vue-компоненте. ENV-redaction теперь имеет одно точное исключение для
диагностического boolean `HY2XS_FORCE_PASSWORD_CHANGE`: корректное значение
`true`/`false` видно в bundle, но другое значение либо совпадение по
префиксу/суффиксу исключения не даёт, и пароли, токены, секреты Hysteria и
credentials продолжают заменяться на `<redacted>`.
По результатам runtime-проверки RC3 login smoke приведён к реальному HTTP
контуру панели: positive и negative пробы используют один helper с wire-полем
`pass`, явным `HY2XS-Installer/1.0` User-Agent и строгим разбором JSON envelope.
Тесты теперь проходят через scanner middleware и отдельно доказывают отказ
стандартного curl UA, успешный bootstrap-вход и `invalid_credentials` для
случайного пароля. Rollback cleanup проверяет `ActiveState` после
`reset-failed`: уже выгруженный юнит больше не создаёт ложное предупреждение о
ручном восстановлении, а недоступный systemd и сохранившийся `failed` остаются
ошибкой.
Diagnostics больше не пишет root-артефакты в `/var/log/hy2xs`, которым владеет
`hy2xs-admin`. Staging и архив перенесены в проверяемый через `lstat` каталог
`/var/lib/hy2xs/diagnostics` (`root:root 0700`), имена создаются эксклюзивно,
symlink и ослабленные права дают fail-closed отказ, а временный staging после
упаковки удаляется. Чтение metadata теперь использует fallback только при
`ENOENT`; ошибки прав, ввода-вывода и неверного типа больше не маскируются.
Очередной hardening-проход закрывает границу кодировок. Оркестратор теперь
читает конфигурацию и секреты как байты с fatal UTF-8 decoding, сохраняет BOM
как U+FEFF до явного транспортного отказа и проверяет исходный EnvironmentFile
до разбора ключей. HTTP API до `encoding/json` отвергает повреждённый UTF-8 и
непарные UTF-16 surrogate escape, поэтому вход или импорт больше не могут
молча превратиться в другое значение через U+FFFD. Diagnostics сначала
санитизирует данные в памяти и никогда не кладёт сырые env/YAML/journal во
временный каталог. U+FEFF во всех слоях отнесён к документированному домену
systemd, а управляющие `Cc` — к политике HY2XS.
Hardening-проход перед релизом `1.0.0`. Основная тема — сделать политику
«только чистая установка» настоящим системным инвариантом, а не строчкой в
документации.
@@ -96,6 +146,255 @@ Hysteria-интеграции с официальной документацие
никто не проверял. Разбор задокументирован в
[docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md).
Четырнадцатый проход — по итогам runtime-прогона `v1.0.0-rc2` на чистом
Debian 13. Тема: **разница между «поднялось» и «работает»**. Установка прошла
целиком и объявила `INSTALL EXIT CODE: 0`, юнит был активен, порт слушал,
`/healthz` отвечал `ok: true` — и вход в панель отдавал HTTP 500 на каждый
запрос, потому что валидатор паниковал на теге правила, пережившего
переименование. Ни один из трёх признаков готовности не отвечал на вопрос, ради
которого они проверялись. Вместе с самим дефектом закрыт его класс: барьер,
проверяющий каждый тег валидации на существование, и smoke, выполняющий
настоящий вход. Прогон задокументирован в
[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.
Шестнадцатый проход — разбор пятнадцатого, со сверкой по исходникам systemd
v257.13, то есть той самой линии, что стоит на Debian 13. Тема та же и на слой
глубже: **контракт, объявленный шире, чем его принимает чужая сторона**.
Пятнадцатый проход сделал транспорт lossless для значений, которые systemd
принимает, но не спросил, какие значения он принимает вообще. Вместе с этим
закрыты рецепт восстановления доступа, ставший опасным ровно оттого, что файл
научился нести произвольные значения, и порядок, при котором детерминированная
ошибка конфигурации обнаруживалась уже после мутаций хоста.
### Исправлено — вход в панель
- **`POST /api/auth/login` паниковал до проверки учётных данных.** На
`LoginDto.Username` стоял тег `validateStr` — правило с таким именем не
регистрировалось: при переименовании в `credentialStr` правка не доехала до
одного файла, оставив мёртвую регистрацию и живую ссылку на несуществующее
имя. `go-playground/validator` на неизвестный тег ПАНИКУЕТ при разборе
структуры, `gin.Recovery` превращал панику в HTTP 500, и панель после чистой
установки была недоступна целиком.
- **Ни один тест не прогонял DTO через production-валидатор.** Проверялся сам
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
тега не доходит. Теперь `TestEveryValidationTagIsRegistered` извлекает все
теги `validate:"…"` из `apps/model/**` и предъявляет каждый валидатору
отдельно — закрыт класс, а не найденный экземпляр.
- **Установка не проверяла, что в панель можно войти.** Smoke отвечал на этот
вопрос фактом `LISTEN` и ответом `/healthz`; RC2 доказал, что оба бывают
истинны при полностью недоступной панели. Теперь smoke выполняет настоящий
`POST /api/auth/login` bootstrap-учётными данными и требует конверт успеха с
непустым токеном — по коду HTTP это неотличимо, админка отвечает `200 OK` и
на отказ. Отрицательная проба выполняется в любом режиме операции.
- **Оркестратор не знал контракта, который сам порождает.** `HY2XS_ADMIN_USER`
по умолчанию был `admin` — пять символов при минимуме панели в шесть, — и
установка с ним завершалась успешно, создавая учётную запись, под которой
невозможно войти. Про одно имя существовало три расходящихся умолчания. Оба
значения теперь проверяются при разборе окружения, умолчание сведено к
`hy2xsadmin` во всех трёх местах.
- **Панель была строже сервера и запирала оператора после смены пароля.** Форма
входа ограничивала пароль 32 символами при серверном пределе в 64, а форма
смены пароля назначала до 64: пароль, назначенный штатной операцией, после
этого не вводился. Набор символов на пароле отвергал значение, которое сервер
принял бы, — сервер его не ограничивает нигде. Контракт учётных данных теперь
объявлен один раз в `apps/credential/admin.go`, а копии в панели и
оркестраторе сверяются с ним тестами, читающими Go-исходник.
- **Класс символов логина был записан диапазоном по опечатке.** Неэкранированный
дефис в `[a-zA-Z0-9!@#$%^&*()_+-=]` превращал `+-=` в диапазон, впускающий
`, - . / 0-9 : ; < =`. С серверным набором это совпадало по совпадению: оба
несли одну опечатку. Набор записан явно и НЕ сужен — он уже действует на
установленных серверах.
- **Красная рамка ошибки обводила не то, что видит оператор.** Element Plus
рисует состояние отказа на `el-input__wrapper` селектором из четырёх классов,
а форма входа рисует видимую рамку поля на `el-form-item` и гасила чужую тень
селектором из трёх — и проигрывала по специфичности. Рамка ложилась вокруг
одного лишь ввода: у логина начиналась после иконки, у пароля обрывалась перед
переключателем видимости. Индикация перенесена на элемент, который оператор и
видит полем.
- **Требование не называлось.** «Неверный формат логина» и «Некорректное
значение» не давали оператору ни одного способа узнать, что от него хотят:
набор символов логина приходит из `hy2xs.env` и в панели нигде не показан.
Фразы форм и серверная причина `credential_format` теперь перечисляют границы
и набор.
- **`last_login_at` не записывался никогда.** Колонка объявлена в схеме и в
entity, `UpdateAdminLastLoginAt` существовал и не вызывался ниоткуда. Отметка
ставится в `service.Login` сразу после успешной проверки пароля; отказ записи
вход не отменяет, но попадает в журнал.
### Исправлено — домен значений файла окружения
- **Контракт не знал, какие значения systemd вообще принимает.** Перед тем как
принять пару, systemd прогоняет ключ и значение через `utf8_is_valid`
(`src/basic/env-file.c`, `check_utf8ness_and_warn`), и отказ там возвращает
`-EINVAL` — то есть НЕзагруженный `EnvironmentFile=` и юнит, который не
стартует, а не предупреждение. `unichar_is_valid` (`src/basic/utf8.c`)
отвергает суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`.
Пароль `abcde` + `U+FDD0` — шесть символов, восемь байт, ни одного
управляющего — проходил панель, оркестратор, DTO и хеширование, записывался в
`hy2xs.env`, и после этого админка не поднималась: тот же класс дефекта, ради
уничтожения которого контракт и существует, только слоем ниже. Введён
`IsEnvTransportableText` (Go) / `isEnvTransportable` (TS), повторяющий
множество systemd точно — не шире и не уже.
- **Одиночный суррогат подменял бы секрет молча.** Строка JavaScript вправе
содержать непарный суррогат, а `TextEncoder` заменяет его на `U+FFFD`. Без
отдельной проверки в файл уехал бы ДРУГОЙ пароль, а не отказ. Панель и
оркестратор отвергают такие значения явно.
- **Проверка транспорта приписывала формату чужие запреты.** Она отвергала C0 и
DEL с формулировкой «формат управляющих символов не несёт» — неправда: внутри
двойных кавычек перевод строки накапливается как обычный байт и переживает
round-trip. Именно эта подмена и позволила проверке не знать про
noncharacters. Домен транспорта и политика продукта теперь разделены и
проверяются отдельными тестами.
- **Политика была шире кода ровно на C1.** Документация обещала «без
управляющих символов», а проверялись только `U+0000..U+001F` и `U+007F`.
Запрещена категория `Cc` целиком, плюс `U+FEFF` — последний отдельным
решением продукта, а не форматом: `0xFEFF & 0xFFFE` это `0xFEFE`, и systemd
такое значение принимает (комментарий `/* BOM */` в его исходнике относится к
`U+xFFFE` и является неточностью самого systemd).
- **Порт автомата расходился с upstream на `\<CR>`.** В состоянии
`DOUBLE_QUOTE_VALUE_ESCAPE` systemd пишет `c != '\n'`, а не проверку на любой
перевод строки (в `VALUE_ESCAPE` — наоборот, `strchr(NEWLINE, c)`). Порт
съедал и `\<LF>`, и `\<CR>`, то есть прочитал бы такое значение иначе, чем
тот, для кого файл в конечном счёте написан.
- **Комментарий обещал одно намеренное расхождение с systemd, а их два.** Кроме
строки без `=`, HY2XS отказывает и на незакрытой кавычке в конце файла, тогда
как systemd принимает накопленное. Оба расхождения fail-closed и теперь
названы оба.
### Исправлено — рецепт восстановления выполнял env-файл как код
- **`set -a; . /etc/hy2xs/hy2xs.env` в инструкции «Забыт пароль
администратора».** Строка стала опасной ровно тогда, когда файл научился нести
произвольные значения. Для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(…)"`
буквальное значение: подстановок в `EnvironmentFile=` нет вовсе. Но `.`
обрабатывает файл bash, а bash внутри двойных кавычек выполняет подстановку
команд — от root, прямо в рецепте восстановления доступа. Причём соседний
раздел той же страницы уже правильно запрещал `source`/`eval` для
`bootstrap-admin.secret`: документ запрещал действие и тут же его предлагал.
Рецепт читает нужные значения как ДАННЫЕ; поставлен гейт приёмки, запрещающий
возврат `source`/`.`/`eval` над этими файлами в командах документации и в
скриптах.
### Исправлено — отказ приходил после мутаций хоста
- **Проверка транспорта жила только внутри `renderRuntimeEnv`.** Она срабатывала
на шаге «write runtime env» — уже после bootstrap оркестратора, установки
пакетов и раскладки файловой системы, — а read-only `preflight-install`
говорил PASS: он зовёт `parseRuntimeEnv` и ничего не рендерит.
Детерминированно известная ошибка конфигурации роняла операцию, оставив за
собой изменённый хост, что прямо противоречит контракту PHASE 0.
`validateRuntimeEnvTransport` вызывается теперь из `parseRuntimeEnv` и
проходит по ВСЕМ парам `runtimeEnvEntries`: ограничение принадлежит формату, а
не полю пароля, и `HY2XS_ADMIN_CON_PASS` сломал бы загрузку юнита так же.
### Исправлено — контракт пароля администратора
- **Граница пароля не совпадала с границей 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.
### Исправлено — правило доступа
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
+35 -10
View File
@@ -634,8 +634,8 @@ hy2xs-orchestrator status \
| `HY2XS_UI_BIND_HOST` | IPv4 bind HY2XS admin | `127.0.0.1` |
| `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` |
| `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` |
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора | `hy2xsadmin` |
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора: 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`. Значение вне контракта роняет установку — панель его не приняла бы на форме входа | `hy2xsadmin` |
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора: 6-64 символа Unicode **и** не более 72 байт в UTF‑8 (предел bcrypt); валидный UTF‑8 в документированном домене `EnvironmentFile=` (в частности, без U+FEFF), без управляющих символов; пробелы по краям — часть пароля, поэтому такое значение записывается в двойных кавычках; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` |
| `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` |
@@ -749,7 +749,7 @@ hy2xs-orchestrator reconfigure \
| `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения |
| `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию |
| `hy2xs-orchestrator repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** |
| `hy2xs-orchestrator diagnostics collect` | Собрать diagnostic bundle в `/var/log/hy2xs/diagnostics` |
| `hy2xs-orchestrator diagnostics collect` | Собрать diagnostic bundle в `/var/lib/hy2xs/diagnostics` |
| `hy2xs-orchestrator redact-config` | Отредактировать секреты в env/yaml перед публикацией логов |
`repair` без `--allow-partial-state` работает только поверх полностью успешной
@@ -766,6 +766,22 @@ hy2xs-orchestrator diagnostics collect \
--config /etc/hy2xs/hy2xs.env
```
Бандл не содержит сырых промежуточных копий конфигурации или журналов:
редакция выполняется в памяти до записи. Файл с повреждённым UTF-8 не
декодируется с заменой и не попадает в архив; вместо него записывается
безопасная причина пропуска.
`HY2XS_FORCE_PASSWORD_CHANGE` — диагностический boolean, поэтому его значение
`true`/`false` сохраняется. Исключение точное и не распространяется на другие
ключи с `PASSWORD`: начальный пароль администратора, `ADMIN_CON_PASS`, пароль
obfs и остальные секреты по-прежнему заменяются на `<redacted>`.
Архив создаётся в `/var/lib/hy2xs/diagnostics` с режимом `0600`. Этот каталог
принадлежит `root:root`, имеет режим `0700` и отделён от
`HY2XS_LOG_DIR`, которым владеет сервисный пользователь `hy2xs-admin`.
Оркестратор отказывает, если каталог подменён symlink, имеет другого владельца
или ослабленные права. Незавершённый staging-каталог после упаковки удаляется.
## Проверка безопасности после установки
Минимальный набор проверок:
@@ -997,17 +1013,18 @@ export GITHUB_TOKEN=<token>
1. проверяет контракт `versions.env` (`verify_versions_contract`);
2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`);
3. прогоняет контрактные тесты панели (спрайт иконок, словари локализации, коды ошибок, атрибуция);
3. прогоняет dependency-free контракты панели (спрайт иконок, совпадение словарей, коды ошибок, атрибуция и frontend/Go-контракты);
4. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
5. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
6. собирает orchestrator, frontend и backend, проставляя версию админки из контракта;
7. прогоняет `go vet` и `go test` для HY2XS admin;
8. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
9. формирует архив и прогоняет acceptance‑проверки.
6. собирает standalone-бинарник orchestrator;
7. устанавливает frontend lock-граф, runtime-компилирует каждое сообщение RU/EN реальным `vue-i18n`, затем проверяет типы и собирает frontend и backend, проставляя версию админки из контракта;
8. прогоняет `go vet` и `go test` для HY2XS admin;
9. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
10. формирует архив и прогоняет acceptance‑проверки.
Любой сбой на шагах 1–8 останавливает сборку до создания пакета.
Любой сбой на шагах 1–9 останавливает сборку до создания пакета.
Тесты и типы (шаги 2, 3 и 7) — такой же обязательный гейт, как проверка
Тесты и типы (шаги 2, 3, 7 и 8) — такой же обязательный гейт, как проверка
зависимостей: переменной, которая их отключает, не существует. Готовый пакет
объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это
утверждение опирается на фактический прогон, а не на намерение.
@@ -1017,6 +1034,14 @@ export GITHUB_TOKEN=<token>
`bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts`
запускаются напрямую и tarball не создают.
Runtime-проверка словарей требует установленного frontend lock-графа:
```bash
cd apps/frontend
pnpm install --frozen-lockfile
bun test test/i18n-runtime.test.ts
```
Переменные, управляющие выбором версии Hysteria:
| Переменная | По умолчанию | Назначение |
+39
View File
@@ -0,0 +1,39 @@
package controller
import (
"errors"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
// Вход в панель живёт в собственном файле, а не среди обработчиков пиров.
//
// Это не косметика. Пока Login лежал в controller/peer.go, паника валидатора на
// форме входа приходила в journal стеком `controller/peer.go:50`, и первая
// гипотеза при разборе RC2 указывала на управление пирами — то есть на
// подсистему, не имеющую к отказу никакого отношения. Самая ценная дверь
// продукта обязана называться своим именем в стеке.
func Login(c *gin.Context) {
loginDto, err := validateField(c, dto.LoginDto{})
if err != nil {
return
}
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
if err != nil {
// Неверные учётные данные получают код, чтобы панель показала
// оператору внятную фразу на его языке. Отказ базы остаётся системной
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
// значит отправить оператора искать несуществующую опечатку.
if errors.Is(err, service.ErrInvalidCredentials) {
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
return
}
vo.Fail(err.Error(), c)
return
}
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
}
+838
View File
@@ -0,0 +1,838 @@
package controller
import (
"bytes"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"hy2xs-admin/credential"
"hy2xs-admin/dao"
"hy2xs-admin/middleware"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
)
// Контракт входа в панель.
//
// Что здесь произошло и почему тестов было мало. В RC2 каждый
// `POST /api/auth/login` заканчивался HTTP 500: на LoginDto.Username стоял тег
// `validateStr`, правило с таким именем не регистрировалось, и
// go-playground/validator ПАНИКОВАЛ до всякой проверки логина и пароля.
// Существующие тесты этого не видели, потому что проверяли регексп напрямую —
// в обход production-валидатора, — а ни одного запроса к обработчику входа не
// делал никто.
//
// Поэтому проверок здесь три уровня, и каждый ловит свой класс:
//
// структурный — ни один тег ни в одном DTO не ссылается на незарегистрированное
// правило (закрывает КЛАСС, а не найденный экземпляр);
// правила — LoginDto проходит через тот самый validate, что и в бою;
// HTTP — обработчик отвечает конвертом, а не 500, на любом входе.
// --------------------------------------------------------------- структура ---
var validationTagPattern = regexp.MustCompile(`validate:"([^"]*)"`)
// Ни один тег валидации не имеет права ссылаться на правило, которого нет.
//
// Это главный барьер файла. Наивная версия — «прогнать нулевое DTO через
// validate.Struct» — дефекта RC2 НЕ ловит: цепочка правил поля обрывается на
// первом несработавшем, поэтому на пустом Username проверка отказывает по
// `required` и до испорченного тега не доходит. Поэтому теги извлекаются из
// исходников и каждый предъявляется валидатору отдельно: незарегистрированное
// правило паникует ровно так же, как паниковало в бою, но здесь — на сборке.
func TestEveryValidationTagIsRegistered(t *testing.T) {
root := filepath.Join("..", "model")
users := map[string]map[string]struct{}{}
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
return nil
}
source, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
for _, match := range validationTagPattern.FindAllSubmatch(source, -1) {
for _, rule := range splitValidationRules(string(match[1])) {
if users[rule] == nil {
users[rule] = map[string]struct{}{}
}
users[rule][filepath.ToSlash(path)] = struct{}{}
}
}
return nil
})
if err != nil {
t.Fatalf("не удалось прочитать исходники модели: %v", err)
}
// Пустой обход означал бы, что проверка молчит из-за неверного пути, а не
// из-за отсутствия дефектов. Число намеренно грубое: тест закрепляет факт
// «теги найдены», а не их количество.
if len(users) < 5 {
t.Fatalf("в %s найдено %d правил валидации — обход не дошёл до DTO", root, len(users))
}
for rule, files := range users {
assertRuleIsRegistered(t, rule, files)
}
}
// splitValidationRules разбирает содержимое тега на отдельные правила.
//
// Разделителями считаются и `,`, и `|`: go-playground/validator понимает оба —
// первый как «и», второй как «или», — и правило, записанное через `|`, обязано
// существовать ровно так же.
func splitValidationRules(tag string) []string {
var rules []string
for _, rule := range strings.FieldsFunc(tag, func(r rune) bool { return r == ',' || r == '|' }) {
rule = strings.TrimSpace(rule)
if rule == "" || rule == "-" {
continue
}
rules = append(rules, rule)
}
return rules
}
func assertRuleIsRegistered(t *testing.T, rule string, files map[string]struct{}) {
t.Helper()
where := make([]string, 0, len(files))
for file := range files {
where = append(where, file)
}
defer func() {
if recovered := recover(); recovered != nil {
t.Errorf(
"правило %q не зарегистрировано в controller/validator.go, но используется в %s: %v\n"+
"незарегистрированный тег означает панику на КАЖДОМ запросе к этой форме",
rule, strings.Join(where, ", "), recovered,
)
}
}()
// Значение подобрано так, чтобы вопрос был именно о существовании правила:
// результат проверки здесь не важен, важно её отсутствие паники.
_ = validate.Var("hy2xsadmin", rule)
}
// ------------------------------------------------------------------ правила ---
func loginDto(username string, password string) dto.LoginDto {
return dto.LoginDto{Username: &username, Pass: &password}
}
// normalizedAndValidated повторяет конвейер validateField: нормализация, затем
// правила. Проверять правила в обход нормализации бессмысленно — в бою они
// видят только приведённый вход.
func normalizedAndValidated(login dto.LoginDto) error {
login.Normalize()
return validate.Struct(&login)
}
// Регрессия RC2: корректный вход не имеет права ни паниковать, ни отказывать.
func TestLoginDtoPassesProductionValidator(t *testing.T) {
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("проверка LoginDto паникует: %v", recovered)
}
}()
if err := normalizedAndValidated(loginDto("hy2xsadmin", "bootstrap-password")); err != nil {
t.Fatalf("корректный LoginDto отклонён: %v", err)
}
}
// Отказ обязан быть ОТКАЗОМ — значением с причиной, а не паникой.
func TestLoginDtoReportsReasonInsteadOfPanic(t *testing.T) {
cases := []struct {
name string
username string
password string
code string
field string
}{
{"кириллица в логине", "админ12", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"пробел в логине", "hy2xs admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"логин короче минимума", "admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"логин длиннее максимума", strings.Repeat("a", credential.AdminUsernameMaxLength+1), "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
{"пустой логин", " ", "bootstrap-password", constant.ErrCodeRequired, "username"},
{"пустой пароль", "hy2xsadmin", "", constant.ErrCodeRequired, "pass"},
{"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeAdminPassword, "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 {
t.Run(testCase.name, func(t *testing.T) {
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("проверка обязана вернуть отказ, а не паниковать: %v", recovered)
}
}()
err := normalizedAndValidated(loginDto(testCase.username, testCase.password))
if err == nil {
t.Fatal("некорректный вход принят")
}
described := describeValidationErrors(err)
if !hasFieldReason(described, testCase.field, testCase.code) {
t.Fatalf("ожидалась причина %q на поле %q, получено %+v", testCase.code, testCase.field, described)
}
})
}
}
func hasFieldReason(described []vo.FieldError, field string, code string) bool {
for _, reason := range described {
if reason.Field == field && reason.Code == code {
return true
}
}
return false
}
// Причина отказа по набору символов обязана НАЗЫВАТЬ набор.
//
// Оператору негде посмотреть, какие символы допустимы в логине: значение
// приходит из hy2xs.env, а панель показывает то, что ответил сервер. Прежнее
// «содержит недопустимые символы» вдобавок описывало и отказ по длине.
func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) {
err := normalizedAndValidated(loginDto("админ12", "bootstrap-password"))
if err == nil {
t.Fatal("некорректный логин принят")
}
for _, reason := range describeValidationErrors(err) {
if reason.Code != constant.ErrCodeCredentialStr {
continue
}
if reason.Params["charset"] != credential.AdminUsernameCharset {
t.Errorf("причина не называет набор символов: %+v", reason.Params)
}
if reason.Params["min"] != strconv.Itoa(credential.AdminUsernameMinLength) {
t.Errorf("причина не называет нижнюю границу: %+v", reason.Params)
}
if reason.Params["max"] != strconv.Itoa(credential.AdminUsernameMaxLength) {
t.Errorf("причина не называет верхнюю границу: %+v", reason.Params)
}
return
}
t.Fatalf("отказ не содержит причины %q", constant.ErrCodeCredentialStr)
}
// ---------------------------------------------------------------- контракты ---
// passwordFields — все поля продукта, несущие пароль администратора.
var passwordFields = []struct {
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"},
}
// Правило пароля ОДНО, и границы живут внутри него.
//
// Здесь стояли `min=6,max=64` прямо в тегах, и проверка следила за тем, чтобы
// числа совпадали с контрактом. Совпадали они честно — и всё равно закрепляли
// неполное правило: тег считает символы Unicode, а bcrypt считает байты, и
// границы в байтах тегом не выразить вовсе. Поэтому теперь проверяется не
// равенство чисел, а ОТСУТСТВИЕ чисел: длина принадлежит правилу, а не соседям
// по тегу. Тот же барьер уже стоит на логине (TestUsernameLengthLivesInsideItsRule).
func TestPasswordLengthLivesInsideItsRule(t *testing.T) {
for _, subject := range passwordFields {
field, ok := subject.typ.FieldByName(subject.field)
if !ok {
t.Fatalf("%s: поле не найдено", subject.name)
}
rules := splitValidationRules(field.Tag.Get("validate"))
for _, rule := range rules {
if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") {
t.Errorf(
"%s: отдельная граница %q рядом с правилом. Длина принадлежит adminPassword: "+
"тег умеет считать только символы, а у пароля есть ещё граница в байтах",
subject.name, rule,
)
}
}
var hasPasswordRule bool
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,
)
}
}
}
}
// Набор символов пароля сервером НЕ ограничивается — ни на входе, ни при смене.
//
// Это решение, а не упущение: значение всё равно сравнивается с хешем, поэтому
// ограничение набора не защищает ничего и умеет только отвергнуть пароль,
// который сервер принял бы. Панель обязана держаться этого же контракта, и
// проверка существует, чтобы правило не «навели» обратно.
func TestPasswordHasNoCharsetRule(t *testing.T) {
for _, subject := range passwordFields {
field, _ := subject.typ.FieldByName(subject.field)
for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
if rule == "credentialStr" || rule == "peerName" {
t.Errorf("%s: на пароль повешено правило набора символов %q", subject.name, rule)
}
}
}
}
// Длина логина живёт ВНУТРИ правила, а не рядом с ним.
//
// Два правила длины на одном поле уже приводили к отказу, который невозможно
// объяснить оператору: `min=1,max=32` вместе с правилом «6-32» принимали и
// отвергали одно и то же значение, а панель показывала «invalid».
func TestUsernameLengthLivesInsideItsRule(t *testing.T) {
field, ok := reflect.TypeOf(dto.LoginDto{}).FieldByName("Username")
if !ok {
t.Fatal("LoginDto.Username не найдено")
}
rules := splitValidationRules(field.Tag.Get("validate"))
for _, rule := range rules {
if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") {
t.Errorf("на логине отдельная граница %q: длина принадлежит правилу credentialStr", rule)
}
}
var hasCredentialRule bool
for _, rule := range rules {
if rule == "credentialStr" {
hasCredentialRule = true
}
}
if !hasCredentialRule {
t.Fatalf("логин проверяется правилами %v — среди них нет credentialStr", rules)
}
}
// --------------------------------------------------------------------- HTTP ---
func newAuthTestDB(t *testing.T, username string, password string) int64 {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := dao.InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := dao.RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
// Токен подписывается ключом из таблицы config. Без него успешный вход
// отказал бы системной ошибкой, и тест перестал бы отличать работающий
// вход от неработающего.
if err := dao.UpsertConfigValue(constant.JwtSecret, "test-jwt-secret"); err != nil {
t.Fatalf("не удалось записать ключ подписи: %v", err)
}
hash, err := util.HashPassword(password)
if err != nil {
t.Fatalf("не удалось захешировать пароль: %v", err)
}
name := username
status := int64(1)
tokenVersion := int64(1)
force := int64(0)
changedAt := time.Now().UnixMilli()
id, err := dao.SaveAdminUser(entity.AdminUser{
Username: &name,
PasswordHash: &hash,
Status: &status,
TokenVersion: &tokenVersion,
ForcePasswordChange: &force,
PasswordChangedAt: &changedAt,
})
if err != nil {
t.Fatalf("не удалось создать администратора: %v", err)
}
return id
}
// postLoginRaw отправляет тело КАК ЕСТЬ через маршрут боевой конфигурации.
//
// `recovery` выбирает, стоит ли перед обработчиком gin.Recovery. Обе ветки
// нужны: без него паника роняет тест и называет причину, с ним воспроизводится
// ровно то, что видел оператор RC2, — HTTP 500 вместо конверта.
func postLoginRaw(t *testing.T, body []byte, recovery bool) (int, apiResult) {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
if recovery {
engine.Use(gin.Recovery())
}
engine.POST("/api/auth/login", Login)
request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if recorder.Body.Len() > 0 {
_ = json.Unmarshal(recorder.Body.Bytes(), &result)
}
return recorder.Code, result
}
func postLogin(t *testing.T, body any) (int, apiResult) {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("не удалось собрать тело запроса: %v", err)
}
return postLoginRaw(t, payload, false)
}
// postLoginThroughFilter воспроизводит реальную внешнюю дверь login API:
// scanner filter выполняется раньше DTO и контроллера. Именно этой связки не
// было в тестах до RC3, поэтому backend и smoke были зелёными по отдельности,
// а настоящий installer получал 403 на стандартный curl User-Agent.
func postLoginThroughFilter(t *testing.T, body any, userAgent string) (int, apiResult) {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("не удалось собрать тело запроса: %v", err)
}
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(gin.Recovery(), middleware.FilterHandler())
engine.POST("/api/auth/login", Login)
request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(payload))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", userAgent)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if recorder.Body.Len() > 0 {
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("middleware вернул не JSON: %s", recorder.Body.String())
}
}
return recorder.Code, result
}
// Полный wire-path RC3: стандартный UA curl действительно блокируется, а
// выделенный UA установщика проходит тот же middleware до authentication logic.
func TestLoginWirePathRespectsScannerFilterAndInstallerUserAgent(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
status, blocked := postLoginThroughFilter(t, map[string]any{
"username": "hy2xsadmin",
"pass": "bootstrap-password",
}, "curl/8.10.1")
if status != http.StatusForbidden || blocked.Code != http.StatusForbidden {
t.Fatalf("scanner-like curl не заблокирован: HTTP %d, ответ %+v", status, blocked)
}
status, accepted := postLoginThroughFilter(t, map[string]any{
"username": "hy2xsadmin",
"pass": "bootstrap-password",
}, "HY2XS-Installer/1.0")
if status != http.StatusOK || accepted.Code != constant.CodeSuccess {
t.Fatalf("UA установщика не дошёл до успешного входа: HTTP %d, ответ %+v", status, accepted)
}
var issued struct {
AccessToken string `json:"accessToken"`
}
if err := json.Unmarshal(accepted.Data, &issued); err != nil || issued.AccessToken == "" {
t.Fatalf("успешная wire-проба не выдала токен: %s", string(accepted.Data))
}
_, rejected := postLoginThroughFilter(t, map[string]any{
"username": "hy2xsadmin",
"pass": "wrong-password",
}, "HY2XS-Installer/1.0")
var invalidCredentials bool
for _, reason := range rejected.Errors {
invalidCredentials = invalidCredentials || reason.Code == constant.ErrCodeInvalidCredentials
}
if rejected.Code != constant.CodeSysError || !invalidCredentials {
t.Fatalf("negative wire-проба не дошла до auth logic: %+v", rejected)
}
}
// `password` не является скрытым alias: иначе orchestrator и frontend могли бы
// незаметно разойтись по двум разным HTTP-контрактам.
func TestLoginWirePathRejectsPasswordAlias(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
_, result := postLoginThroughFilter(t, map[string]any{
"username": "hy2xsadmin",
"password": "bootstrap-password",
}, "HY2XS-Installer/1.0")
var passRequired bool
for _, reason := range result.Errors {
passRequired = passRequired || (reason.Field == "pass" && reason.Code == constant.ErrCodeRequired)
}
if result.Code != constant.CodeInvalidError || !passRequired {
t.Fatalf("alias password не отклонён как отсутствие wire-поля pass: %+v", result)
}
}
// Регрессия RC2 целиком: вход bootstrap-учёткой обязан выдать токен.
func TestLoginEndpointIssuesTokenForValidCredentials(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
status, result := postLogin(t, map[string]any{
"username": "hy2xsadmin",
"pass": "bootstrap-password",
})
if status != http.StatusOK {
t.Fatalf("вход ответил HTTP %d, ожидался 200", status)
}
if result.Code != constant.CodeSuccess {
t.Fatalf("вход отклонён: %+v", result)
}
var issued struct {
TokenType string `json:"tokenType"`
AccessToken string `json:"accessToken"`
}
if err := json.Unmarshal(result.Data, &issued); err != nil {
t.Fatalf("ответ не содержит токена: %s", string(result.Data))
}
if strings.TrimSpace(issued.AccessToken) == "" {
t.Fatal("выдан пустой токен")
}
if issued.TokenType != constant.TokenType {
t.Fatalf("тип токена %q, ожидался %q", issued.TokenType, constant.TokenType)
}
}
// Повреждённые байты и непарные surrogate escape не имеют права превратиться
// в U+FFFD до проверки пароля. Сам U+FFFD остаётся обычным допустимым символом.
func TestLoginEndpointRejectsLossyJSONBeforeAuthentication(t *testing.T) {
password := "abcde" + string(rune(0xFFFD))
id := newAuthTestDB(t, "hy2xsadmin", password)
for _, body := range [][]byte{
[]byte{'{', '"', 'u', 's', 'e', 'r', 'n', 'a', 'm', 'e', '"', ':', '"', 'h', 'y', '2', 'x', 's', 'a', 'd', 'm', 'i', 'n', '"', ',', '"', 'p', 'a', 's', 's', '"', ':', '"', 0xFF, '"', '}'},
[]byte(`{"username":"hy2xsadmin","pass":"abcde\uD800"}`),
[]byte(`{"username":"hy2xsadmin","pass":"abcde\uDC00"}`),
} {
status, result := postLoginRaw(t, body, false)
if status != http.StatusOK {
t.Fatalf("повреждённое тело дало HTTP %d", status)
}
if result.Code != constant.CodeInvalidError {
t.Fatalf("повреждённое тело не отклонено как invalid: %+v", result)
}
var found bool
for _, reason := range result.Errors {
found = found || reason.Code == constant.ErrCodeBodyInvalid
}
if !found {
t.Fatalf("нет причины %q: %+v", constant.ErrCodeBodyInvalid, result)
}
}
admin, err := dao.GetAdminUser("id = ?", id)
if err != nil {
t.Fatalf("не удалось проверить состояние администратора: %v", err)
}
if admin.LastLoginAt != nil && *admin.LastLoginAt != 0 {
t.Fatalf("отклонённое JSON-тело изменило last_login_at: %d", *admin.LastLoginAt)
}
_, valid := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
if valid.Code != constant.CodeSuccess {
t.Fatalf("настоящий U+FFFD ошибочно отклонён: %+v", valid)
}
}
// Тот же запрос ЗА gin.Recovery: именно так он шёл в бою.
//
// В RC2 здесь был HTTP 500 — панику валидатора Recovery превращал в системную
// ошибку транспорта, и панель не могла сказать о ней ничего осмысленного.
func TestLoginEndpointNeverAnswersServerErrorBehindRecovery(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
bodies := [][]byte{
[]byte(`{"username":"hy2xsadmin","pass":"bootstrap-password"}`),
[]byte(`{"username":"","pass":""}`),
[]byte(`{"username":"админ12","pass":"bootstrap-password"}`),
[]byte(`{"username":"hy2xsadmin"}`),
[]byte(`{}`),
[]byte(`{"username":123,"pass":false}`),
[]byte(`не json вовсе`),
[]byte(``),
}
for _, body := range bodies {
status, result := postLoginRaw(t, body, true)
if status != http.StatusOK {
t.Errorf("тело %q дало HTTP %d: обработчик обязан отвечать конвертом, а не отказом транспорта", string(body), status)
continue
}
if result.Code != constant.CodeSuccess && len(result.Errors) == 0 && strings.TrimSpace(result.Message) == "" {
t.Errorf("тело %q дало отказ без причины: %+v", string(body), result)
}
}
}
// Неверный пароль — доменный отказ с кодом, а не системная ошибка.
func TestLoginEndpointReportsInvalidCredentials(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
_, result := postLogin(t, map[string]any{
"username": "hy2xsadmin",
"pass": "wrong-password",
})
if result.Code == constant.CodeSuccess {
t.Fatal("вход с неверным паролем выполнен")
}
if _, found := errorFor(t, result, ""); !found {
t.Fatalf("отказ без причины: %+v", result)
}
var hasCode bool
for _, reason := range result.Errors {
if reason.Code == constant.ErrCodeInvalidCredentials {
hasCode = true
}
}
if !hasCode {
t.Fatalf("отказ не помечен кодом %q: %+v", constant.ErrCodeInvalidCredentials, result)
}
}
// Несуществующий администратор неотличим от неверного пароля.
//
// Иначе форма входа превращается в способ проверять существование имён.
func TestLoginEndpointDoesNotRevealWhetherAccountExists(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
_, missing := postLogin(t, map[string]any{"username": "otheradmin", "pass": "bootstrap-password"})
_, wrong := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "wrong-password"})
if missing.Code != wrong.Code || missing.Message != wrong.Message {
t.Fatalf("ответы различимы: несуществующий %+v против неверного пароля %+v", missing, wrong)
}
}
// Логин из bootstrap-admin.secret регулярно приезжает с пробелом или переводом
// строки на конце. Раньше это давало «неверный логин или пароль».
func TestLoginEndpointTrimsSurroundingWhitespaceInUsername(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
_, result := postLogin(t, map[string]any{
"username": " hy2xsadmin\n",
"pass": "bootstrap-password",
})
if result.Code != constant.CodeSuccess {
t.Fatalf("логин с окружающими пробелами отклонён: %+v", result)
}
}
// Пароль НЕ триммится: его набор символов не ограничен, и пробел по краям —
// часть значения, а не мусор ввода.
func TestLoginEndpointDoesNotTrimPassword(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password ")
_, exact := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password "})
if exact.Code != constant.CodeSuccess {
t.Fatalf("пароль с пробелом на конце отклонён: %+v", exact)
}
_, trimmed := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password"})
if trimmed.Code == constant.CodeSuccess {
t.Fatal("пароль обрезан по краям: вход выполнен не тем значением, которое ввёл оператор")
}
}
// Пароль длиной в 64 символа, назначенный формой смены, обязан пускать в панель.
//
// Ровно этот путь был закрыт: форма входа ограничивала пароль 32 символами при
// серверном пределе в 64, и оператор терял доступ после штатной смены пароля.
func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) {
password := strings.Repeat("a", credential.AdminPasswordMaxLength)
newAuthTestDB(t, "hy2xsadmin", password)
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
if result.Code != constant.CodeSuccess {
t.Fatalf("пароль предельной длины отклонён: %+v", result)
}
}
// Пароль, упирающийся в предел 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 не вызывался ниоткуда.
func TestLoginEndpointRecordsLastLoginAt(t *testing.T) {
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
before, err := dao.GetAdminUser("id = ?", id)
if err != nil {
t.Fatalf("не удалось прочитать администратора: %v", err)
}
if before.LastLoginAt != nil && *before.LastLoginAt != 0 {
t.Fatalf("подготовка: отметка о входе уже проставлена (%d)", *before.LastLoginAt)
}
if _, result := postLogin(t, map[string]any{
"username": "hy2xsadmin",
"pass": "bootstrap-password",
}); result.Code != constant.CodeSuccess {
t.Fatalf("вход отклонён: %+v", result)
}
after, err := dao.GetAdminUser("id = ?", id)
if err != nil {
t.Fatalf("не удалось перечитать администратора: %v", err)
}
if after.LastLoginAt == nil || *after.LastLoginAt == 0 {
t.Fatal("время входа не записано")
}
}
// Неудачная попытка отметку о входе НЕ ставит.
func TestFailedLoginDoesNotRecordLastLoginAt(t *testing.T) {
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
if _, result := postLogin(t, map[string]any{
"username": "hy2xsadmin",
"pass": "wrong-password",
}); result.Code == constant.CodeSuccess {
t.Fatal("вход с неверным паролем выполнен")
}
after, err := dao.GetAdminUser("id = ?", id)
if err != nil {
t.Fatalf("не удалось прочитать администратора: %v", err)
}
if after.LastLoginAt != nil && *after.LastLoginAt != 0 {
t.Fatalf("неудачная попытка проставила время входа: %d", *after.LastLoginAt)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
func Hysteria2Auth(c *gin.Context) {
var req dto.Hysteria2AuthDto
if err := c.ShouldBindJSON(&req); err != nil {
if err := strictBindJSON(c, &req); err != nil {
vo.Hysteria2AuthBadRequest(c)
return
}
+113
View File
@@ -0,0 +1,113 @@
package controller
import (
"bytes"
"errors"
"fmt"
"io"
"unicode/utf8"
"github.com/gin-gonic/gin"
)
const jsonBodyMaxBytes = 1024 * 1024
var errJSONBodyTooLarge = errors.New("тело JSON превышает 1 МиБ")
// strictBindJSON не позволяет стандартному encoding/json молча заменить
// повреждённый UTF-8 или непарный UTF-16 surrogate escape на U+FFFD.
//
// Это критично для секретов: U+FFFD сам по себе разрешён, поэтому без этой
// границы другое байтовое значение могло аутентифицироваться как пароль,
// который фактически хранится в базе. После проверки тело восстанавливается и
// передаётся штатному Gin binder, чтобы сохранить действующий API-контракт.
func strictBindJSON(c *gin.Context, dst any) error {
body := c.Request.Body
if body == nil {
return errors.New("пустое тело JSON")
}
raw, err := io.ReadAll(io.LimitReader(body, jsonBodyMaxBytes+1))
_ = body.Close()
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("не удалось прочитать тело JSON: %w", err)
}
if len(raw) > jsonBodyMaxBytes {
return errJSONBodyTooLarge
}
if err := validateStrictJSONEncoding(raw); err != nil {
return err
}
return c.ShouldBindJSON(dst)
}
// validateStrictJSONEncoding проверяет только границу кодировки. Синтаксис,
// типы и структуру затем проверяет encoding/json; здесь отдельно закрывается
// его документированное replacement-поведение.
func validateStrictJSONEncoding(raw []byte) error {
if !utf8.Valid(raw) {
return errors.New("тело JSON содержит некорректный UTF-8")
}
inString := false
for i := 0; i < len(raw); i++ {
switch raw[i] {
case '"':
inString = !inString
case '\\':
if !inString {
continue
}
i++
if i >= len(raw) {
return errors.New("незавершённая escape-последовательность JSON")
}
if raw[i] != 'u' {
continue
}
code, ok := parseJSONHex4(raw, i+1)
if !ok {
return errors.New("некорректная Unicode escape-последовательность JSON")
}
i += 4
switch {
case code >= 0xD800 && code <= 0xDBFF:
// Старший суррогат допустим только как первая половина пары и
// только в соседней escape-последовательности \uXXXX.
if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
return errors.New("непарный старший UTF-16 суррогат в JSON")
}
low, lowOK := parseJSONHex4(raw, i+3)
if !lowOK || low < 0xDC00 || low > 0xDFFF {
return errors.New("непарный старший UTF-16 суррогат в JSON")
}
i += 6
case code >= 0xDC00 && code <= 0xDFFF:
return errors.New("непарный младший UTF-16 суррогат в JSON")
}
}
}
return nil
}
func parseJSONHex4(raw []byte, start int) (uint16, bool) {
if start < 0 || start+4 > len(raw) {
return 0, false
}
var value uint16
for _, character := range raw[start : start+4] {
value <<= 4
switch {
case character >= '0' && character <= '9':
value += uint16(character - '0')
case character >= 'a' && character <= 'f':
value += uint16(character-'a') + 10
case character >= 'A' && character <= 'F':
value += uint16(character-'A') + 10
default:
return 0, false
}
}
return value, true
}
+31
View File
@@ -0,0 +1,31 @@
package controller
import "testing"
func TestValidateStrictJSONEncoding(t *testing.T) {
tests := []struct {
name string
body []byte
wantErr bool
}{
{"обычный UTF-8", []byte(`{"password":"пароль"}`), false},
{"настоящий U+FFFD", []byte(`{"password":"abcde"}`), false},
{"валидная surrogate pair", []byte(`{"value":"\uD83D\uDE00"}`), false},
{"экранированный обратный слеш", []byte(`{"value":"\\uD800"}`), false},
{"байт FF", []byte{'{', '"', 'x', '"', ':', '"', 0xFF, '"', '}'}, true},
{"оборванный UTF-8", []byte{'{', '"', 'x', '"', ':', '"', 0xC3, '"', '}'}, true},
{"UTF-8 суррогата", []byte{'{', '"', 'x', '"', ':', '"', 0xED, 0xA0, 0x80, '"', '}'}, true},
{"непарный старший", []byte(`{"value":"\uD800"}`), true},
{"старший перед обычным", []byte(`{"value":"\uD800\u0041"}`), true},
{"непарный младший", []byte(`{"value":"\uDC00"}`), true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gotErr := validateStrictJSONEncoding(test.body) != nil
if gotErr != test.wantErr {
t.Fatalf("ошибка=%v, ожидалась=%v", gotErr, test.wantErr)
}
})
}
}
+4 -21
View File
@@ -46,27 +46,6 @@ func resolveID(c *gin.Context) (int64, error) {
return parsed, nil
}
func Login(c *gin.Context) {
loginDto, err := validateField(c, dto.LoginDto{})
if err != nil {
return
}
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
if err != nil {
// Неверные учётные данные получают код, чтобы панель показала
// оператору внятную фразу на его языке. Отказ базы остаётся системной
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
// значит отправить оператора искать несуществующую опечатку.
if errors.Is(err, service.ErrInvalidCredentials) {
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
return
}
vo.Fail(err.Error(), c)
return
}
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
}
func PagePeer(c *gin.Context) {
peerPageDto, err := validateField(c, dto.PeerPageDto{})
if err != nil {
@@ -210,6 +189,10 @@ func ImportPeer(c *gin.Context) {
vo.Fail("the file is too big", c)
return
}
if err = validateStrictJSONEncoding(content); err != nil {
vo.Fail("content contains invalid UTF-8 or UTF-16", c)
return
}
var peerExports []bo.PeerExport
decoder := json.NewDecoder(bytes.NewReader(content))
+20 -2
View File
@@ -17,7 +17,7 @@ import (
// Разбор загруженного файла проверяется без базы: все три отказа наступают до
// первого обращения к слою данных, и это часть контракта — испорченный файл не
// должен доходить до транзакции.
func postImportFile(t *testing.T, fileName string, content string) apiResult {
func postImportBytes(t *testing.T, fileName string, content []byte) apiResult {
t.Helper()
gin.SetMode(gin.TestMode)
@@ -27,7 +27,7 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
if err != nil {
t.Fatalf("не удалось собрать форму: %v", err)
}
if _, err := part.Write([]byte(content)); err != nil {
if _, err := part.Write(content); err != nil {
t.Fatalf("не удалось записать файл в форму: %v", err)
}
if err := writer.Close(); err != nil {
@@ -49,6 +49,24 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
return result
}
func postImportFile(t *testing.T, fileName string, content string) apiResult {
t.Helper()
return postImportBytes(t, fileName, []byte(content))
}
func TestImportPeerRejectsInvalidUTF8(t *testing.T) {
for _, content := range [][]byte{
{'[', '"', 0xFF, '"', ']'},
{'[', '"', 0xC3, '"', ']'},
{'[', '"', 0xED, 0xA0, 0x80, '"', ']'},
} {
result := postImportBytes(t, "peers.json", content)
if result.Type != "no" {
t.Fatalf("повреждённый UTF-8 принят: %+v", result)
}
}
}
// Регрессия: json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл с
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
// половина файла молча не применялась.
+64 -16
View File
@@ -5,11 +5,11 @@ import (
"fmt"
"net/http"
"reflect"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"hy2xs-admin/credential"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
@@ -34,6 +34,7 @@ func init() {
mustRegister("peerName", validatePeerName)
mustRegister("credentialStr", validateCredentialStr)
mustRegister("adminPassword", validateAdminPassword)
}
func mustRegister(tag string, fn validator.Func) {
@@ -60,21 +61,38 @@ func validatePeerName(f validator.FieldLevel) bool {
return service.IsValidPeerName(f.Field().String())
}
// credentialStrPattern — набор символов логина и пароля администратора.
// validateCredentialStr — единственное правило имени администратора.
//
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
// последствия неэкранированного дефиса в исходной записи `_+-=`. Это сделано
// намеренно: имя администратора приходит из HY2XS_ADMIN_USER в hy2xs.env,
// оркестратор набор символов не ограничивает, и сужение правила означало бы,
// что установка с логином вроде `admin.ops` перестаёт пускать оператора в
// панель. Сужать этот набор можно только вместе с проверкой имени на стороне
// оркестратора, и это отдельная работа, а не побочный эффект правки формы
// пира.
var credentialStrPattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+,\-./:;<=]{6,32}$`)
// Набор символов и длина берутся из service по той же причине, что и у имени
// пира: собственная копия правила в слое контроллеров уже расходилась с
// остальным продуктом. Здесь она вдобавок была единственным местом, знавшим
// набор, — оркестратор писал в hy2xs.env любое имя, а панель проверяла своё
// третье правило.
//
// Пустое значение НЕ впускается. Раньше здесь стояло `field == "" || …`, и это
// была ловушка: у поля-указателя `required` считает непустым сам факт
// ненулевого указателя, поэтому `{"username":""}` доходило сюда и объявлялось
// корректным. Пустой логин отсекается нормализацией DTO, которая превращает
// его в «не задано», и получает отказ `required` — то есть ту причину, которая
// у него на самом деле.
func validateCredentialStr(f validator.FieldLevel) bool {
field := f.Field().String()
return field == "" || credentialStrPattern.MatchString(field)
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 разбирает запрос, приводит его к каноничному виду и проверяет
@@ -91,7 +109,7 @@ func validateField[T interface{}](c *gin.Context, field T) (T, error) {
case http.MethodGet:
bindErr = c.ShouldBindQuery(&field)
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
bindErr = c.ShouldBindJSON(&field)
bindErr = strictBindJSON(c, &field)
}
if bindErr != nil {
vo.FailValidation(
@@ -202,8 +220,38 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
)
case "credentialStr":
// Причина называет набор и границы ровно так же, как это делает
// `peerName`. Прежнее «содержит недопустимые символы» не отвечало на
// вопрос оператора: набор символов логина ему негде посмотреть, а
// отказ по длине этой фразой описывался как отказ по символам.
described.Code = constant.ErrCodeCredentialStr
described.Message = fmt.Sprintf("поле %q содержит недопустимые символы", field)
described.Params = map[string]string{
"min": fmt.Sprintf("%d", credential.AdminUsernameMinLength),
"max": fmt.Sprintf("%d", credential.AdminUsernameMaxLength),
"charset": credential.AdminUsernameCharset,
}
described.Message = fmt.Sprintf(
"поле %q: от %d до %d символов из набора %s",
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, без управляющих символов и U+FEFF",
field,
credential.AdminPasswordMinLength,
credential.AdminPasswordMaxLength,
credential.AdminPasswordMaxBytes,
)
default:
described.Code = constant.ErrCodeRuleUnknown
described.Params = map[string]string{"rule": fieldErr.Tag()}
+49 -26
View File
@@ -4,40 +4,63 @@ import (
"strings"
"testing"
"hy2xs-admin/credential"
"hy2xs-admin/service"
)
// Набор символов логина и пароля закреплён ФАКТИЧЕСКИМ множеством.
// Правила валидатора обязаны спрашивать КОНТРАКТ, а не собственную копию.
//
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
// Новая запись перечисляет эти символы явно и НЕ сужает множество: имя
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, оркестратор его
// набор символов не ограничивает, и сужение правила означало бы, что установка
// с логином вроде `admin.ops` перестаёт пускать оператора в панель.
//
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
// классе символов уронит его, а не вход администратора на живом сервере.
func TestCredentialCharsetIsUnchanged(t *testing.T) {
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
for _, symbol := range strings.Split(historical, "") {
candidate := "admin" + symbol
if !credentialStrPattern.MatchString(candidate) {
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
// Прежние версии этих проверок обращались к регекспу внутри слоя контроллеров и
// потому не увидели бы расхождения между копией и правилом — а именно так этот
// класс дефектов и появлялся: набор символов логина существовал в четырёх
// местах, и все четыре разошлись. Сам контракт проверяется в пакете credential;
// здесь проверяется ПРОВОДКА: тег валидатора и функция контракта отвечают
// одинаково на одних и тех же значениях.
func TestCredentialStrTagDelegatesToContract(t *testing.T) {
for _, candidate := range []string{
"hy2xsadmin",
"admin.ops",
"admin+1",
"admi",
"админ12",
"admin пробел",
strings.Repeat("a", credential.AdminUsernameMaxLength+1),
} {
expected := credential.IsValidAdminUsername(candidate)
if got := validate.Var(candidate, "credentialStr") == nil; got != expected {
t.Errorf("тег credentialStr на %q дал %v, контракт — %v", candidate, got, expected)
}
}
}
for _, rejected := range []string{
"admi", // короче шести символов
strings.Repeat("a", 33), // длиннее тридцати двух
"admin пробел", // пробел
"админ1", // кириллица
"admin\n1", // перевод строки
"admin'1", // апостроф вне набора
// Тот же вопрос про пароль, и он важнее.
//
// Границы пароля стояли прямо в теге (`min=6,max=64`), и тег НЕ МОГ выразить
// предел bcrypt в байтах — правила длины в байтах у go-playground/validator
// нет. Поэтому пароль из 64 кириллических букв проходил валидацию формы и
// падал в хешировании, возвращаясь оператору системной ошибкой. Проверка
// закрепляет, что правило теперь одно и что оно то же самое, которым
// пользуются 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",
} {
if credentialStrPattern.MatchString(rejected) {
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
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,
)
}
}
}
+236
View File
@@ -0,0 +1,236 @@
// 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))
}
// Noncharacters и суррогаты — то, что НЕ ПРИМЕТ systemd.
//
// `EnvironmentFile=` разбирается systemd, и перед тем как принять пару, он
// прогоняет ключ и значение через `utf8_is_valid` (src/basic/env-file.c,
// `check_utf8ness_and_warn`). Отказ там — это `-EINVAL`, то есть НЕ загруженный
// environment file, то есть юнит, который не стартует; предупреждением это не
// является.
//
// `utf8_is_valid` отвергает встроенный NUL и всё, что не является Unicode
// scalar value, а `unichar_is_valid` (src/basic/utf8.c) сверх того отвергает:
//
// U+D800..U+DFFF суррогаты
// U+FDD0..U+FDEF noncharacters
// (cp & 0xFFFE) == 0xFFFE — U+FFFE, U+FFFF, U+1FFFE, … U+10FFFF
//
// Пока контракт этого не знал, пароль вроде `abcde﷐` — шесть символов,
// восемь байт, ни одного управляющего — проходил ВСЕ проверки продукта,
// записывался в /etc/hy2xs/hy2xs.env, и админка после этого не стартовала. Тот
// же класс дефекта, ради уничтожения которого контракт и существует, только на
// один слой ниже.
//
// Публичная документация systemd также запрещает U+FEFF. Реализация v257.13
// случайно пропускает его из-за маски в `unichar_is_valid`; продукт следует
// документированному контракту, а не этому расхождению реализации.
func isUnicodeNoncharacter(r rune) bool {
if r >= 0xFDD0 && r <= 0xFDEF {
return true
}
return r&0xFFFE == 0xFFFE
}
// IsEnvTransportableText сообщает, переживёт ли значение файл окружения.
//
// Это документированный ДОМЕН systemd, а не политика HY2XS. Продуктовые ограничения
// (границы длины, запрет управляющих символов) живут отдельно — иначе
// невозможно объяснить, какое из двух правил нарушено и чем это грозит.
func IsEnvTransportableText(value string) bool {
// Невалидный UTF-8 сюда попадает не только из файла: Go-строка — это
// произвольные байты, и `[]byte` из внешнего источника может оказаться
// чем угодно. Проверка идёт ПЕРВОЙ, потому что `range` по невалидной строке
// молча отдаёт U+FFFD и скрыл бы причину.
if !utf8.ValidString(value) {
return false
}
for _, r := range value {
// NUL — валидный UTF-8, но границей строки в C; systemd отвергает его
// отдельной веткой (`embedded NUL`).
if r == 0 || r == 0xFEFF {
return false
}
if isUnicodeNoncharacter(r) {
return false
}
}
return true
}
// IsValidAdminPassword — единственное правило пароля администратора.
//
// Правило состоит из четырёх частей и НИ ОДНА из них не является набором
// символов:
//
// домен systemd — значение обязано быть загружаемым из EnvironmentFile
// (IsEnvTransportableText);
// 6-64 code points — граница, которую видит оператор;
// <= 72 UTF-8 bytes — граница, которую ставит bcrypt;
// Cc — продуктовая политика, см. hasForbiddenRune.
//
// Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает
// go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её
// видит оператор: «пароль из 64 символов» обязано означать одно и то же для
// латиницы и для кириллицы.
//
// Предел в БАЙТАХ существует одновременно с ним, потому что это ограничение
// другого слоя — хранилища пароля, — и выразить его через символы нельзя: у
// 64 символов длина от 64 до 256 байт в зависимости от алфавита.
//
// Набор символов НЕ ограничивается ничем сверх перечисленного. Пароль назначает
// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, —
// и сервер нигде его набор не проверяет. Ограничение на форме входа не
// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает
// пароль, который сервер принял бы.
//
// Пробелы по краям — ЧАСТЬ пароля и не снимаются нигде: ни здесь, ни в
// нормализации DTO, ни при чтении bootstrap-окружения.
func IsValidAdminPassword(password string) bool {
// Домен транспорта проверяется ПЕРВЫМ: значение вне него не доедет до
// админки вовсе — systemd откажется загружать файл окружения, и юнит не
// стартует. Это отказ более грубого рода, чем нарушение границ длины.
if !IsEnvTransportableText(password) {
return false
}
runes := utf8.RuneCountInString(password)
if runes < AdminPasswordMinLength || runes > AdminPasswordMaxLength {
return false
}
if len(password) > AdminPasswordMaxBytes {
return false
}
return !hasForbiddenRune(password)
}
// hasForbiddenRune — продуктовая политика HY2XS поверх домена systemd.
//
// Запрещены УПРАВЛЯЮЩИЕ символы Unicode целиком, то есть категория Cc:
//
// U+0000..U+001F C0
// U+007F DEL
// U+0080..U+009F C1
//
// Раньше здесь стояли только C0 и DEL, а комментарий обещал «без управляющих
// символов» — то есть документация была шире кода ровно на C1. Проверяется
// теперь то, что обещано.
//
// U+FEFF здесь уже не проверяется: он принадлежит документированному домену
// EnvironmentFile и отвергается IsEnvTransportableText.
func hasForbiddenRune(value string) bool {
for _, r := range value {
if r < 0x20 || (r >= 0x7F && r <= 0x9F) {
return true
}
}
return false
}
+269
View File
@@ -0,0 +1,269 @@
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},
// C1: раньше документация обещала «без управляющих символов», а код
// проверял только C0 и DEL — то есть обещание было шире проверки.
{"C1 NEL", "abcde" + string(rune(0x85)), false},
{"C1 верхняя граница", "abcde" + string(rune(0x9F)), false},
// Домен systemd. Каждое из этих значений — шесть символов, ни одного
// управляющего, укладывается в 72 байта, — и при этом делает
// /etc/hy2xs/hy2xs.env НЕзагружаемым: `check_utf8ness_and_warn`
// отвечает -EINVAL, и юнит hy2xs-admin не стартует.
{"noncharacter U+FDD0", "abcde" + string(rune(0xFDD0)), false},
{"noncharacter U+FDEF", "abcde" + string(rune(0xFDEF)), false},
{"noncharacter U+FFFE", "abcde" + string(rune(0xFFFE)), false},
{"noncharacter U+FFFF", "abcde" + string(rune(0xFFFF)), false},
{"noncharacter U+1FFFF", "abcde" + string(rune(0x1FFFF)), false},
{"noncharacter U+10FFFF", "abcde" + string(rune(0x10FFFF)), false},
{"невалидный UTF-8", "abcde\xff", false},
// Соседи noncharacters приниматься обязаны: правило описывает ровно
// множество systemd, а не «всё подозрительное рядом».
{"U+FDCF рядом с диапазоном", "abcde" + string(rune(0xFDCF)), true},
{"U+FDF0 рядом с диапазоном", "abcde" + string(rune(0xFDF0)), true},
{"U+FFFD (replacement)", "abcde" + string(rune(0xFFFD)), true},
// U+FEFF отвергается документированным транспортным доменом systemd.
{"U+FEFF", "abcde" + string(rune(0xFEFF)), 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 байт принят: байтовая граница не проверяется")
}
}
// Домен транспорта повторяет публично документированное множество systemd.
//
// Шире означало бы собственный запрет сверх публичного контракта; уже — что
// значение проходит все двери продукта вопреки документации EnvironmentFile.
// Второе и было дефектом: контракт знал про управляющие символы и не знал про
// noncharacters.
//
// Границы взяты из `unichar_is_valid` (systemd, src/basic/utf8.c) и проверяются
// вместе с соседями: правило обязано отвергать ровно диапазон, а не окрестность.
func TestEnvTransportDomainMatchesDocumentedSystemdContract(t *testing.T) {
rejected := []rune{
0x0000, 0xFEFF, // NUL и BOM запрещены публичным контрактом
0xFDD0, 0xFDEF, // U+FDD0..U+FDEF
0xFFFE, 0xFFFF,
0x1FFFE, 0x1FFFF,
0x10FFFE, 0x10FFFF,
}
for _, r := range rejected {
if IsEnvTransportableText(string(r)) {
t.Errorf("U+%04X принят вопреки документированному контракту EnvironmentFile", r)
}
}
accepted := []rune{
0x0020, 0x007F, 0x0085, // управляющие формат НЕСЁТ; их запрещает политика продукта
0xFDCF, 0xFDF0, // соседи зарезервированного диапазона
0xFFFD, // replacement character — обычный символ
0x10FFFD, // последний scalar value, не noncharacter
0x1F600, // 😀
}
for _, r := range accepted {
if !IsEnvTransportableText(string(r)) {
t.Errorf("U+%04X отвергнут: правило шире документированного множества systemd", r)
}
}
// Невалидный UTF-8 — не «строка со странными символами», а байты, которые
// systemd отвергает первой же проверкой.
for _, invalid := range []string{"\xff", "\xc3", "abc\xed\xa0\x80"} {
if IsEnvTransportableText(invalid) {
t.Errorf("невалидный UTF-8 %q принят", invalid)
}
}
}
// Продуктовая политика и домен systemd — РАЗНЫЕ множества, и это видно.
//
// Пока они были склеены, невозможно было ни объяснить отказ, ни заметить, что
// одно из двух правил отсутствует.
func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) {
// Управляющие символы формат несёт — их запрещает HY2XS.
for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F} {
password := "abcde" + string(r)
if !IsEnvTransportableText(password) && r != 0x00 {
t.Errorf("U+%04X: ожидалось, что формат его несёт", r)
}
if IsValidAdminPassword(password) {
t.Errorf("U+%04X принят паролем вопреки политике продукта", r)
}
}
if IsEnvTransportableText("abcde" + string(rune(0xFEFF))) {
t.Error("U+FEFF принят вопреки документированному контракту EnvironmentFile")
}
}
// Пароль НЕ триммится нигде, включая сам контракт.
//
// Пока `util.HashPassword` вёл собственную проверку через strings.TrimSpace,
// значение `"abcde "` считалось корректным всеми дверями продукта и не могло
// быть захешировано.
func TestAdminPasswordKeepsSurroundingWhitespace(t *testing.T) {
// Шесть символов, из которых последний — пробел. По длине без пробела это
// пять символов, то есть прежняя проверка отказала бы.
const password = "abcde "
if utf8.RuneCountInString(strings.TrimSpace(password)) >= AdminPasswordMinLength {
t.Fatal("подготовка: значение проходит и после тримминга, проверяется не то")
}
if !IsValidAdminPassword(password) {
t.Error("пароль с краевым пробелом отклонён: пробел объявлен частью значения")
}
}
+104
View File
@@ -0,0 +1,104 @@
package dao
import (
"path/filepath"
"strings"
"testing"
"hy2xs-admin/credential"
"hy2xs-admin/util"
)
// Bootstrap-путь администратора обязан подчиняться ОБЩЕМУ контракту пароля.
//
// Здесь было два расхождения, и оба ломали установку молча.
//
// Первое: `strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Продукт
// объявляет пробел по краям частью пароля и не снимает его ни на форме входа,
// ни при смене, ни в оркестраторе — а здесь снимал. Учётная запись создавалась
// с паролем, отличным от того, который оператор записал в hy2xs.env и которым
// он потом пытался войти.
//
// Второе: контракт не проверялся вовсе, поэтому непригодный для bcrypt пароль
// (64 кириллические буквы = 128 байт) доходил до GenerateFromPassword, и старт
// службы падал с «bcrypt: password length exceeds 72 bytes» — сообщением, по
// которому нельзя понять ни что чинить, ни где.
func startAdminBootstrap(t *testing.T, password string) error {
t.Helper()
t.Setenv("HY2XS_ADMIN_USER", "hy2xsadmin")
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", password)
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
err := InitSqlAt(filepath.Join(t.TempDir(), "hy2xs-admin-test.db"))
t.Cleanup(func() { _ = CloseSqliteDB() })
return err
}
// Пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом.
func TestBootstrapAdminKeepsSurroundingWhitespaceInPassword(t *testing.T) {
const password = "bootstrap-admin-password "
if err := startAdminBootstrap(t, password); err != nil {
t.Fatalf("запуск сервиса не удался: %v", err)
}
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
if err != nil {
t.Fatalf("учётная запись администратора не создана: %v", err)
}
if admin.PasswordHash == nil {
t.Fatal("у созданной учётной записи нет хеша пароля")
}
if !util.VerifyPassword(password, *admin.PasswordHash) {
t.Error("вход исходным значением невозможен: пароль обрезан при создании учётной записи")
}
if util.VerifyPassword(strings.TrimSpace(password), *admin.PasswordHash) {
t.Error("вход обрезанным значением возможен: пробел по краям снят вопреки контракту")
}
}
// Пароль, не проходящий контракт, роняет старт с ПРИЧИНОЙ, а не с ошибкой
// bcrypt, и учётной записи после этого не остаётся.
func TestBootstrapAdminRefusesPasswordOutsideTheContract(t *testing.T) {
// 64 символа — в границе; 128 байт — за пределом bcrypt.
password := strings.Repeat("я", credential.AdminPasswordMaxLength)
if credential.IsValidAdminPassword(password) {
t.Fatal("подготовка: контракт принимает значение, проверяется не то")
}
err := startAdminBootstrap(t, password)
if err == nil {
t.Fatal("старт с непригодным паролем завершился успешно")
}
if !strings.Contains(err.Error(), "HY2XS_ADMIN_INITIAL_PASSWORD") {
t.Errorf("отказ не называет переменную, которую надо чинить: %v", err)
}
if !strings.Contains(err.Error(), "hy2xs.env") {
t.Errorf("отказ не называет файл, в котором чинится причина: %v", err)
}
}
// Пароль ровно в предел bcrypt (72 байта) — законное значение, и установка с
// ним обязана проходить целиком.
func TestBootstrapAdminAcceptsPasswordAtBcryptByteLimit(t *testing.T) {
password := strings.Repeat("я", 36)
if len(password) != credential.AdminPasswordMaxBytes {
t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes)
}
if err := startAdminBootstrap(t, password); err != nil {
t.Fatalf("установка с паролем в 72 байта не удалась: %v", err)
}
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
if err != nil {
t.Fatalf("учётная запись администратора не создана: %v", err)
}
if !util.VerifyPassword(password, *admin.PasswordHash) {
t.Error("пароль в 72 байта не проходит проверку собственным хешем")
}
}
+33 -1
View File
@@ -3,11 +3,13 @@ package dao
import (
"encoding/base64"
"errors"
"fmt"
"github.com/glebarez/sqlite"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"hy2xs-admin/credential"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"hy2xs-admin/util"
@@ -132,7 +134,14 @@ func ensureSecureBootstrapAdmin() error {
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 == "" {
return errors.New(
"HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" +
@@ -144,6 +153,28 @@ func ensureSecureBootstrapAdmin() error {
"`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, без управляющих символов и U+FEFF.\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)
status := int64(1)
tokenVersion := int64(1)
@@ -709,6 +740,7 @@ func tableExists(tableName string) bool {
}
return count > 0
}
// ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ
// придумывает его сам.
//
+7 -1
View File
@@ -19,5 +19,11 @@ Frontend собирается production builder'ом из [`tools/build`](../..
```sh
pnpm install --frozen-lockfile
pnpm run build:prod
bun test test/i18n-runtime.test.ts
pnpm run verify
```
Runtime-тест обходит все строковые сообщения русского и английского словарей
через установленный `vue-i18n`. Он проверяет не только исключения, но и
compiler diagnostics в `console.error`: некоторые версии компилятора сообщают
ошибку формата без исключения из `t()`.
+1 -12
View File
@@ -28,7 +28,7 @@
"qrcode.vue": "3.4.1",
"vue": "^3.5.42",
"vue-echarts": "^8.1.0",
"vue-i18n": "^9.14.5",
"vue-i18n": "^11.4.10",
"vue-router": "^4.6.4"
},
"devDependencies": {
@@ -65,17 +65,6 @@
"vue-eslint-parser": "10.4.1",
"vue-tsc": "^3.3.11"
},
"pnpm": {
"overrides": {
"lodash": ">=4.18.1",
"lodash-es": ">=4.18.1",
"brace-expansion": ">=1.1.18",
"picomatch": ">=2.3.2",
"js-yaml": ">=4.3.1",
"table>ajv": ">=8.18.0",
"browserslist": "4.28.7"
}
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
+45 -38
View File
@@ -4,15 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
lodash: '>=4.18.1'
lodash-es: '>=4.18.1'
brace-expansion: '>=1.1.18'
picomatch: '>=2.3.2'
js-yaml: '>=4.3.1'
table>ajv: '>=8.18.0'
browserslist: 4.28.7
importers:
.:
@@ -54,8 +45,8 @@ importers:
specifier: ^8.1.0
version: 8.1.0(echarts@6.1.0)(vue@3.5.42(typescript@5.9.3))
vue-i18n:
specifier: ^9.14.5
version: 9.14.5(vue@3.5.42(typescript@5.9.3))
specifier: ^11.4.10
version: 11.4.10(vue@3.5.42(typescript@5.9.3))
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.42(typescript@5.9.3))
@@ -492,17 +483,21 @@ packages:
'@iconify/utils@3.1.4':
resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
'@intlify/core-base@9.14.5':
resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
engines: {node: '>= 16'}
'@intlify/core-base@11.4.10':
resolution: {integrity: sha512-+yJ74JRWVJokdgG9zYNMyTSzeNV3O9T4vVxk8PvLFHmI+R/BYA//cITh7vhRK37hWLZ4/kTcKcUz1dlWOpypIg==}
engines: {node: '>= 22'}
'@intlify/message-compiler@9.14.5':
resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
engines: {node: '>= 16'}
'@intlify/devtools-types@11.4.10':
resolution: {integrity: sha512-xZxzZsAuu6/0zoLRVQWdpXWe5Kjl0LnWpjlQA3r9u9FbLYMhapqt7IwkgQyn0Tm2GUNAqhj9eZiUmYOrB024BQ==}
engines: {node: '>= 22'}
'@intlify/shared@9.14.5':
resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
engines: {node: '>= 16'}
'@intlify/message-compiler@11.4.10':
resolution: {integrity: sha512-oUB/scz2EJENXDiUJ7JjZffOrH8UIZ1BuZeHvonbi5fWLavLt04aivuk2OIByOZA0tsci1bkeeQRmwhb5M8Imw==}
engines: {node: '>= 22'}
'@intlify/shared@11.4.10':
resolution: {integrity: sha512-FeImVdPeoSHTm3NBFFZHv0eRP9gQ3F4lj2puDBX5Kw7iiM1uJW6JTf39ian0K/17pbXCI3ef5i9RVsRrALqI6Q==}
engines: {node: '>= 22'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1525,7 +1520,7 @@ packages:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: '>=2.3.2'
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
@@ -1789,8 +1784,8 @@ packages:
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
peerDependencies:
'@types/lodash-es': '*'
lodash: '>=4.18.1'
lodash-es: '>=4.18.1'
lodash: '*'
lodash-es: '*'
lodash.truncate@4.4.2:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
@@ -1957,6 +1952,10 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
@@ -2406,7 +2405,7 @@ packages:
resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
hasBin: true
peerDependencies:
browserslist: 4.28.7
browserslist: '>= 4.21.0'
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -2483,10 +2482,9 @@ packages:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
vue-i18n@9.14.5:
resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
engines: {node: '>= 16'}
deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html
vue-i18n@11.4.10:
resolution: {integrity: sha512-Lp+BjOxqzOY87DS6Z8KrQrpiTr9IN/Lt4kZEilwyXG2Wrx+AcU6IVsAW92HNXtVcn1HFFPV6ty41p9e/qDpyvg==}
engines: {node: '>= 22'}
peerDependencies:
vue: ^3.0.0
@@ -2782,17 +2780,23 @@ snapshots:
'@iconify/types': 2.0.0
import-meta-resolve: 4.2.0
'@intlify/core-base@9.14.5':
'@intlify/core-base@11.4.10':
dependencies:
'@intlify/message-compiler': 9.14.5
'@intlify/shared': 9.14.5
'@intlify/devtools-types': 11.4.10
'@intlify/message-compiler': 11.4.10
'@intlify/shared': 11.4.10
'@intlify/message-compiler@9.14.5':
'@intlify/devtools-types@11.4.10':
dependencies:
'@intlify/shared': 9.14.5
'@intlify/core-base': 11.4.10
'@intlify/shared': 11.4.10
'@intlify/message-compiler@11.4.10':
dependencies:
'@intlify/shared': 11.4.10
source-map-js: 1.2.1
'@intlify/shared@9.14.5': {}
'@intlify/shared@11.4.10': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
@@ -4113,7 +4117,7 @@ snapshots:
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 4.0.7
picomatch: 2.3.2
mime-db@1.52.0: {}
@@ -4245,6 +4249,8 @@ snapshots:
picocolors@1.1.1: {}
picomatch@2.3.2: {}
picomatch@4.0.7: {}
pinia@2.3.1(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)):
@@ -4798,10 +4804,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-i18n@9.14.5(vue@3.5.42(typescript@5.9.3)):
vue-i18n@11.4.10(vue@3.5.42(typescript@5.9.3)):
dependencies:
'@intlify/core-base': 9.14.5
'@intlify/shared': 9.14.5
'@intlify/core-base': 11.4.10
'@intlify/devtools-types': 11.4.10
'@intlify/shared': 11.4.10
'@vue/devtools-api': 6.6.4
vue: 3.5.42(typescript@5.9.3)
+13
View File
@@ -0,0 +1,13 @@
# Настройки resolution принадлежат pnpm, а не npm package manifest.
# Единственный workspace-проект — сам frontend.
packages:
- "."
overrides:
lodash: ">=4.18.1"
lodash-es: ">=4.18.1"
brace-expansion: ">=1.1.18"
picomatch: ">=2.3.2"
js-yaml: ">=4.3.1"
"table>ajv": ">=8.18.0"
browserslist: "4.28.7"
+171
View File
@@ -0,0 +1,171 @@
/**
* Контракт учётных данных администратора на стороне панели.
*
* Зачем этот модуль существует. Правила логина и пароля жили прямо в двух
* формах и разошлись и с сервером, и друг с другом:
*
* форма входа логин 6-32 + набор символов, пароль 6-32 + набор
* форма смены пароля пароль 6-64 + набор
* сервер логин 6-32 + набор символов, пароль 6-64 без набора
*
* Следствий было два, и оба закрывали панель. Пароль, назначенный штатной
* формой смены, мог оказаться длиннее 32 символов — и форма входа отказывалась
* его отправлять: оператор терял доступ после операции, которую панель ему же и
* предложила. А набор символов на пароле отвергал значение, которое сервер
* принял бы, — панель была строже сервера там, где она не имеет на это права.
*
* Правило теперь одно на обе формы, и оно сверяется с Go-контрактом
* (apps/credential/admin.go) тестом tools/test/frontend-contract.test.ts.
*/
export const ADMIN_USERNAME_MIN_LENGTH = 6;
export const ADMIN_USERNAME_MAX_LENGTH = 32;
/**
* Набор символов логина в записи регекспа.
*
* Дефис ЭКРАНИРОВАН намеренно. В прежней записи `[a-zA-Z0-9!@#$%^&*()_+-=]` он
* экранирован не был, поэтому `+-=` образовывал ДИАПАЗОН и молча впускал
* `, - . / 0-9 : ; < =`. Действующий набор совпадает с этим фактическим
* множеством — сужать его нельзя, оно уже работает на установленных
* серверах, — но записан явно: пока он выглядел опечаткой, любая попытка
* «навести порядок» развела бы панель и сервер обратно.
*/
const ADMIN_USERNAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+,\\-./:;<=";
export const ADMIN_USERNAME_PATTERN = new RegExp(
`^[${ADMIN_USERNAME_CHARACTER_CLASS}]{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}}$`
);
/** Тот же набор в том виде, в каком его показывают оператору. */
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
/**
* Границы пароля в СИМВОЛАХ Unicode. Набора символов у пароля НЕТ: сервер его
* не ограничивает ни при установке, ни при смене, и панель не имеет права
* отвергать значение, которое сервер принял бы.
*/
export const ADMIN_PASSWORD_MIN_LENGTH = 6;
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.
*
* Ограничений НАБОРА в контракте два, и они разного происхождения.
*
* Первое — домен systemd. Первый пароль администратора уезжает в
* `/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и
* перед принятием пары прогоняет значение через `utf8_is_valid`. Отказ там —
* это не предупреждение, а незагруженный файл окружения и, следовательно,
* юнит, который не стартует. Публичный контракт отвергает NUL, U+FEFF,
* суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`. Реализация
* v257.13 случайно пропускает U+FEFF; панель следует документации. Иначе
* она предложила бы оператору назначить пароль, после которого сервер
* перестанет подниматься.
*
* Одиночные суррогаты проверяются отдельно и по своей причине: строка
* JavaScript вправе их содержать, а `TextEncoder` молча заменит непарный
* суррогат на U+FFFD — то есть без проверки на сервер уехал бы ДРУГОЙ пароль, а
* не отказ.
*
* Второе — продуктовая политика: управляющие символы Unicode целиком (Cc — C0,
* DEL, C1). U+FEFF запрещён документированным транспортным доменом. Ни один из них
* невозможно ни увидеть в поле ввода, ни повторить при следующем входе, то есть
* они умеют ровно одно — запереть оператора снаружи.
*
* Проверяются они перебором кодов, а не классом регекспа: литеральные
* управляющие символы в исходнике невидимы, и такое правило нельзя проверить
* глазами при ревью.
*/
export function isValidAdminPassword(value: string): boolean {
let characters = 0;
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
// Домен systemd: значение вне него не доедет до сервера вовсе.
if (
code === 0 ||
code === 0xfeff ||
(code >= 0xd800 && code <= 0xdfff) ||
(code >= 0xfdd0 && code <= 0xfdef) ||
(code & 0xfffe) === 0xfffe
) {
return false;
}
// Политика HY2XS: Cc. U+FEFF уже отвергнут транспортной веткой.
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
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[],
};
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Контракт имени пира на стороне панели.
*
* Сервер остаётся конечным авторитетом: эти значения зеркалят константы из
* apps/service/peer_import.go и сверяются с ними release-тестом. Общий модуль
* не даёт форме, сообщению об ошибке и подсказке завести разные копии правила.
*/
export const PEER_NAME_MIN_LENGTH = 6;
export const PEER_NAME_MAX_LENGTH = 32;
/** Набор знаков без букв и цифр — передаётся в i18n как значение. */
export const PEER_NAME_PUNCTUATION = "!@#$%^&*()_+-=";
/** Полный набор в том виде, в каком его показывают оператору. */
export const PEER_NAME_CHARSET = `a-z A-Z 0-9 ${PEER_NAME_PUNCTUATION}`;
/**
* Набор символов в записи регулярного выражения.
*
* Дефис экранирован намеренно: неэкранированное `+-=` становится диапазоном и
* принимает лишние знаки. Числовые границы берутся из тех же констант, что и
* тексты формы.
*/
const PEER_NAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+\\-=";
export const PEER_NAME_PATTERN = new RegExp(
`^[${PEER_NAME_CHARACTER_CLASS}]{${PEER_NAME_MIN_LENGTH},${PEER_NAME_MAX_LENGTH}}$`
);
+13 -5
View File
@@ -21,8 +21,13 @@ export default {
password: "Password",
login: "Login",
capsLockOn: "Caps lock is On",
usernameFormatIncorrect: "Username format is incorrect",
passwordFormatIncorrect: "Password format is incorrect",
},
// Требования к учётным данным администратора: общие для формы входа и формы
// смены пароля. См. комментарий в ru.ts.
credentials: {
usernameFormat: "Username: {min} to {max} characters from {charset}",
passwordFormat:
"Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF",
},
dashboard: {
stale: "Dashboard data is stale. Retrying automatically...",
@@ -55,7 +60,8 @@ export default {
trafficApiUnreachable: "Unreachable",
security: {
hysteriaStopped: "Hysteria2 is stopped",
hysteriaStateUnknown: "Hysteria2 service state is unknown: systemd did not answer",
hysteriaStateUnknown:
"Hysteria2 service state is unknown: systemd did not answer",
trafficApiUnavailable: "Traffic API is unavailable",
},
error: {
@@ -161,7 +167,9 @@ export default {
gt: "“{field}”: must be greater than {gt}",
peer_name:
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
credential_format: "“{field}”: contains characters that are not allowed",
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 or U+FEFF",
rule_violated: "“{field}”: value is not acceptable",
validation_failed: "Validation failed",
body_invalid:
@@ -210,7 +218,7 @@ export default {
// The character set is stated as service.IsValidPeerName accepts it. The
// previous wording described the rule that was in force before EX-03.
nameHint:
"Peer identifier: 6 to 32 characters — latin letters, digits and !@#$%^&*()_+-=. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
"Peer identifier: {min} to {max} characters — latin letters, digits and {punctuation}. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
remarkPlaceholder: "laptop",
remarkHint: "Optional operator note. It is never shown to the client.",
secretPlaceholder: "leave empty to generate one",
+26 -4
View File
@@ -19,8 +19,24 @@ export default {
password: "Пароль",
login: "Войти",
capsLockOn: "Caps Lock включён",
usernameFormatIncorrect: "Неверный формат логина",
passwordFormatIncorrect: "Неверный формат пароля",
},
// Требования к учётным данным администратора. Фразы общие для формы входа и
// формы смены пароля: требование одно, и второй его формулировки быть не
// должно — расхождение здесь означало бы, что оператору обещают разное про
// одно и то же поле.
//
// Обе фразы НАЗЫВАЮТ требование, а не сообщают о его нарушении. Прежние
// «Неверный формат логина» и «Некорректное значение» не давали оператору ни
// одного способа узнать, что именно от него хотят: набор символов логина
// приходит из hy2xs.env, и посмотреть его в панели негде.
credentials: {
usernameFormat: "Логин: от {min} до {max} символов из набора {charset}",
// Фраза называет ОБЕ границы, потому что их две и они в разных единицах.
// Пароль из 40 эмодзи укладывается в 64 символа и не укладывается в 72
// байта; сообщение «не длиннее {max} символов» отправило бы оператора
// сокращать пароль, отвергнутый не за это.
passwordFormat:
"Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
},
dashboard: {
stale:
@@ -167,7 +183,13 @@ export default {
gt: "«{field}»: значение должно быть больше {gt}",
peer_name:
"«{field}»: от {min} до {max} символов из набора {charset}. Пробелы, кириллица и знаки / : ; . недопустимы",
credential_format: "«{field}»: недопустимые символы",
// Сервер присылает границы и набор в params — фраза называет требование
// целиком. Прежнее «недопустимые символы» вдобавок описывало этими же
// словами отказ по ДЛИНЕ: правило одно, и оно проверяет и то, и другое.
credential_format:
"«{field}»: от {min} до {max} символов из набора {charset}",
admin_password_format:
"«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
rule_violated: "«{field}»: значение не подходит",
validation_failed: "Проверка данных не пройдена",
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
@@ -218,7 +240,7 @@ export default {
// действовавшего до EX-03: подсказка обещала более узкий набор, чем
// сервер принимает, и оператор не имел причин пробовать остальные.
nameHint:
"Идентификатор пира: от 6 до 32 символов — латиница, цифры и знаки !@#$%^&*()_+-=. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
"Идентификатор пира: от {min} до {max} символов — латиница, цифры и знаки {punctuation}. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: "ноутбук",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем",
+5
View File
@@ -44,6 +44,11 @@ export const ERR_CODE = {
ruleViolated: "rule_violated",
peerName: "peer_name",
credentialFormat: "credential_format",
// Правило пароля администратора целиком: длина в символах, длина в байтах
// (предел bcrypt) и запрет управляющих символов. Отдельный код нужен потому,
// что min_length/max_length не умеют назвать отказ по БАЙТАМ, а он бывает у
// пароля, укладывающегося в границу символов.
adminPasswordFormat: "admin_password_format",
peerNameTaken: "peer_name_taken",
peerNameReserved: "peer_name_reserved",
peerBootstrapLocked: "peer_bootstrap_identity_locked",
@@ -33,6 +33,12 @@ import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { adminChangePasswordApi } from "@/api/admin";
import { useAdminStore } from "@/store/modules/admin";
import {
ADMIN_PASSWORD_MAX_BYTES,
ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH,
adminPasswordFormRule,
} from "@/constants/credentials";
const { t } = useI18n();
const route = useRoute();
@@ -46,7 +52,33 @@ const form = reactive({
newPassword: "",
});
const passwordPattern = /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,64}$/;
// Проверяется ТОЛЬКО длина, и она берётся из общего контракта.
//
// Здесь стояло правило набора символов `[a-zA-Z0-9!@#$%^&*()_+-=]`, которого
// сервер не предъявляет ни при смене пароля, ни при установке. То есть панель
// отказывала оператору в пароле, который сервер принял бы, и сообщала об этом
// фразой «Некорректное значение», не называя ни одного требования.
//
// Границы совпадают с формой входа не случайно: пока они расходились, длинный
// пароль, назначенный здесь, невозможно было ввести там.
//
// Правило — собственное, а не встроенные `min`/`max` Element Plus: те
// делегируются `async-validator`, который считает длину строки в единицах
// UTF-16, тогда как сервер считает code points и вдобавок ограничивает длину в
// БАЙТАХ пределом bcrypt. Пароль из 64 кириллических букв проходил здесь и
// возвращался с сервера системной ошибкой хеширования.
//
// Комментарий записан строчными `//`, а не блоком: скан релизных гейтов
// отбрасывает только их, и объяснение, называющее убранную конструкцию по
// имени, иначе роняет проверку «этой конструкции здесь больше нет».
const passwordRule = adminPasswordFormRule(
t("credentials.passwordFormat", {
min: ADMIN_PASSWORD_MIN_LENGTH,
max: ADMIN_PASSWORD_MAX_LENGTH,
maxBytes: ADMIN_PASSWORD_MAX_BYTES,
})
);
const rules: FormRules = {
oldPassword: [
{
@@ -54,11 +86,7 @@ const rules: FormRules = {
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: passwordPattern,
message: t("common.invalid"),
trigger: ["change", "blur"],
},
{ ...passwordRule },
],
newPassword: [
{
@@ -66,11 +94,7 @@ const rules: FormRules = {
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: passwordPattern,
message: t("common.invalid"),
trigger: ["change", "blur"],
},
{ ...passwordRule },
],
};
+6 -4
View File
@@ -98,7 +98,9 @@
<el-col :xs="24" :sm="12" :lg="6"
><el-card shadow="never"
>{{ $t("dashboard.onlinePeers") }}:
{{ summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?" }}</el-card
{{
summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?"
}}</el-card
></el-col
>
<el-col :xs="24" :sm="12" :lg="6"
@@ -128,9 +130,9 @@
<div class="chart-header">
<span>{{ $t("dashboard.trafficChart") }}</span>
<el-radio-group v-model="range" size="small" @change="loadDashboard">
<el-radio-button label="1h">1h</el-radio-button>
<el-radio-button label="24h">24h</el-radio-button>
<el-radio-button label="7d">7d</el-radio-button>
<el-radio-button value="1h">1h</el-radio-button>
<el-radio-button value="24h">24h</el-radio-button>
<el-radio-button value="7d">7d</el-radio-button>
</el-radio-group>
</div>
</template>
+89 -7
View File
@@ -83,6 +83,16 @@ import { useAdminStore } from "@/store/modules/admin";
// Зависимость API
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
import { AdminLoginDto } from "@/api/admin/types";
import {
ADMIN_PASSWORD_MAX_BYTES,
ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH,
ADMIN_USERNAME_CHARSET,
ADMIN_USERNAME_MAX_LENGTH,
ADMIN_USERNAME_MIN_LENGTH,
ADMIN_USERNAME_PATTERN,
adminPasswordFormRule,
} from "@/constants/credentials";
const adminStore = useAdminStore();
const route = useRoute();
@@ -114,6 +124,21 @@ const loginForm = ref<AdminLoginDto>({
pass: "",
});
/**
* Правила формы входа берутся из общего контракта, а не пишутся здесь.
*
* У пароля проверяется ТОЛЬКО длина. Прежнее правило требовало ещё и набор
* символов, из-за чего форма входа отказывалась отправлять пароль, который
* сервер принимает: набор пароля сервер не ограничивает нигде. Проверка,
* которая умеет только запереть оператора и ничего не защищает, — не проверка.
*
* Правило пароля — собственное, а не встроенные `min`/`max` Element Plus.
* Element Plus делегирует правила формы библиотеке `async-validator`, а она
* сравнивает `min`/`max` строки с `String.prototype.length`, то есть считает
* единицы UTF-16, тогда как сервер считает code points. Пароль из трёх эмодзи
* имел `value.length === 6` и проходил минимум формы, а сервер видел три руны и
* отказывал. См. isValidAdminPassword.
*/
const loginRules = {
username: [
{
@@ -122,8 +147,12 @@ const loginRules = {
trigger: ["change", "blur"],
},
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: t("login.usernameFormatIncorrect"),
pattern: ADMIN_USERNAME_PATTERN,
message: t("credentials.usernameFormat", {
min: ADMIN_USERNAME_MIN_LENGTH,
max: ADMIN_USERNAME_MAX_LENGTH,
charset: ADMIN_USERNAME_CHARSET,
}),
trigger: ["change", "blur"],
},
],
@@ -133,11 +162,13 @@ const loginRules = {
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: t("login.passwordFormatIncorrect"),
trigger: ["change", "blur"],
},
adminPasswordFormRule(
t("credentials.passwordFormat", {
min: ADMIN_PASSWORD_MIN_LENGTH,
max: ADMIN_PASSWORD_MAX_LENGTH,
maxBytes: ADMIN_PASSWORD_MAX_BYTES,
})
),
],
};
@@ -205,10 +236,61 @@ const handleLogin = () => {
}
}
// Видимое поле формы входа — это `el-form-item`, а не `el-input`.
//
// Рамка и фон нарисованы здесь, потому что внутрь одного поля кладутся три
// вещи: иконка, ввод и переключатель видимости пароля. `el-input` занимает лишь
// среднюю из них.
//
// Отсюда и дефект индикации ошибки, который был виден на форме. Element Plus
// рисует состояние отказа на `el-input__wrapper` правилом
//
// .el-form-item.is-error .el-form-item__content .el-input__wrapper
//
// то есть селектором из ЧЕТЫРЁХ классов, а здешнее гашение тени записывалось
// селектором из трёх — и проигрывало по специфичности. В результате красная
// рамка ложилась вокруг одного лишь поля ввода: у логина она начиналась после
// иконки пользователя, у пароля обрывалась перед «глазом», и ни одна её сторона
// не совпадала с видимой границей поля.
//
// Чинится это не увеличением специфичности ради победы, а переносом индикации
// на тот элемент, который оператор и видит полем.
.el-form-item {
background: var(--subMenuBg);
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 5px;
// Просвет под полем принадлежит сообщению об отказе: `el-form-item__error`
// позиционируется абсолютно от `top: 100%`, то есть живёт ВНЕ рамки. При
// стандартных 18px оно вплотную прижималось к границе снизу и к следующему
// полю сверху.
margin-bottom: 26px;
&.is-error {
border-color: var(--el-color-danger);
// Штатная индикация Element Plus гасится ЗДЕСЬ, а не в блоке `.el-input`:
// селектор повторяет её собственный и добавляет атрибут scoped-стиля,
// поэтому выигрывает по специфичности. Прежнее гашение стояло на два
// класса ниже и проигрывало — из-за чего красный прямоугольник вокруг
// одного лишь поля ввода и появлялся. `!important` здесь не нужен: правило
// не сильнее чужого, а конкретнее.
:deep(.el-form-item__content .el-input__wrapper) {
&,
&:hover,
&:focus,
&.is-focus {
box-shadow: none;
}
}
}
// Сообщение выравнивается по тексту поля, а не по краю рамки: иначе оно
// висит на сдвиг левее всего, что находится над ним.
:deep(.el-form-item__error) {
padding-top: 6px;
padding-left: 12px;
}
}
.el-input {
+19 -8
View File
@@ -247,7 +247,15 @@
v-model="dataForm.name"
:placeholder="$t('peer.form.namePlaceholder')"
/>
<div class="form-hint">{{ $t("peer.form.nameHint") }}</div>
<div class="form-hint">
{{
$t("peer.form.nameHint", {
min: PEER_NAME_MIN_LENGTH,
max: PEER_NAME_MAX_LENGTH,
punctuation: PEER_NAME_PUNCTUATION,
})
}}
</div>
</el-form-item>
<el-form-item :label="$t('peer.remark')" :error="serverErrors.remark">
<el-input
@@ -445,6 +453,13 @@ import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
import { ERR_CODE, isApiError } from "@/utils/api-error";
import { describeApiError, fieldErrorMap } from "@/utils/api-message";
import { saveResponseAsFile } from "@/utils/download";
import {
PEER_NAME_CHARSET,
PEER_NAME_MAX_LENGTH,
PEER_NAME_MIN_LENGTH,
PEER_NAME_PATTERN,
PEER_NAME_PUNCTUATION,
} from "@/constants/peer";
/**
* Ограничение ширины всплывающих подсказок.
@@ -582,10 +597,6 @@ watch(
}
);
// Зеркало серверного контракта, а не второй его экземпляр: границы и набор
// символов заданы в service.IsValidPeerName и dto.PeerSaveDto, и расхождение
// здесь приводит лишь к лишнему запросу, а не к принятому некорректному пиру.
const PEER_NAME_PATTERN = /^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$/;
const SECRET_MIN_LENGTH = 6;
const SECRET_MAX_LENGTH = 128;
@@ -600,9 +611,9 @@ const rules = {
pattern: PEER_NAME_PATTERN,
message: t("error.code.peer_name", {
field: t("error.field.name"),
min: 6,
max: 32,
charset: "a-z A-Z 0-9 !@#$%^&*()_+-=",
min: PEER_NAME_MIN_LENGTH,
max: PEER_NAME_MAX_LENGTH,
charset: PEER_NAME_CHARSET,
}),
trigger: ["change", "blur"],
},
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, test } from "bun:test";
import { createI18n } from "vue-i18n";
import {
PEER_NAME_MAX_LENGTH,
PEER_NAME_MIN_LENGTH,
PEER_NAME_PUNCTUATION,
} from "../src/constants/peer";
import en from "../src/lang/package/en";
import ru from "../src/lang/package/ru";
type Dictionary = Record<string, unknown>;
function leafMessages(
value: unknown,
prefix = ""
): Array<[key: string, message: string]> {
if (typeof value === "string") {
return [[prefix, value]];
}
if (!value || typeof value !== "object") {
return [];
}
return Object.entries(value as Dictionary).flatMap(([key, child]) =>
leafMessages(child, prefix ? `${prefix}.${key}` : key)
);
}
function interpolationValues(message: string): Record<string, string | number> {
const values: Record<string, string | number> = {
count: 2,
n: 2,
min: PEER_NAME_MIN_LENGTH,
max: PEER_NAME_MAX_LENGTH,
punctuation: PEER_NAME_PUNCTUATION,
};
for (const match of message.matchAll(/\{([A-Za-z_][A-Za-z0-9_$-]*)\}/g)) {
values[match[1]] ??= match[1];
}
return values;
}
for (const [locale, dictionary] of Object.entries({ ru, en }) as Array<
["ru" | "en", Dictionary]
>) {
describe(`runtime-компиляция локализации ${locale}`, () => {
test("каждое строковое сообщение компилируется без diagnostics", () => {
const compilerDiagnostics: string[] = [];
const thrown: string[] = [];
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
compilerDiagnostics.push(args.map(String).join(" "));
};
try {
const i18n = createI18n({
legacy: false,
locale,
fallbackLocale: false,
missingWarn: false,
fallbackWarn: false,
messages: { [locale]: dictionary },
});
for (const [key, message] of leafMessages(dictionary)) {
try {
const translated = i18n.global.t(key, interpolationValues(message));
if (typeof translated !== "string") {
thrown.push(`${key}: результат не является строкой`);
}
} catch (error) {
thrown.push(
`${key}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
} finally {
console.error = originalConsoleError;
}
expect(thrown).toEqual([]);
expect(compilerDiagnostics).toEqual([]);
});
test("набор знаков имени пира проходит интерполяцию как данные", () => {
const i18n = createI18n({
legacy: false,
locale,
fallbackLocale: false,
messages: { [locale]: dictionary },
});
const template = (dictionary as any).peer.form.nameHint as string;
expect(
i18n.global.t("peer.form.nameHint", {
min: PEER_NAME_MIN_LENGTH,
max: PEER_NAME_MAX_LENGTH,
punctuation: PEER_NAME_PUNCTUATION,
})
).toBe(
template
.replace("{min}", String(PEER_NAME_MIN_LENGTH))
.replace("{max}", String(PEER_NAME_MAX_LENGTH))
.replace("{punctuation}", PEER_NAME_PUNCTUATION)
);
});
});
}
+14 -5
View File
@@ -43,15 +43,24 @@ const (
// валидатора у них один. Оператору это разные фразы: «не меньше 1
// устройства» и «не короче 6 символов», — и панель обязана уметь их
// различить, не заводя у себя таблицу «какое поле какого рода».
ErrCodeMin string = "min"
ErrCodeMax string = "max"
ErrCodeMinLength string = "min_length"
ErrCodeMaxLength string = "max_length"
ErrCodeLen string = "len"
ErrCodeMin string = "min"
ErrCodeMax string = "max"
ErrCodeMinLength string = "min_length"
ErrCodeMaxLength string = "max_length"
ErrCodeLen string = "len"
ErrCodeOneOf string = "oneof"
ErrCodeGreaterThan string = "gt"
ErrCodePeerName string = "peer_name"
ErrCodeCredentialStr string = "credential_format"
// ErrCodeAdminPassword — пароль администратора не удовлетворяет контракту.
//
// Отдельный код, а не пара min_length/max_length, потому что правило одно и
// проверяет три вещи сразу: длину в символах, длину в байтах (предел
// bcrypt) и отсутствие управляющих символов. Пока границы стояли двумя
// тегами, отказ по байтам было нечем назвать — его просто не существовало,
// и пароль из 64 кириллических букв доходил до хеширования, где становился
// системной ошибкой.
ErrCodeAdminPassword string = "admin_password_format"
ErrCodeRuleUnknown string = "rule_violated"
// Доменные коды: правило соблюдено, но операция всё равно невозможна.
+33 -2
View File
@@ -1,6 +1,37 @@
package dto
// Правило пароля здесь и в LoginDto обязано быть ОДНИМ И ТЕМ ЖЕ.
//
// Пока границы расходились, панель запирала оператора снаружи после операции,
// которую сама же и предлагала: форма смены пароля принимала 64 символа, а
// форма входа — 32, и назначенный длинный пароль становился паролем, с которым
// невозможно войти.
//
// Числа из тегов ушли внутрь правила `adminPassword`. Записанные тегом
// `min=6,max=64` они, кроме дублирования, были ещё и неполны: тег считает
// символы Unicode, а bcrypt — байты, поэтому новый пароль из 64 кириллических
// букв проходил проверку формы, доходил до util.HashPassword и возвращался
// оператору системной ошибкой. Отказ по байтам тегом валидатора выразить
// нечем — правила длины в байтах у него нет.
//
// Набора символов здесь нет и не было: пароль принадлежит оператору целиком.
type AdminChangePasswordDto struct {
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,min=6,max=64"`
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,min=6,max=64"`
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,adminPassword"`
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)
}
+52 -2
View File
@@ -1,6 +1,56 @@
package dto
// Контракт формы входа.
//
// Раньше на Username стояло `required,min=6,max=32,validateStr`, и правила
// `validateStr` не существовало: оно пережило переименование в `credentialStr`
// и осталось ссылкой на незарегистрированную функцию. go-playground/validator в
// таком случае ПАНИКУЕТ, поэтому каждый POST /api/auth/login заканчивался
// паникой, которую gin.Recovery превращал в HTTP 500 — панель после чистой
// установки была недоступна целиком. Ни один тест этого не видел: проверялся
// сам регексп, а не LoginDto через production-валидатор, и цепочка правил на
// нулевом DTO обрывается на `required` раньше, чем доходит до испорченного
// тега.
//
// Границы длины логина живут ВНУТРИ правила `credentialStr`, а не рядом с ним.
// Два правила на одном поле уже приводили к отказу, который невозможно
// объяснить: имя пира с `min=1,max=32,validateStr` принималось одним правилом
// и отвергалось другим, а оператор видел «invalid». Здесь та же конструкция
// была бы той же ошибкой.
//
// У пароля границы длины живут внутри правила `adminPassword` по той же
// причине. Здесь стояло `min=6,max=64`, и эта запись была не только вторым
// правилом на одном поле, но и НЕПОЛНОЙ: `max` считает символы Unicode, а
// bcrypt — байты, поэтому 64 кириллические буквы (128 байт) проходили проверку
// и падали в хешировании. Границу в байтах тегом валидатора не выразить.
//
// Набора символов у пароля НЕТ, и это осознанно: сервер нигде его не
// ограничивает — ни при установке, ни при смене пароля. Ограничение на входе
// не защищало бы ничего (значение всё равно сравнивается с хешем) и умело
// только запирать оператора с паролем, который сервер принял бы. Единственное
// исключение — управляющие символы, и оно принадлежит контракту, а не форме:
// см. credential.IsValidAdminPassword.
type LoginDto struct {
Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"`
Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=64"`
Username *string `json:"username" form:"username" validate:"required,credentialStr"`
Pass *string `json:"pass" form:"pass" validate:"required,adminPassword"`
}
// Normalize: незаполненное поле обязано выглядеть незаполненным.
//
// У поля-указателя `required` считает значение заданным по ненулевому
// указателю, поэтому `{"username":"","pass":""}` проходило `required` целиком и
// получало отказ по набору символов и по длине — то есть оператор, отправивший
// пустую форму, читал про недопустимые символы вместо «поле обязательно».
//
// Логин при этом ещё и триммится: скопированное из bootstrap-admin.secret
// значение регулярно приезжает с пробелом или переводом строки на конце, и
// раньше это давало «неверный логин или пароль» — отказ, который невозможно
// связать с причиной.
//
// Пароль НЕ триммится. Набор его символов не ограничен, пробел по краям —
// законная часть значения, и молчаливое его удаление означало бы, что панель
// отправляет не то, что ввёл оператор.
func (d *LoginDto) Normalize() {
blankToNil(&d.Username)
emptyToNil(&d.Pass)
}
+18
View File
@@ -66,6 +66,24 @@ func trimValue(field *string) {
*field = strings.TrimSpace(*field)
}
// emptyToNil: пустая строка означает «не задано», и ничего больше.
//
// Отличается от blankToNil тем, что НЕ трогает пробелы. Применяется к полям,
// содержимое которых принадлежит оператору целиком, — к паролю: набор его
// символов не ограничен, пробел по краям является частью значения, и
// молчаливое его удаление означало бы, что на сервер уезжает не то, что было
// введено. Пустое поле при этом обязано получить отказ `required`, а не отказ
// по длине: у поля-указателя `required` считает значение заданным по самому
// факту ненулевого указателя.
func emptyToNil(field **string) {
if *field == nil {
return
}
if **field == "" {
*field = nil
}
}
// zeroToNil: ноль у необязательного числового поля означает «не задано».
//
// Применяется ТОЛЬКО там, где ноль не является осмысленным значением:
+52
View File
@@ -119,4 +119,56 @@ func TestNormalizableIsImplemented(t *testing.T) {
var _ Normalizable = (*PeerPageDto)(nil)
var _ Normalizable = (*BaseDto)(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("пустые пароли формы смены остались заданными")
}
}
+18
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
@@ -58,6 +59,23 @@ func Login(username string, plainPassword string) (string, bool, error) {
if tokenErr != nil {
return "", false, tokenErr
}
// Отметка о входе ставится здесь, а не в слое контроллеров.
//
// Колонка `last_login_at` объявлена и в схеме, и в entity, а писать её было
// некому: UpdateAdminLastLoginAt существовал и не вызывался ниоткуда, то
// есть панель показывала бы «последний вход» как 1970 год на живом сервере.
// Место выбрано так, чтобы записать её было невозможно забыть: успешная
// проверка пароля — единственная дверь, и она здесь одна.
//
// Отказ записи НЕ отменяет вход. Учётные данные уже подтверждены, и
// превращать неудачу бухгалтерии в отказ аутентификации значило бы закрыть
// панель из-за строки статистики. Молчать при этом тоже нельзя: неписаная
// отметка — расхождение между тем, что показывает панель, и тем, что
// произошло.
if err = UpdateAdminLastLoginAt(*admin.Id, time.Now().UnixMilli()); err != nil {
logrus.Errorf("не удалось записать время входа администратора %d: %v", *admin.Id, err)
}
// Признак «пароль в старом формате, заставить сменить» отсюда убран вместе
// с поддержкой самого формата: единственный источник этого требования —
// флаг force_password_change, который ставит установка.
+31 -2
View File
@@ -13,6 +13,7 @@ import (
"strings"
"golang.org/x/crypto/bcrypt"
"hy2xs-admin/credential"
)
// Функции 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) {
if len(strings.TrimSpace(password)) < 6 {
return "", errors.New("password too short")
if !credential.IsValidAdminPassword(password) {
return "", fmt.Errorf(
"пароль администратора не удовлетворяет контракту: от %d до %d символов, "+
"не более %d байт в UTF-8, без управляющих символов и U+FEFF",
credential.AdminPasswordMinLength,
credential.AdminPasswordMaxLength,
credential.AdminPasswordMaxBytes,
)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
+76 -5
View File
@@ -5,6 +5,8 @@ import (
"encoding/hex"
"strings"
"testing"
"hy2xs-admin/credential"
)
// Здесь был TestSHA224String, который печатал хеш в stdout и ничего не
@@ -41,12 +43,81 @@ func TestHashPasswordIsSalted(t *testing.T) {
}
}
func TestHashPasswordRejectsShortPassword(t *testing.T) {
if _, err := HashPassword("12345"); err == nil {
t.Fatal("слишком короткий пароль принят")
// HashPassword проверяет ОБЩИЙ контракт, а не собственную копию правила.
//
// Здесь стояло `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"},
{"пусто", ""},
}
if _, err := HashPassword(" "); err == nil {
t.Fatal("пароль из пробелов принят")
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))
}
}
}
@@ -0,0 +1,262 @@
# Runtime-прогон `1.0.0-rc2` на чистом Debian 13
```text
Findings base: 82e5ca40 — дерево, на котором собран проверявшийся RC2
Fixes verified in: рабочее дерево этого прохода
Хост: чистый Debian 13, установка с нуля из release-архива
```
Провенанс у этого файла другой, чем у соседних preflight-разборов: дефект
наблюдался **на хосте**, а не найден чтением дерева. Установка прошла целиком и
объявила успех, после чего панель оказалась недоступна.
## Статус прогона
```text
RC2 BUILD ACCEPTANCE: PASS
RC2 CLEAN INSTALL: PASS
RC2 SERVER RUNTIME: PASS
RC2 ADMIN LOGIN: FAIL
RC2 OVERALL RUNTIME: REJECTED
```
Hysteria работает, сервер не повреждён, паника восстановима. Приёмка RC2
останавливается здесь: основная admin-панель после чистой установки недоступна
целиком, и это P0 для release candidate.
## Сводка
| ID | Дефект | Приоритет | Статус |
| --- | --- | --- | --- |
| LOGIN-01 | `POST /api/auth/login` паниковал на теге незарегистрированного правила | P0 | закрыт |
| LOGIN-02 | Ни один тест не прогонял DTO через production-валидатор | P0 | закрыт |
| LOGIN-03 | Установка объявляла успех, не проверив, что в панель можно войти | P0 | закрыт |
| LOGIN-04 | Оркестратор не знал контракта учётных данных и по умолчанию писал невалидный логин | P0 | закрыт |
| LOGIN-05 | Форма входа была строже сервера и запирала оператора после смены пароля | P1 | закрыт |
| LOGIN-06 | Индикация ошибки на форме входа рисовалась вокруг не того элемента | P2 | закрыт |
| LOGIN-07 | Класс символов записан диапазоном по опечатке в двух формах панели | P2 | закрыт |
| LOGIN-08 | `last_login_at` объявлен в схеме, но не записывался никогда | P3 | закрыт |
---
## LOGIN-01 — вход паниковал до проверки учётных данных
**Наблюдалось на хосте.** Каждый `POST /api/auth/login` отдавал HTTP 500. В
journal на каждый запрос:
```text
panic recovered:
Undefined validation function 'validateStr' on field 'Username'
controller/validator.go:115
controller/peer.go:50
```
В `apps/model/dto/auth.go` на поле стоял тег `validateStr`:
```go
Username *string `json:"username" ... validate:"required,min=6,max=32,validateStr"`
```
Правило с таким именем не регистрируется: при переименовании в `credentialStr`
правка не доехала до одного файла. `go-playground/validator` на неизвестный тег
ПАНИКУЕТ при разборе структуры — то есть до всякой проверки логина и пароля, —
а `gin.Recovery` превращал панику в HTTP 500.
Побочно это подтверждается тем, что зарегистрированное правило `credentialStr`
не использовалось **нигде**: переименование оставило после себя мёртвую
регистрацию и живую ссылку на несуществующее имя.
**Закрыто.** Контракт учётных данных вынесен в
`apps/service/admin_credentials.go` — по образцу уже существующего
`IsValidPeerName`. Правило `credentialStr` зовёт его, длина живёт ВНУТРИ
правила (два правила длины на одном поле уже приводили к необъяснимому отказу
на имени пира), а обработчик входа переехал в `apps/controller/auth.go`: пока
он лежал в `peer.go`, стек указывал на управление пирами — подсистему, не
имеющую к отказу отношения.
---
## LOGIN-02 — 311 Go-тестов не видели дефекта
**Наблюдалось по дереву.** `validator_test.go` проверял регексп `credentialStr`
НАПРЯМУЮ, в обход валидатора, а обработчика входа не касался ни один тест.
Важно, почему очевидная проверка не помогла бы. Прогон нулевого `LoginDto`
через `validate.Struct` дефекта НЕ ловит: цепочка правил поля обрывается на
первом несработавшем, поэтому на пустом `Username` проверка отказывает по
`required` и до испорченного тега не доходит.
**Закрыто** барьером, закрывающим КЛАСС, а не найденный экземпляр:
`TestEveryValidationTagIsRegistered` извлекает все теги `validate:"…"` из
`apps/model/**` и предъявляет каждый валидатору отдельно. Незарегистрированное
правило паникует так же, как паниковало в бою, — но на сборке. Барьер проверен
возвратом исходного тега: тест падает с именем правила и файлом.
Сверх него добавлены прогон `LoginDto` через production-валидатор, таблица
негативных случаев с ожидаемыми кодами причин и HTTP-регрессия обработчика — в
том числе за `gin.Recovery`, то есть ровно в той конфигурации, в которой дефект
наблюдался.
---
## LOGIN-03 — установка не проверяла, что в панель можно войти
**Наблюдалось на хосте.** Установка завершилась `INSTALL EXIT CODE: 0` при
полностью недоступной панели.
Smoke отвечал на вопрос «работает ли панель» тремя фактами: юнит активен,
`127.0.0.1:8080` в `LISTEN`, `/healthz` отвечает `ok: true`. Все три были
истинны. Факт `LISTEN` не означает, что панель функциональна, — RC2 это
буквально доказал.
**Закрыто.** `orchestrator/src/steps/smoke.ts` выполняет настоящий
`POST /api/auth/login` bootstrap-учётными данными и требует `code: 20000` с
непустым `accessToken`; успех определяется КОНВЕРТОМ, а не кодом HTTP — админка
отвечает `200 OK` и на отказ тоже. Отдельная отрицательная проба выполняется в
любом режиме операции и не зависит от актуальности пароля: заведомо неверные
учётные данные обязаны получить конверт отказа, а не 500. Учётные данные не
попадают ни в текст ошибки, ни в журнал.
---
## LOGIN-04 — оркестратор не знал контракта, который сам порождает
**Найдено при разборе смежного кода.** `orchestrator/src/config/env.ts` брал
логин как
```ts
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin")
```
`admin` — пять символов при минимуме панели в шесть. При пустом
`HY2XS_ADMIN_USER` установка проходила целиком и создавала учётную запись, под
которой невозможно войти. Про одно и то же имя существовало три расходящихся
умолчания: `admin` здесь, `hy2xsadmin` в `apps/dao/sqlite.go` и `hy2xsadmin` в
`package/config/hy2xs.env`.
Ни логин, ни операторский `HY2XS_ADMIN_INITIAL_PASSWORD` не проверялись против
контракта панели вовсе.
**Закрыто.** Оба значения проверяются при разборе окружения — той стороной,
которая их ПОРОЖДАЕТ: отказ, пришедший установщику, чинится одной строкой в
`hy2xs.env`, а неработающий вход на готовом сервере — переустановкой. Умолчание
сведено к `hy2xsadmin` во всех трёх местах. Проверяется и сгенерированный
пароль: генератор — такой же источник значения.
---
## LOGIN-05 — панель была строже сервера и запирала после смены пароля
**Найдено при разборе панели.** Границы пароля различались на трёх сторонах:
| Где | Логин | Пароль |
| --- | --- | --- |
| форма входа | 6-32 + набор | 6-**32** + набор |
| форма смены пароля | — | 6-**64** + набор |
| сервер | 6-32 + набор | 6-**64**, набора нет |
Следствий два, и оба закрывают панель. Пароль длиннее 32 символов назначался
штатной формой смены и после этого не вводился на форме входа: оператор терял
доступ после операции, которую панель ему же и предложила. А набор символов на
пароле отвергал значение, которое сервер принял бы, — в том числе
`HY2XS_ADMIN_INITIAL_PASSWORD`, заданный оператором со знаком вне набора.
**Закрыто.** Правило объявлено один раз в
`apps/frontend/src/constants/credentials.ts` и используется обеими формами;
набор символов с пароля снят — сервер его не предъявляет нигде, а проверка,
умеющая только запереть оператора, не защищает ничего. Совпадение с Go-контрактом
удерживается тестом, читающим Go-исходник.
---
## LOGIN-06 — красная рамка обводила не то, что видит оператор
**Наблюдалось на экране.** При отказе проверки красная рамка ложилась вокруг
одного лишь поля ввода: у логина начиналась после иконки пользователя, у пароля
обрывалась перед переключателем видимости, и ни одна её сторона не совпадала с
видимой границей поля.
Причина — специфичность, а не опечатка. Element Plus 2.14.5
(`theme-chalk/src/form-item.scss`) рисует состояние отказа на
`el-input__wrapper` селектором из ЧЕТЫРЁХ классов:
```text
.el-form-item.is-error .el-form-item__content .el-input__wrapper
```
Форма входа рисует видимую рамку поля на `el-form-item` — внутрь одного поля
кладутся иконка, ввод и переключатель видимости, — а штатную тень враппера
гасила селектором из трёх классов и проигрывала.
**Закрыто.** Индикация перенесена на элемент, который оператор и видит полем
(`.el-form-item.is-error`), а тень враппера гасится селектором, повторяющим
чужой и добавляющим атрибут scoped-стиля, — то есть выигрывает конкретностью, а
не `!important`. Сообщению об отказе оставлено место под полем:
`el-form-item__error` позиционируется абсолютно от `top: 100%` и живёт вне
рамки.
Проверены остальные формы панели: собственная рамка на `el-form-item` вместе с
переопределением `el-input__wrapper` встречается только на форме входа. Смена
пароля, диалог пира и тулбары используют штатную рамку, где `is-error` попадает
точно.
**Что машина не докажет.** Совпадение рамки с границей поля на экране остаётся
ручной проверкой; тест закрепляет только наличие правил, которые её
обеспечивают.
---
## LOGIN-07 — класс символов записан диапазоном по опечатке
**Найдено при разборе панели.** В формах входа и смены пароля стояло
```text
/^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/
```
Дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и впускает
`, - . / 0-9 : ; < =`. С серверным набором это совпадало по совпадению: оба
несли одну и ту же опечатку. В форме пира тот же класс уже был записан явно
(`_+\-=`) — договорённость в проекте существовала и до входа не доехала.
**Закрыто.** Класс записан явно и НЕ сужен: фактическое множество уже действует
на установленных серверах. Экранирование дефиса закреплено тестом — пока набор
выглядел опечаткой, любая попытка «навести порядок» развела бы панель и сервер
обратно.
---
## LOGIN-08 — время последнего входа не записывалось никогда
**Найдено при разборе.** Колонка `last_login_at` объявлена в схеме и в entity,
`service.UpdateAdminLastLoginAt` существовал — и не вызывался ниоткуда.
**Закрыто.** Отметка ставится в `service.Login`, сразу после успешной проверки
пароля: это единственная дверь, и записать её оттуда невозможно забыть. Отказ
записи вход НЕ отменяет — учётные данные уже подтверждены, — но пишется в
журнал уровнем error: неписаная отметка есть расхождение между тем, что
показывает панель, и тем, что произошло.
---
## Отдельно: диагностика на хосте
`systemctl cat` открывает `less`, из-за чего вставленный следом блок
перемешивается с pager. Для воспроизводимых прогонов используется
```bash
SYSTEMD_PAGER=cat systemctl cat hy2xs-admin.service
```
или `systemctl --no-pager cat hy2xs-admin.service`. Юнит `hy2xs-admin.service`
проверен и к дефекту отношения не имеет.
## Что делать с установленным RC2
На хосте ничего чинить вручную не нужно и не следует: hotpatch бинарника на
проде и перезалив содержимого уже опубликованного `v1.0.0-rc2` противоречат
воспроизводимости и immutable provenance, вокруг которых построен продукт.
Правильный путь — исправленный source и новая сборка, а хост переустанавливается
с нуля, чтобы проверка шла по тому же clean-host сценарию, а не поверх
установленного RC2.
@@ -0,0 +1,50 @@
# Результаты runtime-проверки v1.0.0-rc3
Дата проверки: 2026-09-07.
RC3 проверялся на чистом Debian после успешной проверки целостности
опубликованного артефакта. Hysteria, ACME, firewall, диагностика и основной
authentication backend отработали штатно. Кандидат отклонён из-за несовместимости
installer smoke с HTTP middleware и ложного отчёта rollback cleanup.
## Сводка
| ID | Наблюдение | Итог |
| --------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| RC3-LOGIN-01 | стандартный `curl/<version>` получает HTTP 403 от scanner middleware | исправлено для RC4 явным UA установщика |
| RC3-LOGIN-02 | запрос с полем `password` не проходит DTO: требуется `pass` | ожидаемое поведение wire-контракта, добавлена регрессия |
| RC3-LOGIN-03 | `{username, pass}` с неверным паролем доходит до auth logic и возвращает `invalid_credentials` | backend исправен |
| RC3-LOGIN-04 | positive и negative smoke имели две независимые curl-команды | исправлено общим request helper'ом |
| RC3-ROLLBACK-01 | `reset-failed` для уже выгруженного юнита создавал ложное `manual recovery may be required` | исправлено проверкой postcondition |
## Что доказала диагностика login
Последовательность запросов отделила middleware, DTO и authentication logic:
| Запрос | Результат |
| ---------------------------------------- | -------------------------------------------------------- |
| стандартный curl UA + `password` | HTTP 403 до разбора тела |
| допустимый UA + `password` | validation envelope: требуется поле `pass` |
| допустимый UA + `pass` и неверный пароль | operation envelope с `errors[].code=invalid_credentials` |
Следовательно, паника старого `validateStr` не вернулась. Дефект RC3 находился
между smoke-клиентом и scanner middleware, а не в проверке пароля.
## Контракт RC4
- positive и negative login используют один helper;
- helper отправляет JSON `{username, pass}` и `User-Agent: HY2XS-Installer/1.0`;
- success требует `code=20000` и непустой `data.accessToken`;
- rejection требует `code=50000`, причину `invalid_credentials` внутри
`errors[]` и отсутствие выданного токена;
- envelope разбирается как JSON, поэтому совпадение текста внутри `message` не
считается доказательством;
- стандартный curl UA продолжает получать 403: scanner middleware в RC4 не
удаляется;
- уже выгруженный systemd-юнит считается успешным конечным состоянием cleanup
только после наблюдения `LoadState` и `ActiveState`;
- недоступный systemd или сохранившийся `ActiveState=failed` остаются настоящим
rollback failure.
Пересмотр необходимости scanner middleware для loopback-only панели остаётся
отдельной задачей после v1.0.0 и не расширяет change surface RC4.
+4
View File
@@ -25,12 +25,16 @@
| Дата | Версия | Коммит источника | Вид | Вердикт |
| --- | --- | --- | --- | --- |
| 2026-09-01 | `1.0.0-rc1` | `a1f0db22` | build + host acceptance, Debian 13 | [RC ACCEPTED WITH RELEASE-REQUIRED UX FIXES](2026-09-01-v1.0.0-rc1-host-acceptance.md) |
| 2026-09-04 | `1.0.0-rc2` | `82e5ca40` | clean install + runtime, Debian 13 | [RUNTIME REJECTED — вход в панель недоступен](2026-09-04-v1.0.0-rc2-runtime-findings.md) |
| 2026-09-07 | `1.0.0-rc3` | `bf10810c` | clean install + failure rollback, Debian 13 | [RUNTIME REJECTED — installer smoke блокируется middleware](2026-09-07-v1.0.0-rc3-runtime-findings.md) |
## Открытые дефекты приёмки
| Прогон | Дефекты |
| --- | --- |
| 2026-09-01, `1.0.0-rc1` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) |
| 2026-09-04, `1.0.0-rc2` | [LOGIN-01…LOGIN-08](2026-09-04-v1.0.0-rc2-runtime-findings.md) — все закрыты |
| 2026-09-07, `1.0.0-rc3` | [RC3-LOGIN-01…04, RC3-ROLLBACK-01](2026-09-07-v1.0.0-rc3-runtime-findings.md) — исправления подготовлены для RC4 |
## Разборы кода между прогонами
+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-бандл. Помимо
+203
View File
@@ -85,6 +85,12 @@
сборку — vue-i18n молча отдаёт сам ключ, и оператор видит `error.code.min_length`
вместо фразы.
Совпадения ключей недостаточно: строки компилируются как Vue I18n message
format только при переводе. Специальные символы (`@`, `$`, `{}`, `|`) нельзя
вставлять в текст как произвольные данные. Динамический набор знаков передаётся
через named interpolation, а builder переводит каждую leaf-строку RU/EN и
считает ошибкой как исключение, так и compiler diagnostics в `console.error`.
**Состояние сессии** сообщается кодами `unauthorized`, `session_expired`,
`token_invalid`, `account_disabled` при `code = 50401`. Панель по ним
показывает диалог и возвращает на форму входа.
@@ -132,6 +138,11 @@
продукт: в таблицу пиров ведут две двери, и они не имеют права требовать
разного.
Frontend-зеркало границ, regex и человекочитаемого набора находится в одном
модуле `constants/peer.ts` и сверяется с Go-контрактом тестом. Пунктуация
`!@#$%^&*()_+-=` попадает в подсказку как значение `{punctuation}`, а не как
часть синтаксиса message format.
---
## 4. Таблицы журнала
@@ -259,3 +270,195 @@ control plane показывал всех пиров офлайн. Теперь
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
ни config API, ни таблица `config`, ни настройки панели его не содержат и не
могут переопределить.
---
## 10. Форма входа
**Правило.** Панель не имеет права быть строже сервера. Значение, которое
сервер принял бы, форма обязана отправить.
### Где живёт контракт
Требования к логину и паролю администратора объявлены **один раз**, в
`apps/credential/admin.go`:
| Что | Значение | Владелец |
| --- | --- | --- |
| Длина логина | 6-32 символа | `AdminUsernameMinLength` / `AdminUsernameMaxLength` |
| Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` |
| Длина пароля | 6-64 символа Unicode | `AdminPasswordMinLength` / `AdminPasswordMaxLength` |
| Размер пароля | не более 72 байт в UTF-8 | `AdminPasswordMaxBytes` |
| Домен пароля | документированный домен systemd `EnvironmentFile=`: валидный UTF-8 без NUL, U+FEFF, суррогатов и noncharacters | `IsEnvTransportableText` |
| Набор символов пароля | не ограничен, кроме `Cc` | `hasForbiddenRune` |
| Пробелы по краям пароля | часть значения, не снимаются | — |
Контракт живёт в отдельном **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` и `adminPassword` в
`apps/controller/validator.go`, длина живёт ВНУТРИ них.
### Почему у пароля нет набора символов
Пароль назначает оператор — установкой через `HY2XS_ADMIN_INITIAL_PASSWORD` или
формой смены. Сервер его набор не проверяет нигде: значение сравнивается с
bcrypt-хешем. Ограничение набора на форме не защищает ничего и умеет только
отвергнуть пароль, который сервер принял бы.
Исключения два, и они **разного происхождения**. Их важно не путать: одно
описывает чужое ограничение, другое — наше решение.
**Домен systemd — не наше правило.** Первый пароль администратора уезжает в
`/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`. Перед тем
как принять пару, systemd прогоняет ключ и значение через `utf8_is_valid`
(`src/basic/env-file.c`, `check_utf8ness_and_warn`), и отказ там возвращает
`-EINVAL`: это **незагруженный файл окружения**, то есть юнит, который не
стартует, а не предупреждение. `unichar_is_valid` (`src/basic/utf8.c`)
отвергает:
```text
U+D800..U+DFFF суррогаты
U+FDD0..U+FDEF noncharacters
(cp & 0xFFFE) == 0xFFFE U+FFFE, U+FFFF, U+1FFFE, … U+10FFFF
```
плюс встроенный NUL, U+FEFF и любую невалидную последовательность UTF-8.
U+FEFF запрещён публичной документацией EnvironmentFile. Реализация systemd
v257.13 случайно пропускает его (`0xFEFF & 0xFFFE == 0xFEFE`); HY2XS следует
документированному контракту, а не закрепляет ошибку конкретной версии.
Пока контракт этого не знал, пароль `abcde` + `U+FDD0` — шесть символов, восемь
байт, ни одного управляющего — проходил панель, оркестратор, DTO и хеширование,
записывался в `hy2xs.env`, и после этого админка не поднималась. Тот же класс
дефекта, ради уничтожения которого контракт и существует, только слоем ниже.
На стороне панели и оркестратора отдельно отвергаются **одиночные суррогаты**:
строка JavaScript вправе их содержать, а `TextEncoder` молча заменит непарный
суррогат на `U+FFFD`. Без этой проверки не было бы отказа — было бы тихое
изменение пароля по дороге в файл.
**Политика HY2XS — наше решение.** Сверх транспортного домена запрещены управляющие
символы Unicode целиком (категория `Cc`: `U+0000..U+001F`, `U+007F`,
`U+0080..U+009F`). Их невозможно ни увидеть в
поле ввода, ни повторить при следующем входе: они умеют ровно одно — запереть
оператора снаружи.
На HTTP-границе проверяется не только уже декодированная Go-строка. Сырые JSON
байты должны быть валидным UTF-8, а `\uXXXX` — не содержать непарных UTF-16
суррогатов. Это делается до `encoding/json`, который иначе молча заменил бы оба
дефекта на допустимый U+FFFD и мог бы аутентифицировать другое значение.
### Почему границ у пароля две
Их две потому, что они в **разных единицах**, и вывести одну из другой нельзя.
Граница в символах — та, которую видит оператор. Она считается в 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`.
### Границы обеих форм обязаны совпадать
Форма входа и форма смены пароля предъявляют к паролю **одно и то же**
требование. Расхождение здесь запирает оператора снаружи после операции,
которую панель ему же и предложила: пароль длиннее предела формы входа
назначается успешно и после этого не вводится.
### Границы обеих форм обязаны совпадать
Форма входа и форма смены пароля предъявляют к паролю **одно и то же**
требование. Расхождение здесь запирает оператора снаружи после операции,
которую панель ему же и предложила: пароль длиннее предела формы входа
назначается успешно и после этого не вводится.
### Индикация ошибки принадлежит видимому полю
Element Plus рисует состояние отказа на `el-input__wrapper` правилом
```text
.el-form-item.is-error .el-form-item__content .el-input__wrapper
```
то есть селектором из четырёх классов. На форме входа видимое поле — это
`el-form-item`: внутрь одного поля кладутся иконка, ввод и переключатель
видимости пароля, а `el-input` занимает лишь среднюю часть. Поэтому штатная
индикация ложится вокруг одного лишь ввода и ни одной стороной не совпадает с
границей поля.
**Правило.** Там, где рамка поля нарисована на `el-form-item`, состояние отказа
рисуется на нём же, а штатная тень враппера гасится селектором, который
повторяет чужой и добавляет атрибут scoped-стиля — то есть выигрывает
специфичностью, а не `!important`. Сообщению об отказе оставляется место под
полем: `el-form-item__error` позиционируется абсолютно от `top: 100%` и живёт
вне рамки.
**Что машина не докажет.** Совпадение рамки с границей поля на экране. Проверка
остаётся ручной и фиксируется в отчёте приёмки; тест закрепляет только наличие
правил, которые её обеспечивают.
### Требование называется, а не нарушается
Фразы `credentials.usernameFormat` и `credentials.passwordFormat` перечисляют
границы и набор символов. Набор логина приходит из `HY2XS_ADMIN_USER`, и
посмотреть его в панели больше негде — сообщение «Неверный формат логина» не
давало оператору ни одного способа узнать, что от него хотят.
Серверная причина `credential_format` несёт те же значения в `params`
(`min`, `max`, `charset`), и фраза панели обязана их использовать: правило одно
и проверяет и длину, и набор, поэтому описывать его только через символы —
значит описывать отказ по длине неверно.
У пароля причина отдельная — `admin_password_format` с `params`
(`min`, `max`, `maxBytes`), — и фраза обязана называть **обе** границы. Пароль
из 40 эмодзи укладывается в 64 символа и не укладывается в 72 байта: сообщение
«не длиннее 64 символов» отправило бы оператора сокращать пароль, отвергнутый
не за это.
+15 -8
View File
@@ -136,7 +136,7 @@ Bun обновляется отдельно от остальных: оркес
полного прохода `bun test → tsc → compile → приёмка на Debian`, а не строки в
общем патче.
### Проверка типов frontend — обязательный шаг релиза
### Runtime-компиляция локализации и типы frontend — обязательные шаги релиза
```text
pnpm run typecheck → vue-tsc --noEmit → ОБЯЗАН пройти
@@ -144,9 +144,13 @@ pnpm run build:prod → vite build
pnpm run verify → typecheck, затем build
```
`bundle_ui()` запускает `typecheck` **до** сборки bundle: собирать production
bundle из кода, который не проходит проверку типов, незачем. Порядок и сам факт
наличия шага проверяются приёмкой.
После `pnpm install --frozen-lockfile` функция `bundle_ui()` сначала запускает
`bun test test/i18n-runtime.test.ts`, затем `typecheck` и только потом сборку
bundle. Словари заданы TypeScript-объектами, поэтому Vite и `vue-tsc` не
компилируют message format и не ловят специальные символы `@`, `$`, `{}`, `|`.
Runtime-gate использует реальный `vue-i18n` из lock-графа, обходит все строки
RU/EN и перехватывает также `console.error`: один лишь `try/catch` недостаточен,
поскольку компилятор может сообщить об ошибке без исключения из `t()`.
До v1 этой гарантии не было. `build:prod` означал `vite build && vue-tsc
--noEmit`, но `vue-tsc` был версии `0.35.0` (2022 год) и шаблоны Vue
@@ -222,7 +226,9 @@ bundle из кода, который не проходит проверку ти
Уязвимость обычно приходит не из прямой зависимости, а из чужого поддерева, и
поднимать ради неё весь frontend-граф не нужно. Такие случаи закрываются
списком `pnpm.overrides` в `apps/frontend/package.json`:
списком `overrides` в `apps/frontend/pnpm-workspace.yaml`. Настройки pnpm не
хранятся в поле `pnpm` package manifest: современные версии его игнорируют, и
правило осталось бы только случайно замороженным в старом lock-файле.
| override | зачем |
| --- | --- |
@@ -253,14 +259,15 @@ bundle из кода, который не проходит проверку ти
| Шаг сборки | Что запускается |
| --- | --- |
| `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` |
| `bundle_ui` | `pnpm run typecheck` (`vue-tsc --noEmit`) до сборки bundle |
| `run_frontend_tests` | dependency-free контракты панели |
| `bundle_ui` | frozen install → runtime-компиляция всех RU/EN-сообщений → `pnpm run typecheck` → production bundle |
| `run_admin_tests` | `go vet ./...`, `go test ./...` |
Готовый пакет объявляет об этом полем `tests_gate=true` в
`metadata/package.env` — так же, как `dependency_security_gate` и
`hysteria_compat_gate`. Значение у поля ровно одно, потому что не бывает
пакета, собранного с пропущенными тестами: обе функции прогона выставляют свой
флаг **после** успешного завершения, а `write_metadata` отказывается писать
пакета, собранного с пропущенными тестами: функции прогона выставляют свои
флаги **после** успешного завершения, а `write_metadata` отказывается писать
метаданные, если хотя бы один из них не выставлен. То есть поле остаётся
утверждением о результате, а не переключателем.
@@ -541,6 +541,57 @@ 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`) |
| валидный UTF-8 без NUL, U+FEFF, суррогатов и noncharacters | документированный домен systemd `EnvironmentFile=` |
| без управляющих символов Unicode (`Cc`) | политика HY2XS: значение должно воспроизводиться в однострочной форме входа |
Границы независимы: у 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, поэтому придуманный секрет разошёлся бы с файлом и первая же
@@ -550,13 +601,43 @@ machine-auth, поэтому придуманный секрет разошёл
```bash
systemctl stop hy2xs-admin
set -a; . /etc/hy2xs/hy2xs.env; set +a
# Значения читаются КАК ДАННЫЕ. Обоснование — ниже, оно существенно.
read_runtime_field() {
sed -n "s/^$1=//p" /etc/hy2xs/hy2xs.env | head -n1 \
| sed -e 's/^"//' -e 's/"$//' -e 's/\\\(["\\]\)/\1/g'
}
HY2XS_INSTALL_DIR="$(read_runtime_field HY2XS_INSTALL_DIR)"
HY2XS_DATA_DIR="$(read_runtime_field HY2XS_DATA_DIR)"
HY2XS_LOG_DIR="$(read_runtime_field HY2XS_LOG_DIR)"
export HY2XS_DATA_DIR HY2XS_LOG_DIR
"$HY2XS_INSTALL_DIR/hy2xs-admin" reset-admin
systemctl start hy2xs-admin
```
Runtime env подключается намеренно: из него берутся пути к базе и журналу
(`HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`) — те же, с которыми работает юнит.
Пути к базе и журналу (`HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`) берутся из runtime env
намеренно: это те же значения, с которыми работает юнит.
**Почему не `set -a; . /etc/hy2xs/hy2xs.env`.** Здесь стояла именно эта строка, и
она стала опасной ровно тогда, когда файл научился нести произвольные значения.
Оператор задаёт `HY2XS_ADMIN_INITIAL_PASSWORD`, набор символов у пароля не
ограничен, и запись в файле выглядит так:
```text
HY2XS_ADMIN_INITIAL_PASSWORD="$(touch /tmp/pwn)"
```
Для systemd это **буквальное значение**: подстановки в `EnvironmentFile=` нет
вовсе, `$` там обычный символ. Но `.` (`source`) обрабатывает файл **bash**, а
bash внутри двойных кавычек выполняет подстановку команд — и выполнил бы её от
root, вместе с рецептом восстановления доступа.
То же правило действует и для `/etc/hy2xs/bootstrap-admin.secret` (см. «Как
посмотреть bootstrap-пароль» выше): файлы этого формата читаются как ДАННЫЕ.
Результат `$(read_runtime_field …)` повторно как код не исполняется — он
становится значением переменной, и это принципиальная разница.
Команда печатает новые логин и пароль в консоль и требует смены пароля при
первом входе. Работает поверх существующей установки; на машине без базы она
+21 -15
View File
@@ -74,18 +74,19 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env
быть отменён: ни неудачной записью состояния в `/var/lib/hy2xs`, ни отказом
одной из своих стадий. Поэтому в журнале нужно читать две разные вещи:
| Строка в журнале | Что она означает |
| --- | --- |
| `failed to persist failure state, continuing with the mandatory rollback` | маркер не обновился (обычно заполненный диск), но восстановление выполнено; после освобождения места запустить `doctor` |
| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены |
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
| `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)` | guard взведён; с этого момента операция обязана снять его до фиксации успеха |
| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания |
| `automatic firewall rollback has already fired` | guard успел сработать; сервер работает на **прежнем** firewall, операция обязана завершиться отказом |
| `firewall rollback guard <unit> is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd |
| `unable to verify firewall rollback guard state; systemd query failed` | состояние guard'а недоказуемо; операция не начата, чинить нужно systemd, а не ждать |
| Строка в журнале | Что она означает |
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `failed to persist failure state, continuing with the mandatory rollback` | маркер не обновился (обычно заполненный диск), но восстановление выполнено; после освобождения места запустить `doctor` |
| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены |
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
| `systemctl reset-failed <unit> exited with …, but the cleanup postcondition is satisfied` | юнит уже выгружен или не находится в `failed`; это допустимый идемпотентный cleanup, ручное восстановление не требуется |
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
| `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)` | guard взведён; с этого момента операция обязана снять его до фиксации успеха |
| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания |
| `automatic firewall rollback has already fired` | guard успел сработать; сервер работает на **прежнем** firewall, операция обязана завершиться отказом |
| `firewall rollback guard <unit> is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd |
| `unable to verify firewall rollback guard state; systemd query failed` | состояние guard'а недоказуемо; операция не начата, чинить нужно systemd, а не ждать |
Отдельно про сработавший guard. Окно 45 секунд намеренно короче худшего случая
smoke и не обязано его покрывать: доказательством служит не время, а маркер
@@ -336,6 +337,7 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/
```
Инварианты:
- команда не выводит исходные секреты в stdout;
- требуется выбрать ровно один режим: `--in-place` или `--out <path>`;
- `--format auto` пытается определить формат по имени файла, при неоднозначности используйте `--format env|yaml`;
@@ -344,14 +346,18 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/
редакцию, а результат остаётся валидным YAML;
- в env-файлах секрет вырезается и из URL-значения, даже если имя ключа
несекретное — например, `HY2_AUTH_URL` в `post-install.env`.
- `HY2XS_FORCE_PASSWORD_CHANGE=true|false` сохраняется как диагностический
boolean; это точное исключение, остальные ключи с `PASSWORD` редактируются.
Та же редакция применяется к diagnostics-бандлу
(`hy2xs-orchestrator diagnostics collect`), который собирается автоматически при
неудачной установке или реконфигурации. Бандл предназначен для передачи наружу,
поэтому попадающие в него `hy2xs.env`, `post-install.env` и `config.yaml`
редактируются перед упаковкой.
редактируются перед упаковкой. Готовый архив находится в
`/var/lib/hy2xs/diagnostics`, принадлежит `root:root` и имеет режим `0600`.
Сам каталог имеет режим `0700`; не ослабляйте его права и не переносите bundle
в `HY2XS_LOG_DIR`, доступный сервисному пользователю.
При отказе **до** начала применения изменений (`fatal_pre_apply`) бандл не
собирается: его сбор сам создал бы каталоги в `/var/log/hy2xs` на сервере,
собирается: его сбор сам создал бы каталог в `/var/lib/hy2xs/diagnostics` на сервере,
который мы обещали не трогать.
+207 -15
View File
@@ -7,11 +7,13 @@
## Технологический стек оркестратора
Оркестратор фиксируется как:
- **Bun + TypeScript** по исходникам
- локальная сборка builder layer'ом
- поставка на target в виде **готового install-артефакта**
Это означает:
- на target нет `npm`, `pnpm`, `yarn` или `bun install`
- на target нет transpile/build step
- shell на target допустим только как thin wrapper entrypoint
@@ -19,6 +21,7 @@
## Главная роль оркестратора
Оркестратор работает **только на target machine** и умеет:
- выполнить read-only проверку чистоты хоста (`preflight-install`)
- выполнить первичную установку (`install`)
- выполнить явную реконфигурацию (`reconfigure --dry-run|--apply`)
@@ -42,6 +45,7 @@
## Предусловия
Оркестратор рассчитан только на:
- чистый Debian 13
- root/sudo install context
- один сервер
@@ -127,10 +131,10 @@ clean-host. Ко второму вызову на диске лежал собс
Guard умеет останавливать только то, что через него проходит. Поэтому
универсального раннера в `lib/process.ts` нет — есть два явных набора:
| Набор | Guard | Назначение |
| --- | --- | --- |
| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` |
| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост |
| Набор | Guard | Назначение |
| -------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------- |
| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` |
| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост |
`*Secret`-варианты не печатают команду в текст ошибки: их аргументы несут
machine token или пароль пира, а сообщение уходит в логи и диагностику.
@@ -254,7 +258,7 @@ preflight-ошибка со словом `nftables` приводила к отк
него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом
случае system rollback не выполняется, `install-state.json` не пишется,
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
`/var/log/hy2xs`).
`/var/lib/hy2xs/diagnostics`).
- `systemctl stop/disable` выполняется **только если текущая операция сама
развернула эти unit-файлы**.
@@ -277,10 +281,10 @@ preflight-ошибка со словом `nftables` приводила к отк
Второй инвариант — **стадии отката независимы**:
| Команда | Стадии |
| --- | --- |
| `install` | firewall → stop services → disable services → reset failed services |
| `reconfigure` | firewall → restore configuration |
| Команда | Стадии |
| ------------- | --------------------------------------------------------------------------------------------------------- |
| `install` | firewall → stop services → disable services → reset failed `hysteria-server` → reset failed `hy2xs-admin` |
| `reconfigure` | firewall → restore configuration |
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все
@@ -294,6 +298,14 @@ preflight-ошибка со словом `nftables` приводила к отк
ошибка операции: проблема внутри отката — это дополнительная информация о том,
что осталось не восстановленным, а не замена диагноза.
`reset-failed` для каждого сервиса является отдельной стадией и завершается
проверкой `LoadState`/`ActiveState`. Ненулевой код команды допустим, если юнит
уже выгружен (`not-found` + `inactive`): failed-состояния у него больше нет, а
значит cleanup завершён. Текст `Unit … not loaded` намеренно не разбирается — он
зависит от версии и локали systemd. Ошибка чтения состояния или сохранившийся
`ActiveState=failed` остаются настоящим отказом и попадают в manual-recovery
сводку.
Команды внутри стадий **не глушат собственные ошибки**. Это правило обратно
тому, что действовало раньше. Пока непрерывность держалась на `|| true` в каждой
команде, стадия физически не могла сообщить, что восстановление не выполнилось:
@@ -368,7 +380,11 @@ reconfigure B → создание копии упало, ошибка скры
"version": 1,
"opId": "2026-08-30T10-00-00.000Z",
"entries": [
{ "path": "/etc/hysteria/config.yaml", "present": true, "stored": "etc_hysteria_config.yaml" },
{
"path": "/etc/hysteria/config.yaml",
"present": true,
"stored": "etc_hysteria_config.yaml"
},
{ "path": "/etc/nftables.d/hy2xs.nft", "present": false, "stored": null }
]
}
@@ -423,11 +439,11 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY`:
| Значение | Поведение |
| --- | --- |
| `strict` (по умолчанию) | расхождение останавливает операцию |
| `warn` | печатается предупреждение, операция продолжается |
| `off` | сравнение не выполняется |
| Значение | Поведение |
| ----------------------- | ------------------------------------------------ |
| `strict` (по умолчанию) | расхождение останавливает операцию |
| `warn` | печатается предупреждение, операция продолжается |
| `off` | сравнение не выполняется |
Ослабление предназначено для топологий вне baseline (NAT, floating IP, anycast).
Отсутствие A-записи остаётся фатальным при любом значении: имя без A-записи не
@@ -436,6 +452,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
## Что приходит на target
На target должен попадать уже готовый package, содержащий:
- thin install entrypoint
- compiled orchestrator artifact
- bundled HY2XS admin
@@ -447,6 +464,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
## Логическая модульность
Даже если на target приезжает один собранный артефакт, внутри исходников оркестратор должен быть разложен по шагам:
- preflight
- deps
- filesystem
@@ -474,17 +492,20 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
## Модель поставки
Рекомендуемая baseline-модель:
- исходники оркестратора хранятся в `orchestrator/`
- builder выполняет локальную сборку через Bun
- в install package кладётся готовый артефакт, который запускается thin wrapper'ом
Например:
- `package/install.sh` — проверка контекста и вызов оркестратора
- `package/orchestrator/hy2xs-orchestrator` — собранный артефакт
## Логирование и коды возврата
Оркестратор должен:
- печатать понятные step-based сообщения
- завершаться ненулевым кодом при ошибке
- не скрывать первичный источник падения
@@ -500,6 +521,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
## CLI baseline
Команды:
- `preflight-install --package-dir <path> [--config <source-env>]`
- `install --package-dir <path> [--config <source-env>]`
- `reconfigure --package-dir <path> --config /etc/hy2xs/hy2xs.env --dry-run`
@@ -513,6 +535,7 @@ PHASE 0 ничего не меняет.
`--allow-partial-state` допустим только для `repair`.
Инварианты:
- только IPv4 bind/listen;
- TLS modes: `acme | file | self_signed_dev`;
- `trafficStats.secret` отдельный от `JWT_SECRET`;
@@ -542,11 +565,179 @@ 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 ровно два, оба намеренные и оба **fail-closed**:
1. строка без `=`**отказ**, а не пропуск. systemd такую строку молча
отбрасывает; молчаливая потеря строки из `hy2xs.env` означала бы установку с
настройкой, которую оператор задал, а продукт не увидел;
2. незакрытая кавычка или escape в конце файла — **отказ**. systemd в
состояниях `VALUE_ESCAPE` / `SINGLE_QUOTE_VALUE` / `DOUBLE_QUOTE_VALUE`
принимает на EOF то, что успел накопить; для конфигурации, от которой зависит
доступ в панель, «что успели накопить» — не ответ.
Оба останавливают операцию там, где её можно починить, вместо того чтобы
применить не то, что написано в файле.
### Домен значений принадлежит systemd, а не нам
Формат несёт не всякую строку, и граница здесь чужая. Перед тем как принять
пару, systemd прогоняет ключ и значение через `utf8_is_valid`
(`check_utf8ness_and_warn`), и отказ там — `-EINVAL`, то есть **незагруженный
файл окружения** и юнит, который не стартует. `unichar_is_valid` отвергает
суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`, а сам
`utf8_is_valid` — встроенный NUL и невалидный UTF-8. Публичная документация
EnvironmentFile дополнительно запрещает U+FEFF. Реализация v257.13 случайно
пропускает его из-за маски; HY2XS следует документированному контракту.
`isEnvTransportable` в `lib/envFile.ts` повторяет документированное множество.
Управляющие символы формат несёт — внутри двойных кавычек перевод
строки накапливается как обычный байт и переживает round-trip, — и запрещает их
контракт учётных данных, а не транспорт. Приписывать формату чужие запреты
нельзя: именно так проверка и пропустила noncharacters, о которых ничего не
знала.
Одиночные суррогаты проверяются отдельно и по своей причине: строка JavaScript
вправе их содержать, а `TextEncoder` молча заменит непарный суррогат на
`U+FFFD` — то есть без проверки в файл уехал бы **другой** секрет, а не отказ.
Сам файл читается только как байты и декодируется через
`TextDecoder("utf-8", { fatal: true, ignoreBOM: true })`. Обычный
`Bun.file(...).text()` запрещён на этой границе: он заменяет повреждённые байты
на U+FFFD. `ignoreBOM: true` сохраняет BOM как U+FEFF, чтобы тот не исчез до
транспортной проверки. Исходный текст целиком проверяется **до** разбора ключей:
запрещённый символ не может спрятаться в комментарии или неизвестной переменной.
### Непригодная конфигурация отвергается до первой мутации
`validateRuntimeEnvTransport` вызывается из `parseRuntimeEnv`, а не при записи
файла, и проходит по **всем** парам `runtimeEnvEntries` — не только по паролю
администратора.
Раньше проверка жила только внутри `renderRuntimeEnv`, то есть срабатывала на
шаге «write runtime env» — уже после bootstrap оркестратора, установки пакетов и
раскладки файловой системы. Read-only `preflight-install` при этом говорил PASS:
он зовёт `parseRuntimeEnv` и ничего не рендерит. Детерминированно известная
ошибка конфигурации роняла операцию, оставив за собой изменённый хост, — что
прямо противоречит контракту PHASE 0.
## Smoke проверяет, что панель ВПУСКАЕТ
Открытый порт — это не работающая панель.
До RC3 установка отвечала на вопрос «работает ли панель» тремя фактами: юнит
активен, `127.0.0.1:8080` в `LISTEN`, `/healthz` отвечает `ok: true`. RC2
доказал, что все три бывают истинными одновременно с полностью недоступной
панелью: на поле логина стоял тег незарегистрированного правила валидации,
`POST /api/auth/login` паниковал ещё до проверки учётных данных, `gin.Recovery`
превращал панику в HTTP 500 — и установка завершалась `INSTALL EXIT CODE: 0`.
Поэтому smoke выполняет **настоящий вход** на `POST /api/auth/login`:
| Проба | Когда | Что требуется |
| ---------------------------------------------------- | ---------------- | ----------------------------------------------------------------------- |
| настоящий логин + СЛУЧАЙНЫЙ пароль | всегда | `code: 50000`, причина `invalid_credentials`, `accessToken` отсутствует |
| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` |
Детали, которые здесь существенны:
- **успех определяется конвертом, а не кодом HTTP.** Админка отвечает `200 OK` и
на отказ тоже — причина живёт в поле `code`. Проверка «HTTP 200» приняла бы за
успешный вход любой отказ, то есть не проверяла бы ничего;
- **отказ определяется конвертом по той же причине.** Отрицательная проба
сверяла `%{http_code}` с `200` и доказывала ровно одно — что запрос не
закончился пятисоткой. Теперь требуются три признака сразу: код конверта
`50000` (отказ операции, а не успех и не отказ валидации, который означал бы
негодный запрос), доменная причина `invalid_credentials` и ОТСУТСТВИЕ
`accessToken`;
- **конверт разбирается как JSON**, а не ищется регулярным выражением в сыром
тексте. Подстрока `invalid_credentials` внутри `message` или сломанный JSON не
имеют права превратить неизвестный ответ в успешную проверку;
- **обе пробы используют один request helper.** Wire-поле называется `pass`, а
не `password`; `Content-Type`, User-Agent и настройки curl не дублируются и не
могут разойтись между positive и negative ветками;
- **smoke отправляет явный `HY2XS-Installer/1.0` User-Agent.** Стандартный
`curl/<version>` отклоняется действующим scanner middleware раньше DTO. UA
установщика называется своим именем, не имитирует браузер и при этом проходит
существующий фильтр;
- **пароль отрицательной пробы генерируется**, а не записан литералом. Записанное
в исходнике значение теоретически может оказаться настоящим паролем — и тогда
проверка «неверные данные отвергаются» отчиталась бы об успешном входе. На
`install`, где настоящий пароль известен, дополнительно утверждается, что
проба ему не равна;
- **bootstrap-секрет читается парсером формата**, а не `grep … | cut -d= -f2-`.
Набор символов пароля не ограничен, пробелы по краям являются его частью, и
шелл-конвейер срезал бы их — положительная проба взяла бы не тот пароль и
объявила бы рабочую установку сломанной;
- **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы
панель, которая пускает и не выдаёт сессию;
- **тело общего helper'а собирается `JSON.stringify`**, а не интерполяцией в строку: пароль
задаёт оператор, и кавычка в нём сломала бы сам запрос, а не панель — проверка
объявила бы рабочую установку сломанной;
- **общий helper идёт через `runReadOnlySecret`**: он не кладёт команду в текст
ошибки, а команда несёт пароль администратора. Наружу отдаётся только код
ответа: тело успешного входа содержит токен доступа, а текст ошибки уезжает в
журнал установки и в diagnostics-бандл;
- **положительная проба install-only.** На `reconfigure` пароль в
`bootstrap-admin.secret` устаревает в тот момент, когда оператор сменил его в
панели, и требовать по нему вход значило бы ронять законную операцию.
Отрицательная проба от пароля не зависит и выполняется всегда — именно она
воспроизводит дефект RC2.
## Редактирование секретов
`redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML
разбирается и обходится как дерево.
Diagnostics не копирует env/YAML и не перенаправляет сырой journal/systemctl
сразу в staging. Сначала данные читаются или захватываются в память, проходят
редакцию и лишь затем записываются с режимом `0600`. Некорректный UTF-8 в
конфигурационном файле даёт безопасный маркер пропуска без исходных байтов.
Вывод каждой внешней команды ограничен 8 МиБ на поток и при усечении явно
помечается.
Staging и архив лежат только в `/var/lib/hy2xs/diagnostics`, а не в
service-writable `HY2XS_LOG_DIR`. Родитель проверяется через `lstat`: symlink,
не-root владелец, доступ на запись для группы/остальных или режим дочернего
каталога не `0700` останавливают сбор fail closed. Рабочий каталог получает
непредсказуемое имя через `mkdtemp`, archive path заранее резервируется через
эксклюзивный `open("wx")`, итоговый файл проверяется как обычный
`root:root 0600`. После успешной упаковки staging удаляется.
Это не косметика. Построчное правило `auth:\s*(.*)` подставляло маркер в
заголовок mapping'а и оставляло нетронутым вложенный
`auth.http.url` с `access_token=<секрет>`, то есть бандл уносил machine token
@@ -554,6 +745,7 @@ production-профилем, а не ищет подстроки. Проверя
дерево.
Редактируются:
- поля с секретоподобным именем (`password`, `secret`, `token`, `apiKey`,
`privateKey`, `authorization`, `cookie`, `bearer`, `signature`, …);
- карты, где секретны все значения (`auth.userpass`, `acme.dns.config`);
+96 -1
View File
@@ -140,10 +140,105 @@ 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`.
В санитизированной диагностике корректное значение `true`/`false` у
`HY2XS_FORCE_PASSWORD_CHANGE` остаётся видимым: это boolean состояния, а не
пароль. Исключение применяется только к точному имени ключа и допустимому
boolean; неизвестное значение скрывается fail-closed.
`HY2XS_ADMIN_INITIAL_PASSWORD`, `HY2XS_ADMIN_CON_PASS`, пароль obfs,
traffic-stats secret и похожие ключи продолжают редактироваться.
### Учётные данные администратора проверяются при разборе окружения
`HY2XS_ADMIN_USER` и `HY2XS_ADMIN_INITIAL_PASSWORD` — это значения, которые
потом принимает **форма входа в панель**. Оркестратор проверяет их против того
же контракта, что и админка (`apps/credential/admin.go`):
| Переменная | Требование | Значение по умолчанию |
| --- | --- | --- |
| `HY2XS_ADMIN_USER` | 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `hy2xsadmin` |
| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа Unicode **и** не более 72 байт в UTF-8; документированный транспортный домен systemd, включая запрет U+FEFF; набор не ограничен, кроме `Cc` | генерируется |
Значение вне контракта **роняет установку** с явным текстом, называющим границы
и набор. Так и должно быть: отказ, пришедший установщику, чинится одной строкой
в `hy2xs.env`, а неработающий вход на готовом сервере — переустановкой.
Проверяется и сгенерированный пароль, а не только заданный оператором:
генератор — такой же источник значения.
Границ у пароля **две**, и они в разных единицах. Предел в 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` руками и в пароле есть пробел по краям, кавычка или обратный
слеш, закавычьте значение тем же способом. Обычные значения (порты, пути,
домены) кавычек не требуют и записываются как раньше.
Есть два ограничения набора символов, и они разного происхождения.
**Домен systemd.** Значение обязано быть загружаемым из `EnvironmentFile=`:
валидный UTF-8 из Unicode scalar values, без NUL, U+FEFF, суррогатов и
noncharacters (`U+FDD0..U+FDEF` и все `*FFFE`/`*FFFF`). Это не наше правило —
systemd прогоняет значение через `utf8_is_valid` и отвечает `-EINVAL`, то есть
файл окружения **не загружается** и юнит не стартует. Оркестратор проверяет
домен на **каждом** значении файла, а не только на пароле: `HY2XS_ADMIN_CON_PASS`
или obfs-пароль сломали бы загрузку юнита ровно так же.
**Политика HY2XS.** Сверх этого запрещены управляющие символы Unicode
(категория `Cc`). Формат их несёт, но ввести такой пароль в
однострочное поле формы входа всё равно нельзя.
Ограничение U+FEFF берётся из публичной документации systemd. В реализации
v257.13 он случайно проходит через `unichar_is_valid` из-за маски; HY2XS не
делает эту ошибку версии частью собственного контракта.
Отказ по любому из двух правил приходит **при разборе конфигурации**, то есть до
первой необратимой операции над хостом: `preflight-install` и `install` видят
его одинаково.
Значение по умолчанию совпадает в трёх местах и обязано совпадать:
`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` создаётся оркестратором только при первичной установке.
+10 -4
View File
@@ -18,9 +18,12 @@ cd apps && go vet ./... && go test ./...
# Проверка типов и сборка frontend
cd apps/frontend && pnpm install --frozen-lockfile && pnpm run verify
# Контракты панели: спрайт иконок, словари локализации, коды ошибок, атрибуция
# Dependency-free контракты панели
bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts
# Runtime-компиляция всех RU/EN-сообщений реальным vue-i18n
cd apps/frontend && bun test test/i18n-runtime.test.ts
# Сверка среды разработки с versions.env (ничего не меняет)
./tools/dev/doctor.sh
@@ -36,9 +39,12 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
## Почему контракты панели проверяет Bun, а не vitest
Проверяемые модули (`SvgIcon/symbol.ts`, `constants/branding.ts`, словари
локализации) намеренно чистые: ни Vite, ни DOM в них нет, поэтому их можно
выполнить вне браузера уже закреплённым в `versions.env` Bun.
Dependency-free модули (`SvgIcon/symbol.ts`, frontend constants и словари
локализации) намеренно чистые: ни Vite, ни DOM в них нет. Отдельный
`i18n-runtime.test.ts` запускается после `pnpm install`, потому что обязан
использовать именно `vue-i18n` из frozen lock-графа. Он обходит каждую строку
обоих словарей и считает отказом и исключение, и compiler diagnostics в
`console.error`.
Vitest с jsdom не вычисляет `currentColor` и визуальной корректности всё равно
не доказал бы, зато привёл бы в граф `pnpm audit` — а его порог считается по
+144 -5
View File
@@ -89,6 +89,51 @@
Отдельно — 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 её отбрасывает молча) |
| незакрытая кавычка на EOF | отказ (второе намеренное расхождение: systemd принял бы накопленное) |
И обратимость: любое значение — с краевыми пробелами, кавычками, обратными
слешами, `$`, `` ` ``, `#`, эмодзи — переживает `render -> parse` побайтово, а
обычные значения (`8080`, `/etc/hysteria/server.crt`, `50 mbps`) остаются без
кавычек, чтобы релизные гейты и инструкции оператора продолжали работать.
Отдельно проверяется **домен** значений — чужое множество, а не наша политика:
| Вход | Ожидание |
| --- | --- |
| NUL, `U+FEFF`, `U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`, `U+1FFFF`, `U+10FFFF` | отказ ЗАПИСИ: публичный контракт EnvironmentFile запрещает такое значение |
| одиночный суррогат `U+D800` | отказ — иначе `TextEncoder` молча заменил бы его на `U+FFFD`, то есть подменил бы секрет |
| `U+FDCF`, `U+FDF0`, `U+FFFD`, `U+10FFFD`, `U+1F600` | принимаются: правило описывает диапазон, а не окрестность |
| `\n`, `\r`, `\t`, `U+007F`, `U+0085` | формат их НЕСЁТ и round-trip сохраняет; запрещает их контракт учётных данных, а не транспорт |
### A4b. Непригодная конфигурация отвергается до первой мутации
Там же. `validateRuntimeEnvTransport` вызывается из `parseRuntimeEnv`, поэтому
`preflight-install` и `install` видят отказ одинаково — до bootstrap
оркестратора, apt и раскладки файловой системы. Проверяется:
- `parseRuntimeEnv` отвергает значение вне домена systemd;
- проверяется КАЖДОЕ значение файла, а не только пароль администратора
(`HY2XS_ADMIN_CON_PASS`, `HY2XS_HYSTERIA_BANDWIDTH_UP`, `HY2XS_ACME_EMAIL`);
- запись и проверка ходят по одному списку `runtimeEnvEntries`;
- всё, что `parseRuntimeEnv` принял, записывается без отказа.
Рендер конфига (`orchestrator/test/render-config.test.ts`):
- Gecko рендерит **только** gecko-подблок;
@@ -361,6 +406,15 @@
хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где
редактировались env и YAML, а `journal-admin.log` копировался как есть.
`orchestrator/test/diagnostics-storage.test.ts` отдельно фиксирует границу
привилегий: production path не входит в `HY2XS_LOG_DIR`, symlink и чужой
владелец отвергаются, режим root-каталога равен `0700`, рабочие каталоги
уникальны, а archive path резервируется эксклюзивно до запуска `tar`.
`orchestrator/test/package-meta-utf8.test.ts` проверяет соседнюю fail-closed
границу metadata: fallback разрешён только для отсутствующего файла; каталог
вместо файла и повреждённый UTF-8 пробрасываются как ошибка пакета.
## A7. Machine token в журналах (unit)
`apps/middleware/log_test.go` — запрос
@@ -508,7 +562,88 @@ 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 | **отвергнут** |
последняя строка — исходный дефект: прежний тест требовал ПРИЁМА этого
значения, то есть закреплял как ожидаемое ровно то, на чём продукт ломался;
- пробел по краям — часть пароля, шесть пробелов являются корректным паролем;
- управляющие символы Unicode целиком, то есть `Cc`: `\n`, `\r`, `\t`, `NUL`,
`DEL` и C1 (`U+0085`, `U+009F`). Раньше проверялись только C0 и DEL, а
документация обещала «без управляющих символов» — то есть была шире кода;
- значения вне документированного домена systemd (`U+FEFF`, `U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`,
`U+1FFFF`, `U+10FFFF`, невалидный UTF-8) отвергаются: с ними
`/etc/hy2xs/hy2xs.env` не загрузится и юнит не стартует;
- соседи запрещённых диапазонов (`U+FDCF`, `U+FDF0`, `U+FFFD`) принимаются:
правило описывает множество systemd, а не окрестность подозрительных значений;
- `U+FEFF` отвергается **транспортным доменом**: публичная документация systemd
запрещает его, хотя реализация v257.13 случайно пропускает из-за маски
`0xFEFF & 0xFFFE == 0xFEFE`. Тест
`TestEnvTransportDomainMatchesDocumentedSystemdContract` закрепляет публичный контракт, а
`TestProductPolicyIsWiderThanTransportDomain` — что политика и домен остаются
различимы.
`orchestrator/test/strict-text-read.test.ts` подаёт reader'у реальные байтовые
последовательности `0xFF`, оборванную `0xC3` и `ED A0 80`. Ни одна из них не
превращается в U+FFFD; начальный BOM сохраняется как U+FEFF и доходит до
транспортного отказа.
`apps/controller/json_body_test.go` и HTTP-тест входа доказывают то же на API:
повреждённый UTF-8 и непарные `\uD800`/`\uDC00` отвергаются до стандартного
Go-декодера, а настоящий U+FFFD остаётся допустимым значением.
`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 символов
частоты первых восьми символов алфавита не отличаются от остальных более чем на
@@ -783,7 +918,8 @@ build tooling исполняется на build-машине и порождае
| Шаг сборки | Что запускается |
| --- | --- |
| `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` |
| `bundle_ui` | `pnpm run typecheck` до сборки bundle |
| `run_frontend_tests` | dependency-free контракты панели |
| `bundle_ui` | после frozen install: `bun test test/i18n-runtime.test.ts`, затем `pnpm run typecheck` и bundle |
| `run_admin_tests` | `go vet ./...`, `go test ./...` |
Приёмка проверяет:
@@ -791,9 +927,13 @@ build tooling исполняется на build-машине и порождае
- отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs
(место для истории — `CHANGELOG.md`);
- `metadata/package.env` содержит `tests_gate=true`;
- общий frontend-флаг выставляется только после ранних контрактов и
runtime-компиляции всех сообщений RU/EN;
- runtime-gate стоит между frozen install и typecheck/build, использует
`vue-i18n` из lock-графа и считает ошибкой compiler diagnostics;
- утверждение о прогоне выставляется **после** самого прогона, а не до него;
- `write_metadata` отказывается писать метаданные, если хотя бы один из двух
прогонов не подтверждён.
- `write_metadata` отказывается писать метаданные, если хотя бы одна из
обязательных групп проверок не подтверждена.
## A12. Приёмка проверяет код, а не упоминания
@@ -815,4 +955,3 @@ Hysteria. Вторая не была замечена только потому,
Отсутствие legacy-маршрута доказывает не `grep` по исходникам, а
`TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера —
и существование этого теста само проверяется контрактом.
+64 -1
View File
@@ -5,6 +5,7 @@
## B. Target install tests
### На чистом Debian 13 проверяем
1. пакет запускается без ручной сборки на сервере
2. Hysteria2 скачивается с official upstream
3. bundled HY2XS admin раскладывается локально из пакета
@@ -52,6 +53,69 @@
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 с конвертом отказа: `code: 50000`, причина `invalid_credentials` и отсутствие `accessToken`
36. пароль предельной длины, назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными. Границ **две** — 6-64 символа Unicode и не более 72 байт в UTF-8 (предел bcrypt): пароль из 36 кириллических букв (72 байта) принимается, из 37 (74 байта) — отвергается конвертом валидации, а не системной ошибкой
37. `HY2XS_ADMIN_INITIAL_PASSWORD` с пробелом по краям доезжает до учётной записи неизменным: значение записано в `hy2xs.env` в двойных кавычках, и вход выполняется ровно им, а не обрезанным
37a. `HY2XS_ADMIN_INITIAL_PASSWORD` со значением вне документированного домена systemd (`U+FEFF`, `U+FDD0`, `U+FFFF`) или файл с байтами `0xFF`, оборванной `0xC3`, `ED A0 80` **роняет `preflight-install`** — отказ приходит до первой мутации хоста; сервер остаётся нетронутым
37b. API входа и импорта отвергает повреждённый UTF-8 и непарные UTF-16 surrogate escape до `encoding/json`; настоящий U+FFFD работает как обычный символ
38. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки
## C0. Панель обязана впускать, а не слушать порт
Проверки 1-4 отвечают на вопрос «поднялось ли», и ни одна из них не отвечает на
вопрос «работает ли». RC2 показал разницу: юнит активен, `127.0.0.1:8080` в
`LISTEN`, `/healthz` отвечает `ok: true` — и `POST /api/auth/login` отдаёт
HTTP 500 на каждый запрос, потому что валидатор паникует на теге
несуществующего правила. Установка при этом завершилась `INSTALL EXIT CODE: 0`.
Поэтому вход в панель проверяется **настоящим запросом**, а не косвенными
признаками, и эта проверка встроена в smoke оркестратора — то есть релиз с
недоступной панелью физически не может завершиться успешной установкой.
Ручной эквивалент:
```bash
# Значение читается ПО ФОРМАТУ, а не `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 \
--user-agent 'HY2XS-Installer/1.0' \
-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 \
--user-agent 'HY2XS-Installer/1.0' \
-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
```
Ожидается `20000` и ненулевая длина токена. Сам токен не печатается: это
действующая сессия администратора. Явный User-Agent обязателен: стандартный
`curl/<version>` осознанно отклоняется scanner middleware с HTTP 403 ещё до
разбора JSON. Значение совпадает с production smoke и не маскируется под браузер.
## C1. Семантический smoke конфига
@@ -179,4 +243,3 @@ idle timeout проходил семантическую проверку. То
Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами:
граница определяется значением, а не именем ключа.
+114 -58
View File
@@ -1,74 +1,130 @@
import { join } from "node:path";
import type { CommonOptions } from "../types/context";
import { InvalidUtf8Error, readText, writeTextAtomic } from "../lib/fs";
import { info, setOperationContext } from "../lib/log";
import { runMutating } from "../lib/process";
import { runMutatingArgv, runReadOnlyArgvStatus } from "../lib/process";
import { redactEnv, redactLogText, redactYaml } from "../lib/redaction";
import {
cleanupDiagnosticsWorkspace,
createDiagnosticsWorkspace,
discardDiagnosticsArchive,
finalizeDiagnosticsArchive
} from "../lib/diagnosticsStorage";
function shellEscapeSingleQuotes(value: string): string {
return value.replaceAll("'", "'\\''");
const unavailable = (reason: string): string =>
`[HY2XS: источник не включён в диагностику: ${reason}]\n`;
async function writeDiagnostic(outDir: string, name: string, content: string): Promise<void> {
await writeTextAtomic(join(outDir, name), content, { mode: 0o600, owner: "root", group: "root" });
}
/**
* Вывод сначала попадает в память, редактируется и лишь затем записывается.
* В staging-каталоге ни на мгновение нет сырого journal/systemctl-вывода с
* machine token.
*/
async function collectCommand(outDir: string, name: string, argv: string[]): Promise<void> {
try {
const result = await runReadOnlyArgvStatus(argv);
const combined = [result.stdout, result.stderr]
.filter(Boolean)
.join(result.stdout && result.stderr ? "\n" : "");
const status = result.exitCode === 0 ? "" : `[exit code: ${result.exitCode}]\n`;
const truncated = result.stdoutTruncated || result.stderrTruncated
? "[HY2XS: вывод обрезан по безопасному пределу 8 МиБ на поток]\n"
: "";
await writeDiagnostic(outDir, name, redactLogText(`${status}${truncated}${combined}`));
} catch (error) {
info(`diagnostics: ${name} пропущен: ${error instanceof Error ? error.message : String(error)}`);
await writeDiagnostic(outDir, name, unavailable("команда недоступна"));
}
}
/**
* Конфигурация никогда не копируется в staging как есть. Повреждённый UTF-8
* не декодируется с заменой: в бандл попадает только безопасное объяснение без
* исходных байтов.
*/
async function collectFile(
outDir: string,
name: string,
source: string,
sanitize: (content: string) => string
): Promise<void> {
await writeDiagnostic(outDir, name, await prepareDiagnosticFile(source, sanitize));
}
/** Чистая граница «прочитать строго -> санитизировать -> вернуть текст». */
export async function prepareDiagnosticFile(
source: string,
sanitize: (content: string) => string
): Promise<string> {
try {
const raw = await readText(source);
return sanitize(raw);
} catch (error) {
const reason = error instanceof InvalidUtf8Error ? "некорректный UTF-8" : "файл недоступен";
info(`diagnostics: ${source} пропущен: ${error instanceof Error ? error.message : String(error)}`);
return unavailable(reason);
}
}
export async function diagnosticsCollect(_options: CommonOptions): Promise<void> {
const opId = `diag-${Date.now().toString(36)}`;
setOperationContext(opId);
const outDir = `/var/log/hy2xs/diagnostics/${opId}`;
const archive = `/var/log/hy2xs/diagnostics/${opId}.tar.gz`;
await runMutating`mkdir -p ${outDir}`;
await runMutating`sh -c ${`systemctl status hysteria-server > '${shellEscapeSingleQuotes(`${outDir}/systemd-hysteria.txt`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`systemctl status hy2xs-admin > '${shellEscapeSingleQuotes(`${outDir}/systemd-admin.txt`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`journalctl -u hysteria-server -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-hysteria.log`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`journalctl -u hy2xs-admin -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-admin.log`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`nft list ruleset > '${shellEscapeSingleQuotes(`${outDir}/nftables.ruleset`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`uname -a > '${shellEscapeSingleQuotes(`${outDir}/uname.txt`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`cat /etc/os-release > '${shellEscapeSingleQuotes(`${outDir}/os-release.txt`)}' 2>&1 || true`}`;
await runMutating`sh -c ${`cp -a /etc/hysteria/post-install.env '${shellEscapeSingleQuotes(`${outDir}/post-install.env`)}' 2>/dev/null || true`}`;
await runMutating`sh -c ${`cp -a /etc/hy2xs/hy2xs.env '${shellEscapeSingleQuotes(`${outDir}/hy2xs.env`)}' 2>/dev/null || true`}`;
await runMutating`sh -c ${`cp -a /etc/hysteria/config.yaml '${shellEscapeSingleQuotes(`${outDir}/hysteria-config.yaml`)}' 2>/dev/null || true`}`;
await runMutating`sh -c ${`cp -a /var/lib/hy2xs/install-state.json '${shellEscapeSingleQuotes(`${outDir}/install-state.json`)}' 2>/dev/null || true`}`;
await runMutating`sh -c ${`ss -ltnup > '${shellEscapeSingleQuotes(`${outDir}/ss-ltnup.txt`)}' 2>&1 || true`}`;
const workspace = await createDiagnosticsWorkspace();
setOperationContext(workspace.operationId);
let archiveComplete = false;
let collectionFailed = false;
try {
const envRaw = await Bun.file(`${outDir}/hy2xs.env`).text();
await Bun.write(`${outDir}/hy2xs.env`, redactEnv(envRaw));
} catch {
// noop
}
await collectCommand(workspace.outDir, "systemd-hysteria.txt", ["systemctl", "status", "hysteria-server"]);
await collectCommand(workspace.outDir, "systemd-admin.txt", ["systemctl", "status", "hy2xs-admin"]);
await collectCommand(workspace.outDir, "journal-hysteria.log", ["journalctl", "-u", "hysteria-server", "-n", "300", "--no-pager"]);
await collectCommand(workspace.outDir, "journal-admin.log", ["journalctl", "-u", "hy2xs-admin", "-n", "300", "--no-pager"]);
await collectCommand(workspace.outDir, "nftables.ruleset", ["nft", "list", "ruleset"]);
await collectCommand(workspace.outDir, "uname.txt", ["uname", "-a"]);
await collectCommand(workspace.outDir, "ss-ltnup.txt", ["ss", "-ltnup"]);
try {
const postInstallRaw = await Bun.file(`${outDir}/post-install.env`).text();
await Bun.write(`${outDir}/post-install.env`, redactEnv(postInstallRaw));
} catch {
// noop
}
await collectFile(workspace.outDir, "os-release.txt", "/etc/os-release", redactLogText);
await collectFile(workspace.outDir, "post-install.env", "/etc/hysteria/post-install.env", redactEnv);
await collectFile(workspace.outDir, "hy2xs.env", "/etc/hy2xs/hy2xs.env", redactEnv);
await collectFile(workspace.outDir, "hysteria-config.yaml", "/etc/hysteria/config.yaml", redactYaml);
await collectFile(workspace.outDir, "install-state.json", "/var/lib/hy2xs/install-state.json", redactLogText);
try {
const cfgRaw = await Bun.file(`${outDir}/hysteria-config.yaml`).text();
await Bun.write(`${outDir}/hysteria-config.yaml`, redactYaml(cfgRaw));
} catch {
// noop
}
// Журналы и вывод systemctl — такой же артефакт, покидающий сервер, как env
// и YAML. Раньше редактировались только последние два, а journal-admin.log
// копировался как есть — и уносил machine token, который админка логировала
// в составе RequestURI.
for (const logFile of [
"journal-hysteria.log",
"journal-admin.log",
"systemd-hysteria.txt",
"systemd-admin.txt"
]) {
// Archive path уже создан через open("wx") внутри доверенного root-only
// namespace. argv-форма не запускает shell и сохраняет границы аргументов.
await runMutatingArgv(["tar", "-czf", workspace.archive, "-C", workspace.outDir, "."]);
await finalizeDiagnosticsArchive(workspace.archive);
archiveComplete = true;
} catch (error) {
collectionFailed = true;
throw error;
} finally {
const cleanupErrors: unknown[] = [];
try {
const raw = await Bun.file(`${outDir}/${logFile}`).text();
await Bun.write(`${outDir}/${logFile}`, redactLogText(raw));
} catch {
// noop
await cleanupDiagnosticsWorkspace(workspace);
} catch (error) {
cleanupErrors.push(error);
}
if (!archiveComplete) {
try {
await discardDiagnosticsArchive(workspace);
} catch (error) {
cleanupErrors.push(error);
}
}
if (cleanupErrors.length > 0) {
if (!collectionFailed) {
throw new AggregateError(cleanupErrors, "не удалось полностью очистить временные diagnostics-артефакты");
}
for (const cleanupError of cleanupErrors) {
info(
`diagnostics cleanup failed after the primary error: ${
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
}`
);
}
}
}
await runMutating`sh -c ${`tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`;
info(`diagnostics bundle collected: ${archive}`);
info(`diagnostics bundle collected: ${workspace.archive}`);
}
+106 -37
View File
@@ -1,17 +1,22 @@
import type { InstallContext, InstallOptions } from "../types/context";
import { fileExists, readText, writeTextAtomic } from "../lib/fs";
import { runMutatingVisible } from "../lib/process";
import { resetFailedUnit } from "../lib/systemd";
import { info, setOperationContext, step, stepDone } from "../lib/log";
import { readPackageValue } from "../lib/packageMeta";
import { REPAIR_HINT, buildInstallStateRecord } from "../lib/installState";
import { persistInstallState } from "../lib/installStateWriter";
import { persistFailureState, runRollbackStages, type RollbackStage } from "../lib/rollback";
import {
persistFailureState,
runRollbackStages,
type RollbackStage,
} from "../lib/rollback";
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import {
ORCHESTRATOR_INSTALL_DIR,
ORCHESTRATOR_INSTALL_PATH,
ORCHESTRATOR_SYMLINK_PATH,
RUNTIME_PACKAGE_DIR
RUNTIME_PACKAGE_DIR,
} from "../config/profile";
import { preflight } from "../steps/preflight";
import { bootstrapRuntime } from "../steps/bootstrap";
@@ -27,7 +32,7 @@ import {
cleanupFirewallRollback,
disarmFirewallRollback,
operationKeyFor,
rollbackFirewallNow
rollbackFirewallNow,
} from "../steps/firewall";
import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
import { smoke } from "../steps/smoke";
@@ -125,7 +130,7 @@ function newOwnership(): OperationOwnership {
firewallTouched: false,
postInstallTouched: false,
bootstrapSecretTouched: false,
servicesStarted: false
servicesStarted: false,
};
}
@@ -146,14 +151,14 @@ function installOwnedPaths(context: InstallContext): string[] {
ORCHESTRATOR_INSTALL_DIR,
ORCHESTRATOR_INSTALL_PATH,
ORCHESTRATOR_SYMLINK_PATH,
RUNTIME_PACKAGE_DIR
RUNTIME_PACKAGE_DIR,
];
}
async function writeInstallState(
context: InstallContext,
phase: InstallPhase,
lastError: string
lastError: string,
): Promise<void> {
const record = buildInstallStateRecord({
productVersion: context.packageVersion,
@@ -168,7 +173,7 @@ async function writeInstallState(
installed: phase === "installed",
ownedPaths: installOwnedPaths(context),
lastError,
repairHint: phase === "installed" ? undefined : REPAIR_HINT
repairHint: phase === "installed" ? undefined : REPAIR_HINT,
});
await persistInstallState(record);
@@ -178,7 +183,7 @@ async function advanceInstallState(
context: InstallContext,
ownership: OperationOwnership,
phase: InstallPhase,
lastError = ""
lastError = "",
): Promise<void> {
// Флаг взводится ПЕРЕД записью, а не после неё: см. комментарий к
// stateTouched. Частично выполненная запись маркера — это уже изменение
@@ -211,7 +216,7 @@ async function advanceInstallState(
export function classifyFailure(
ownership: OperationOwnership,
phase: InstallPhase,
error?: unknown
error?: unknown,
): FailureKind {
if (error instanceof FirewallGuardFiredError) {
return "firewall_guard_fired";
@@ -256,10 +261,12 @@ export function classifyFailure(
async function rollbackFailedInstall(
context: InstallContext,
ownership: OperationOwnership,
failureKind: FailureKind
failureKind: FailureKind,
): Promise<void> {
if (failureKind === "fatal_pre_apply") {
info("pre-apply failure: nothing was applied, system rollback is not required");
info(
"pre-apply failure: nothing was applied, system rollback is not required",
);
return;
}
@@ -275,7 +282,7 @@ async function rollbackFailedInstall(
name: "firewall",
run: async () => {
await rollbackFirewallNow(context);
}
},
});
}
@@ -295,23 +302,31 @@ async function rollbackFailedInstall(
name: "stop services",
run: async () => {
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin`;
}
},
},
{
name: "disable services",
run: async () => {
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin`;
}
},
},
{
name: "reset failed services",
name: "reset failed hysteria-server",
run: async () => {
await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin`;
}
}
await resetFailedUnit("hysteria-server");
},
},
{
name: "reset failed hy2xs-admin",
run: async () => {
await resetFailedUnit("hy2xs-admin");
},
},
);
} else {
info("rollback: systemd units were not deployed by this operation, leaving services untouched");
info(
"rollback: systemd units were not deployed by this operation, leaving services untouched",
);
}
await runRollbackStages(stages);
@@ -319,11 +334,15 @@ async function rollbackFailedInstall(
export async function install(options: InstallOptions): Promise<void> {
setOperationContext(`install-${Date.now().toString(36)}`);
const hasSourceConfig = options.sourceConfigPath ? await fileExists(options.sourceConfigPath) : false;
const hasSourceConfig = options.sourceConfigPath
? await fileExists(options.sourceConfigPath)
: false;
if (options.sourceConfigPath && !hasSourceConfig) {
throw new Error(`config source not found: ${options.sourceConfigPath}`);
}
const sourceConfigPath = hasSourceConfig ? options.sourceConfigPath : `${options.packageDir}/config/hy2xs.env`;
const sourceConfigPath = hasSourceConfig
? options.sourceConfigPath
: `${options.packageDir}/config/hy2xs.env`;
const sourceConfigRaw = await readText(sourceConfigPath);
const config = parseRuntimeEnv(sourceConfigRaw);
@@ -331,18 +350,48 @@ export async function install(options: InstallOptions): Promise<void> {
mode: "install",
options,
config,
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
packageVersion: await readPackageValue(
options.packageDir,
"package.version",
"unknown",
),
packageBuildId: await readPackageValue(
options.packageDir,
"package.build_id",
"unknown",
),
installDate: new Date().toISOString(),
hysteriaVersion: "unknown",
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown"),
hysteriaTargetVersion: await readPackageValue(options.packageDir, "hysteria.version", ""),
hysteriaArtifactUrl: await readPackageValue(options.packageDir, "hysteria.url", ""),
hysteriaArtifactSha256: await readPackageValue(options.packageDir, "hysteria.sha256", "")
hysteriaResolution: await readPackageValue(
options.packageDir,
"hysteria.resolution",
"unknown",
),
hysteriaTargetVersion: await readPackageValue(
options.packageDir,
"hysteria.version",
"",
),
hysteriaArtifactUrl: await readPackageValue(
options.packageDir,
"hysteria.url",
"",
),
hysteriaArtifactSha256: await readPackageValue(
options.packageDir,
"hysteria.sha256",
"",
),
};
if (!context.hysteriaTargetVersion || !context.hysteriaArtifactUrl || !context.hysteriaArtifactSha256) {
throw new Error("missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256");
if (
!context.hysteriaTargetVersion ||
!context.hysteriaArtifactUrl ||
!context.hysteriaArtifactSha256
) {
throw new Error(
"missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256",
);
}
const ownership = newOwnership();
@@ -356,7 +405,10 @@ export async function install(options: InstallOptions): Promise<void> {
// Всё, что дальше, уже создаёт наши собственные пути, и повторная проверка
// опознала бы их как чужую установку.
step("preflight");
await preflight(context, { requireCapabilities: false, checkCleanHost: true });
await preflight(context, {
requireCapabilities: false,
checkCleanHost: true,
});
stepDone("preflight");
await advanceInstallState(context, ownership, "preflight_ok");
@@ -372,7 +424,10 @@ export async function install(options: InstallOptions): Promise<void> {
await installDeps(context);
stepDone("system dependencies");
step("preflight capabilities");
await preflight(context, { requireCapabilities: true, checkCleanHost: false });
await preflight(context, {
requireCapabilities: true,
checkCleanHost: false,
});
stepDone("preflight capabilities");
await advanceInstallState(context, ownership, "deps_ok");
phase = "deps_ok";
@@ -387,7 +442,7 @@ export async function install(options: InstallOptions): Promise<void> {
await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), {
mode: 0o600,
owner: "root",
group: "root"
group: "root",
});
stepDone("write runtime env");
await advanceInstallState(context, ownership, "runtime_env_written");
@@ -468,8 +523,13 @@ export async function install(options: InstallOptions): Promise<void> {
try {
await cleanupFirewallRollback(context);
} catch (cleanupError) {
const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
info(`firewall rollback data cleanup failed after a successful install: ${cleanupMessage}`);
const cleanupMessage =
cleanupError instanceof Error
? cleanupError.message
: String(cleanupError);
info(
`firewall rollback data cleanup failed after a successful install: ${cleanupMessage}`,
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -498,7 +558,12 @@ export async function install(options: InstallOptions): Promise<void> {
: "failed";
await persistFailureState(() =>
advanceInstallState(context, ownership, failurePhase, `${failureKind}: ${message}`)
advanceInstallState(
context,
ownership,
failurePhase,
`${failureKind}: ${message}`,
),
);
// Диагностика — best effort, откат — обязателен.
@@ -512,8 +577,12 @@ export async function install(options: InstallOptions): Promise<void> {
await diagnosticsCollect(options);
} catch (diagnosticsError) {
const diagnosticsMessage =
diagnosticsError instanceof Error ? diagnosticsError.message : String(diagnosticsError);
info(`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`);
diagnosticsError instanceof Error
? diagnosticsError.message
: String(diagnosticsError);
info(
`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`,
);
}
await rollbackFailedInstall(context, ownership, failureKind);
+126 -59
View File
@@ -1,9 +1,12 @@
import { randomBytes } from "node:crypto";
import type { FirewallMode, PublicEndpointPolicy, RuntimeConfig, TlsMode } from "../types/context";
import { assertEnvTransportable, parseEnvFile, renderEnvFile } from "../lib/envFile";
import {
GECKO_DEFAULT_MAX_PACKET_SIZE,
GECKO_DEFAULT_MIN_PACKET_SIZE,
HY2XS_CONFIG_SCHEMA_VERSION,
assertValidAdminPassword,
assertValidAdminUsername,
normalizeHysteriaObfsType,
validateGeckoPacketSizes
} from "./profile";
@@ -21,22 +24,17 @@ function valueOrGenerate(value: string | undefined): string {
return value;
}
/**
* Разбор env-файла делегирован lib/envFile.
*
* Здесь стоял построчный разбор с `value.trim()`, и он молча срезал пробелы по
* краям КАЖДОГО значения. Для пароля администратора это прямое нарушение
* контракта продукт объявляет краевой пробел частью пароля, а для
* значений с кавычками разбор вдобавок расходился с systemd, который читает
* этот же файл как EnvironmentFile.
*/
function parseEnv(content: string): EnvMap {
const result: EnvMap = {};
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;
return parseEnvFile(content);
}
function parsePort(name: string, raw: string, fallback: number): number {
@@ -223,6 +221,11 @@ function normalizeSafeAbsolutePath(name: string, value: string, options?: { disa
}
export function parseRuntimeEnv(content: string): RuntimeConfig {
// Проверяется ИСХОДНЫЙ текст до разбора. Иначе запрещённый символ в
// комментарии, неизвестном ключе или перед первым ключом мог бы исчезнуть
// при нормализации, и preflight объявил бы повреждённый EnvironmentFile
// корректным. Нормализованные значения проверяются повторно ниже.
assertEnvTransportable("runtime EnvironmentFile", content);
const env = parseEnv(content);
const uiPort = parsePort("HY2XS_UI_PORT", env.HY2XS_UI_PORT, 8080);
@@ -250,8 +253,23 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
uiBindHost,
uiPublicAccess: parseBool("HY2XS_UI_PUBLIC_ACCESS", env.HY2XS_UI_PUBLIC_ACCESS, false),
uiPort,
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"),
adminInitialPassword: valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD),
// Умолчание — `hy2xsadmin`, и оно совпадает с package/config/hy2xs.env и с
// запасным значением в apps/dao/sqlite.go. Раньше здесь стояло `admin`:
// пять символов при минимуме панели в шесть, и третье расходящееся
// умолчание про одно и то же имя. Установка при этом завершалась успешно, а
// войти было нельзя — отказ приходил не установщику, а оператору, и уже без
// объяснения.
adminUser: assertValidAdminUsername(
"HY2XS_ADMIN_USER",
requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "hy2xsadmin")
),
// Проверяется и сгенерированный пароль, а не только заданный оператором:
// генератор — такой же источник значения, и его расхождение с контрактом
// панели обязано ронять установку, а не всплывать на форме входа.
adminInitialPassword: assertValidAdminPassword(
"HY2XS_ADMIN_INITIAL_PASSWORD",
valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD)
),
adminConPass: requireValue("HY2XS_ADMIN_CON_PASS", valueOrGenerate(env.HY2XS_ADMIN_CON_PASS)),
forcePasswordChange: parseBool("HY2XS_FORCE_PASSWORD_CHANGE", env.HY2XS_FORCE_PASSWORD_CHANGE, false),
allowSelfSignedDev: parseBool("HY2XS_ALLOW_SELF_SIGNED_DEV", env.HY2XS_ALLOW_SELF_SIGNED_DEV, false),
@@ -296,6 +314,18 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
};
validateRuntimeConfig(config);
// Проверка транспорта идёт ЗДЕСЬ, а не при записи файла.
//
// Раньше она жила только внутри renderRuntimeEnv, то есть срабатывала на шаге
// «write runtime env» — уже ПОСЛЕ bootstrap оркестратора, установки пакетов и
// раскладки файловой системы. Детерминированно известная ошибка конфигурации
// роняла операцию, оставив за собой изменённый хост, а read-only
// `preflight-install` про неё говорил PASS: он вызывает parseRuntimeEnv и не
// рендерит ничего.
//
// Это противоречит контракту PHASE 0: всё, что про конфигурацию известно
// детерминированно, обязано быть отвергнуто ДО первой необратимой мутации.
validateRuntimeEnvTransport(config);
return config;
}
@@ -379,47 +409,84 @@ export function validateRuntimeConfig(config: RuntimeConfig): void {
}
}
export function renderRuntimeEnv(config: RuntimeConfig): string {
const lines = [
"# HY2XS runtime config (editable)",
`HY2XS_CONFIG_SCHEMA_VERSION=${config.configSchemaVersion}`,
`HY2XS_IPV6_ENABLED=${config.ipv6Enabled}`,
`HY2XS_DOMAIN=${config.domain}`,
`HY2XS_DNS_AAAA_POLICY=${config.dnsAaaaPolicy}`,
`HY2XS_PUBLIC_ENDPOINT_POLICY=${config.publicEndpointPolicy}`,
`HY2XS_PUBLIC_HOST=${config.publicHost}`,
`HY2XS_PUBLIC_PORT=${config.publicPort}`,
`HY2XS_SSH_PORT=${config.sshPort}`,
`HY2XS_FIREWALL_MODE=${config.firewallMode}`,
`HY2XS_FIREWALL_STAGED_APPLY=${config.firewallStagedApply}`,
`HY2XS_UI_BIND_HOST=${config.uiBindHost}`,
`HY2XS_UI_PUBLIC_ACCESS=${config.uiPublicAccess}`,
`HY2XS_UI_PORT=${config.uiPort}`,
`HY2XS_ADMIN_USER=${config.adminUser}`,
`HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`,
`HY2XS_ADMIN_CON_PASS=${config.adminConPass}`,
`HY2XS_FORCE_PASSWORD_CHANGE=${config.forcePasswordChange}`,
`HY2XS_ALLOW_SELF_SIGNED_DEV=${config.allowSelfSignedDev}`,
`HY2XS_TLS_MODE=${config.tlsMode}`,
`HY2XS_ACME_TYPE=${config.acmeType}`,
`HY2XS_ACME_EMAIL=${config.acmeEmail}`,
`HY2XS_TLS_CERT_PATH=${config.tlsCertPath}`,
`HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`,
`HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`,
`HY2XS_HYSTERIA_PORT=${config.hysteriaPort}`,
`HY2XS_HYSTERIA_AUTH_MODE=${config.hysteriaAuthMode}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=${config.hysteriaTrafficStatsHost}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${config.hysteriaTrafficStatsSecret}`,
`HY2XS_HYSTERIA_OBFS_TYPE=${config.hysteriaObfsType}`,
`HY2XS_HYSTERIA_OBFS_PASSWORD=${config.hysteriaObfsPassword}`,
`HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`,
`HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`,
`HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=${config.hysteriaIgnoreClientBandwidth}`,
`HY2XS_HYSTERIA_CONFIG_PATH=${config.hysteriaConfigPath}`,
`HY2XS_INSTALL_DIR=${config.installDir}`,
`HY2XS_DATA_DIR=${config.dataDir}`,
`HY2XS_LOG_DIR=${config.logDir}`
/**
* Пары `KEY=VALUE`, которые уезжают в /etc/hy2xs/hy2xs.env.
*
* Вынесены из renderRuntimeEnv, потому что у списка ДВА потребителя: запись
* файла и проверка транспорта, выполняемая задолго до неё. Пока список
* существовал только внутри рендера, единственным способом узнать, что
* конфигурация не запишется, было её записать.
*/
export function runtimeEnvEntries(config: RuntimeConfig): [string, string][] {
return [
["HY2XS_CONFIG_SCHEMA_VERSION", String(config.configSchemaVersion)],
["HY2XS_IPV6_ENABLED", String(config.ipv6Enabled)],
["HY2XS_DOMAIN", config.domain],
["HY2XS_DNS_AAAA_POLICY", config.dnsAaaaPolicy],
["HY2XS_PUBLIC_ENDPOINT_POLICY", config.publicEndpointPolicy],
["HY2XS_PUBLIC_HOST", config.publicHost],
["HY2XS_PUBLIC_PORT", String(config.publicPort)],
["HY2XS_SSH_PORT", String(config.sshPort)],
["HY2XS_FIREWALL_MODE", config.firewallMode],
["HY2XS_FIREWALL_STAGED_APPLY", String(config.firewallStagedApply)],
["HY2XS_UI_BIND_HOST", config.uiBindHost],
["HY2XS_UI_PUBLIC_ACCESS", String(config.uiPublicAccess)],
["HY2XS_UI_PORT", String(config.uiPort)],
["HY2XS_ADMIN_USER", config.adminUser],
["HY2XS_ADMIN_INITIAL_PASSWORD", config.adminInitialPassword],
["HY2XS_ADMIN_CON_PASS", config.adminConPass],
["HY2XS_FORCE_PASSWORD_CHANGE", String(config.forcePasswordChange)],
["HY2XS_ALLOW_SELF_SIGNED_DEV", String(config.allowSelfSignedDev)],
["HY2XS_TLS_MODE", config.tlsMode],
["HY2XS_ACME_TYPE", config.acmeType],
["HY2XS_ACME_EMAIL", config.acmeEmail],
["HY2XS_TLS_CERT_PATH", config.tlsCertPath],
["HY2XS_TLS_KEY_PATH", config.tlsKeyPath],
["HY2XS_HYSTERIA_BIND_HOST", config.hysteriaBindHost],
["HY2XS_HYSTERIA_PORT", String(config.hysteriaPort)],
["HY2XS_HYSTERIA_AUTH_MODE", config.hysteriaAuthMode],
["HY2XS_HYSTERIA_TRAFFIC_STATS_HOST", config.hysteriaTrafficStatsHost],
["HY2XS_HYSTERIA_TRAFFIC_STATS_PORT", String(config.hysteriaTrafficStatsPort)],
["HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", config.hysteriaTrafficStatsSecret],
["HY2XS_HYSTERIA_OBFS_TYPE", config.hysteriaObfsType],
["HY2XS_HYSTERIA_OBFS_PASSWORD", config.hysteriaObfsPassword],
["HY2XS_HYSTERIA_BANDWIDTH_UP", config.hysteriaBandwidthUp],
["HY2XS_HYSTERIA_BANDWIDTH_DOWN", config.hysteriaBandwidthDown],
["HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH", String(config.hysteriaIgnoreClientBandwidth)],
["HY2XS_HYSTERIA_CONFIG_PATH", config.hysteriaConfigPath],
["HY2XS_INSTALL_DIR", config.installDir],
["HY2XS_DATA_DIR", config.dataDir],
["HY2XS_LOG_DIR", config.logDir]
];
return `${lines.join("\n")}\n`;
}
/**
* Отвергает конфигурацию, которую нельзя записать в файл окружения так, чтобы
* systemd её прочитал.
*
* Проверяются ВСЕ значения, а не только пароль администратора. Ограничение
* принадлежит формату, а не одному полю: `HY2XS_ADMIN_CON_PASS`,
* `HY2XS_HYSTERIA_OBFS_PASSWORD` и любой будущий параметр сломали бы загрузку
* юнита ровно тем же способом.
*/
export function validateRuntimeEnvTransport(config: RuntimeConfig): void {
for (const [key, value] of runtimeEnvEntries(config)) {
assertEnvTransportable(key, value);
}
}
/**
* Пишет /etc/hy2xs/hy2xs.env.
*
* Каждое значение проходит через formatEnvAssignment, а не подставляется в
* строку интерполяцией. Раньше подставлялось, и файл поэтому был форматом
* только для значений без пробелов по краям, кавычек и обратных слешей: пароль
* администратора, у которого набор символов объявлен неограниченным, не
* пережил бы обратного чтения ни нашего, ни systemd'ного.
*
* Обычные значения (порты, пути, домены, `50 mbps`) кавычек не получают и
* остаются побайтово прежними см. UNQUOTED_SAFE_VALUE в lib/envFile.
*/
export function renderRuntimeEnv(config: RuntimeConfig): string {
return `# HY2XS runtime config (editable)\n${renderEnvFile(runtimeEnvEntries(config))}`;
}
+146
View File
@@ -1,4 +1,5 @@
import type { HysteriaObfsType, RuntimeConfig } from "../types/context";
import { isEnvTransportable } from "../lib/envFile";
/**
* HY2XS production profile: единственное место, где определены значения
@@ -41,6 +42,151 @@ export const HY2XS_TARGET_ARCH = "amd64";
export const ADMIN_API_BASE = "/api";
export const HYSTERIA_MACHINE_AUTH_PATH = "/internal/hysteria/auth";
/**
* Путь формы входа в панель. Смысл тот же, что у HYSTERIA_MACHINE_AUTH_PATH:
* это runtime-контракт продукта, по которому smoke проверяет, что установка
* оставила после себя РАБОТАЮЩУЮ панель, а не просто открытый порт.
*/
export const ADMIN_LOGIN_PATH = `${ADMIN_API_BASE}/auth/login`;
/**
* Контракт учётных данных администратора.
*
* Зачем он здесь. Оркестратор задаёт имя и первый пароль администратора, а
* принимает их панель по правилам, которых оркестратор не знал вовсе.
* Следствие было не теоретическим: значением по умолчанию здесь стояло
* `admin` пять символов при минимуме в шесть, и такая установка
* завершалась `INSTALL EXIT CODE: 0`, оставляя панель, в которую невозможно
* войти. Проверять контракт обязана та сторона, которая значение ПОРОЖДАЕТ:
* отказ установки чинится одной строкой в hy2xs.env, а неработающий вход на
* готовом сервере переустановкой.
*
* Значения обязаны совпадать с apps/credential/admin.go; сверка выполняется
* тестом admin-credentials.test.ts, который читает Go-исходник.
*/
export const ADMIN_USERNAME_MIN_LENGTH = 6;
export const ADMIN_USERNAME_MAX_LENGTH = 32;
export const ADMIN_PASSWORD_MIN_LENGTH = 6;
export const ADMIN_PASSWORD_MAX_LENGTH = 64;
/**
* Предел bcrypt в БАЙТАХ.
*
* Он существует одновременно с границей в символах и не выводится из неё: у 64
* символов длина от 64 до 256 байт в зависимости от алфавита. Пока оркестратор
* знал только границу в символах, он пропускал в hy2xs.env пароль вроде 64
* кириллических букв (128 байт), установка проходила целиком, а первая учётная
* запись администратора не создавалась вовсе bcrypt отвечал
* ErrPasswordTooLong уже внутри админки, при старте службы.
*/
export const ADMIN_PASSWORD_MAX_BYTES = 72;
/**
* Набор символов логина в записи регекспа.
*
* Дефис ЭКРАНИРОВАН намеренно. В исходной записи `_+-=` он экранирован не был,
* из-за чего `+-=` образовывал диапазон и молча впускал `, - . / 0-9 : ; < =`.
* Здесь перечислено то же самое ФАКТИЧЕСКОЕ множество, но явно: сужать его в
* одиночку нельзя оно уже действует на установленных серверах.
*/
const ADMIN_USERNAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+,\\-./:;<=";
export const ADMIN_USERNAME_PATTERN = new RegExp(
`^[${ADMIN_USERNAME_CHARACTER_CLASS}]{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}}$`
);
/** Тот же набор в том виде, в каком его показывают оператору. */
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
/**
* Проверка логина администратора против контракта панели.
*
* Возвращает значение, а не булево: вызывающий обязан использовать именно
* проверенное с обрезанными краями, иначе пробел из hy2xs.env уедет в базу
* и вход снова перестанет работать по причине, которую негде увидеть.
*/
export function assertValidAdminUsername(name: string, value: string): string {
const username = value.trim();
if (!ADMIN_USERNAME_PATTERN.test(username)) {
throw new Error(
`invalid ${name}: панель принимает от ${ADMIN_USERNAME_MIN_LENGTH} до ${ADMIN_USERNAME_MAX_LENGTH} ` +
`символов из набора ${ADMIN_USERNAME_CHARSET}. ` +
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
);
}
return username;
}
/**
* Единственное правило пароля администратора на стороне оркестратора.
*
* Копия Go-контракта, и сверяется с ним по исходнику (admin-credentials.test.ts):
* оркестратор собирается отдельно от Go-бинарника и импортировать его константы
* не может.
*
* Правило состоит из трёх частей, и каждая закрывает свой класс отказа.
*
* Длина в CODE POINTS, а не в `String.length`. `String.length` считает единицы
* UTF-16, и пароль из трёх эмодзи имел бы здесь длину 6 прошёл бы минимум и
* получил отказ на форме входа, где сервер видит три руны.
*
* Длина в БАЙТАХ предел bcrypt. Пока его не было, пароль из 64 кириллических
* букв проходил установку целиком, а учётная запись администратора не
* создавалась: bcrypt отвечал ErrPasswordTooLong при старте админки, и оператор
* получал не отказ установки, а сервер без администратора.
*
* Управляющие символы формат EnvironmentFile способен нести; их отвергает
* политика HY2XS, потому что невидимое значение нельзя надёжно повторить в
* однострочной форме входа. Транспортный домен (включая U+FEFF) проверяется
* отдельно в lib/envFile.ts.
*
* Набор символов сверх этого НЕ ограничивается: сервер его не ограничивает ни
* при установке, ни при смене пароля, и оркестратор не имеет права быть строже.
* Пробелы по краям часть пароля и не снимаются.
*/
export function isValidAdminPassword(value: string): boolean {
// Домен транспорта проверяется ПЕРВЫМ: значение вне него не доедет до админки
// вовсе — systemd откажется загружать /etc/hy2xs/hy2xs.env, и юнит не
// стартует. Это отказ более грубого рода, чем нарушение границ длины, и
// раньше контракт про него не знал: `abcde` плюс U+FDD0 — шесть символов,
// восемь байт, ни одного управляющего — проходило здесь, записывалось в файл
// и оставляло сервер без работающей панели.
if (!isEnvTransportable(value)) {
return false;
}
let characters = 0;
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
// Продуктовая политика: управляющие символы Unicode целиком (Cc — C0,
// DEL, C1). U+FEFF уже отвергнут транспортным доменом выше.
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
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 {
if (!isValidAdminPassword(value)) {
throw new Error(
`invalid ${name}: панель принимает пароль длиной от ${ADMIN_PASSWORD_MIN_LENGTH} ` +
`до ${ADMIN_PASSWORD_MAX_LENGTH} символов Unicode, не длиннее ${ADMIN_PASSWORD_MAX_BYTES} байт ` +
`в UTF-8, без управляющих символов и U+FEFF; получено ${[...value].length} символов ` +
`и ${Buffer.byteLength(value, "utf8")} байт. ` +
`Набор символов не ограничен, пробелы по краям являются частью пароля. ` +
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
);
}
return value;
}
/**
* Где оркестратор живёт на установленном хосте.
*
+253
View File
@@ -0,0 +1,253 @@
import { chmod, chown, lstat, mkdir, mkdtemp, open, rm, unlink } from "node:fs/promises";
import type { Stats } from "node:fs";
import { basename, dirname, join } from "node:path";
import { assertMutationAllowed } from "./guard";
import { INSTALL_STATE_DIR } from "./installState";
export const DIAGNOSTICS_ROOT = `${INSTALL_STATE_DIR}/diagnostics`;
const ROOT_UID = 0;
const ROOT_GID = 0;
const DIAGNOSTICS_MODE = 0o700;
const ARCHIVE_MODE = 0o600;
const HAS_POSIX_MODE_SEMANTICS = process.platform !== "win32";
type DirectoryPolicy = {
uid: number;
gid: number;
exactMode?: number;
rejectGroupOrOtherWrite?: boolean;
};
export type DiagnosticsStorageOptions = {
stateRoot?: string;
diagnosticsRoot?: string;
uid?: number;
gid?: number;
};
export type DiagnosticsWorkspace = {
operationId: string;
outDir: string;
archive: string;
};
function errnoCode(error: unknown): string | undefined {
return error && typeof error === "object" && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
}
async function lstatOptional(path: string): Promise<Stats | null> {
try {
return await lstat(path);
} catch (error) {
if (errnoCode(error) === "ENOENT") {
return null;
}
throw error;
}
}
function octal(mode: number): string {
return (mode & 0o777).toString(8).padStart(4, "0");
}
/**
* Проверяет каталог именно по lstat-снимку: stat здесь запрещён, поскольку он
* скрыл бы symlink и тем самым снова перенёс root-запись в чужой namespace.
*/
export function assertTrustedDirectory(path: string, stats: Stats, policy: DirectoryPolicy): void {
if (stats.isSymbolicLink()) {
throw new Error(`небезопасный каталог ${path}: symbolic link запрещён`);
}
if (!stats.isDirectory()) {
throw new Error(`небезопасный каталог ${path}: ожидается обычный каталог`);
}
if (stats.uid !== policy.uid || stats.gid !== policy.gid) {
throw new Error(
`небезопасный каталог ${path}: ожидается владелец ${policy.uid}:${policy.gid}, ` +
`получен ${stats.uid}:${stats.gid}`
);
}
const mode = stats.mode & 0o777;
if (policy.exactMode !== undefined && mode !== policy.exactMode) {
throw new Error(
`небезопасный каталог ${path}: ожидается режим ${octal(policy.exactMode)}, получен ${octal(mode)}`
);
}
if (policy.rejectGroupOrOtherWrite && (mode & 0o022) !== 0) {
throw new Error(
`небезопасный каталог ${path}: группа или остальные могут изменять root namespace (режим ${octal(mode)})`
);
}
}
function resolveOptions(options: DiagnosticsStorageOptions = {}): Required<DiagnosticsStorageOptions> {
const stateRoot = options.stateRoot ?? INSTALL_STATE_DIR;
const diagnosticsRoot = options.diagnosticsRoot ?? join(stateRoot, "diagnostics");
const uid = options.uid ?? ROOT_UID;
const gid = options.gid ?? ROOT_GID;
if (dirname(diagnosticsRoot) !== stateRoot) {
throw new Error(`diagnostics root обязан быть непосредственным дочерним каталогом ${stateRoot}`);
}
return { stateRoot, diagnosticsRoot, uid, gid };
}
function assertWorkspacePaths(
workspace: DiagnosticsWorkspace,
options: DiagnosticsStorageOptions = {}
): void {
const resolved = resolveOptions(options);
const directoryName = basename(workspace.outDir);
if (
dirname(workspace.outDir) !== resolved.diagnosticsRoot ||
!/^diag-[A-Za-z0-9]{6}$/.test(directoryName) ||
workspace.operationId !== directoryName ||
workspace.archive !== `${workspace.outDir}.tar.gz`
) {
throw new Error(`небезопасные пути diagnostics workspace: ${workspace.outDir}`);
}
}
/**
* Создаёт и затем повторно проверяет root-only namespace диагностики.
*
* Родитель уже принадлежит install-state и недоступен сервисному пользователю.
* Неверные существующие права не «исправляются» автоматически: это может быть
* следом подмены, поэтому граница привилегий обязана отказать fail closed.
*/
export async function ensureDiagnosticsStorageRoot(
options: DiagnosticsStorageOptions = {}
): Promise<string> {
assertMutationAllowed("ensureDiagnosticsStorageRoot");
const resolved = resolveOptions(options);
const stateStats = await lstatOptional(resolved.stateRoot);
if (!stateStats) {
throw new Error(`доверенный каталог состояния отсутствует: ${resolved.stateRoot}`);
}
assertTrustedDirectory(resolved.stateRoot, stateStats, {
uid: resolved.uid,
gid: resolved.gid,
rejectGroupOrOtherWrite: HAS_POSIX_MODE_SEMANTICS
});
let created = false;
try {
await mkdir(resolved.diagnosticsRoot, { mode: DIAGNOSTICS_MODE });
created = true;
} catch (error) {
// Параллельные read-only diagnostics не берут operation lock и законно
// могут одновременно увидеть отсутствующий каталог. Победитель создаёт
// его, проигравший всё равно обязан проверить итоговый inode через lstat.
if (errnoCode(error) !== "EEXIST") {
throw error;
}
}
if (created) {
// mkdir применяет umask. Под доверенным root-only родителем pathname нельзя
// подменить непривилегированным процессом, поэтому режим можно довести до
// точного публичного контракта и затем проверить ещё раз.
if (HAS_POSIX_MODE_SEMANTICS) {
await chown(resolved.diagnosticsRoot, resolved.uid, resolved.gid);
}
await chmod(resolved.diagnosticsRoot, DIAGNOSTICS_MODE);
}
const diagnosticsStats = await lstat(resolved.diagnosticsRoot);
assertTrustedDirectory(resolved.diagnosticsRoot, diagnosticsStats, {
uid: resolved.uid,
gid: resolved.gid,
exactMode: HAS_POSIX_MODE_SEMANTICS ? DIAGNOSTICS_MODE : undefined
});
return resolved.diagnosticsRoot;
}
/** Резервирует непредсказуемые рабочий каталог и archive path эксклюзивно. */
export async function createDiagnosticsWorkspace(
options: DiagnosticsStorageOptions = {}
): Promise<DiagnosticsWorkspace> {
assertMutationAllowed("createDiagnosticsWorkspace");
const root = await ensureDiagnosticsStorageRoot(options);
const outDir = await mkdtemp(join(root, "diag-"));
try {
const resolved = resolveOptions(options);
if (HAS_POSIX_MODE_SEMANTICS) {
await chown(outDir, resolved.uid, resolved.gid);
}
await chmod(outDir, DIAGNOSTICS_MODE);
assertTrustedDirectory(outDir, await lstat(outDir), {
uid: resolved.uid,
gid: resolved.gid,
exactMode: HAS_POSIX_MODE_SEMANTICS ? DIAGNOSTICS_MODE : undefined
});
const archive = `${outDir}.tar.gz`;
const archiveHandle = await open(archive, "wx", ARCHIVE_MODE);
try {
if (HAS_POSIX_MODE_SEMANTICS) {
await archiveHandle.chown(resolved.uid, resolved.gid);
}
await archiveHandle.chmod(ARCHIVE_MODE);
} finally {
await archiveHandle.close();
}
return { operationId: basename(outDir), outDir, archive };
} catch (error) {
await rm(outDir, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
}
export async function assertDiagnosticsArchive(path: string, options: DiagnosticsStorageOptions = {}): Promise<void> {
const resolved = resolveOptions(options);
if (dirname(path) !== resolved.diagnosticsRoot || !/^diag-[A-Za-z0-9]{6}\.tar\.gz$/.test(basename(path))) {
throw new Error(`небезопасный путь diagnostics archive: ${path}`);
}
const stats = await lstat(path);
if (stats.isSymbolicLink() || !stats.isFile()) {
throw new Error(`небезопасный diagnostics archive ${path}: ожидается обычный файл`);
}
if (stats.uid !== resolved.uid || stats.gid !== resolved.gid) {
throw new Error(`небезопасный diagnostics archive ${path}: неверный владелец`);
}
if (HAS_POSIX_MODE_SEMANTICS && (stats.mode & 0o777) !== ARCHIVE_MODE) {
throw new Error(`небезопасный diagnostics archive ${path}: ожидается режим 0600`);
}
}
export async function finalizeDiagnosticsArchive(
path: string,
options: DiagnosticsStorageOptions = {}
): Promise<void> {
assertMutationAllowed("finalizeDiagnosticsArchive");
await chmod(path, ARCHIVE_MODE);
await assertDiagnosticsArchive(path, options);
}
export async function cleanupDiagnosticsWorkspace(
workspace: DiagnosticsWorkspace,
options: DiagnosticsStorageOptions = {}
): Promise<void> {
assertMutationAllowed("cleanupDiagnosticsWorkspace");
assertWorkspacePaths(workspace, options);
await rm(workspace.outDir, { recursive: true, force: true });
}
export async function discardDiagnosticsArchive(
workspace: DiagnosticsWorkspace,
options: DiagnosticsStorageOptions = {}
): Promise<void> {
assertMutationAllowed("discardDiagnosticsArchive");
assertWorkspacePaths(workspace, options);
await unlink(workspace.archive).catch((error) => {
if (errnoCode(error) !== "ENOENT") {
throw error;
}
});
}
+399
View File
@@ -0,0 +1,399 @@
/**
* Формат файлов `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.
*
* Расхождений с upstream ровно два, оба намеренные и оба FAIL-CLOSED:
*
* 1. строка без `=` ОШИБКА, а не пропуск. systemd такую строку молча
* отбрасывает; молчаливая потеря строки из /etc/hy2xs/hy2xs.env означала
* бы установку с настройкой, которую оператор задал, а продукт не увидел;
* 2. незакрытая кавычка или escape в конце файла ОШИБКА. systemd в
* состояниях VALUE_ESCAPE / SINGLE_QUOTE_VALUE / DOUBLE_QUOTE_VALUE
* принимает на EOF то, что успел накопить; для конфигурации, от которой
* зависит доступ в панель, «что успели накопить» не ответ.
*
* Оба расхождения останавливают операцию там, где её можно починить, вместо
* того чтобы применить не то, что написано в файле.
*/
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 (c !== "\n") {
// Обратный слеш СОХРАНЯЕТСЯ вместе с символом — «как делает
// настоящий shell», по формулировке самого systemd.
//
// Условие здесь `c !== "\n"`, а НЕ проверка на любой перевод строки.
// Это не описка upstream и не описка порта: в состоянии VALUE_ESCAPE
// systemd пишет `!strchr(NEWLINE, c)` и съедает и LF, и CR, а здесь —
// `c != '\n'`, то есть `\<CR>` даёт `\` + CR. Порт обязан повторять
// это буквально: иначе значение с `\<CR>` мы прочитали бы иначе, чем
// тот, для кого файл в конечном счёте написан.
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_\-.\/:@,=+%]+)*$/;
/** Печатает код символа так, как его принято называть в отчётах об ошибке. */
function describeCodePoint(code: number): string {
return `U+${code.toString(16).toUpperCase().padStart(4, "0")}`;
}
/**
* Документированный домен значений EnvironmentFile.
*
* Это чужое множество, а не наша политика, и оно проверяется отдельно именно
* поэтому: нарушение здесь не «некрасивое значение», а НЕзагруженный файл
* окружения и, следовательно, юнит, который не стартует.
*
* Публичная документация systemd дополнительно запрещает U+FEFF. Реализация
* v257.13 по ошибке пропускает его из-за маски в `unichar_is_valid`; HY2XS
* следует документированному контракту, а не случайному расхождению одной
* версии реализации. Перед тем как принять пару, systemd прогоняет ключ и значение через
* `utf8_is_valid` (src/basic/env-file.c, `check_utf8ness_and_warn`), и отказ там
* возвращает `-EINVAL`. `utf8_is_valid` отвергает встроенный NUL и всё, что не
* является Unicode scalar value, а `unichar_is_valid` (src/basic/utf8.c) сверх
* того отвергает:
*
* U+D800..U+DFFF суррогаты
* U+FDD0..U+FDEF noncharacters
* (cp & 0xFFFE) === 0xFFFE U+FFFE, U+FFFF, U+1FFFE, U+10FFFF
*
* Одиночные суррогаты проверяются ОТДЕЛЬНО и по своей причине. Строка
* JavaScript это последовательность единиц UTF-16, и она вправе содержать
* непарный суррогат; `TextEncoder` при кодировании молча заменит его на U+FFFD.
* То есть без этой проверки отказа не было бы вовсе было бы тихое ИЗМЕНЕНИЕ
* секрета по дороге в файл.
*
* Продуктовых ограничений здесь нет: управляющие символы формат несёт, и
* запрещает их контракт учётных данных, а не транспорт.
*/
export function isEnvTransportable(value: string): boolean {
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
if (code === 0 || code === 0xfeff) {
return false;
}
if (code >= 0xd800 && code <= 0xdfff) {
return false;
}
if (code >= 0xfdd0 && code <= 0xfdef) {
return false;
}
if ((code & 0xfffe) === 0xfffe) {
return false;
}
}
return true;
}
/**
* Проверяет, что значение вообще представимо в этом формате, и называет
* причину.
*
* Раньше здесь проверялись только C0 и DEL, а сообщение утверждало, что формат
* «управляющих символов не несёт». Оба утверждения были неверны: управляющие
* символы формат несёт (их запрещает продуктовая политика), а НЕ несёт он
* noncharacters и суррогаты ровно то, чего проверка не знала. Значение вроде
* `abcde﷐` проходило все двери HY2XS, попадало в /etc/hy2xs/hy2xs.env, и
* админка после этого не стартовала.
*
* Отказ здесь громкий намеренно: молчаливая потеря или подмена части секрета
* означала бы установку, после которой невозможно войти, и причину, которой
* негде увидеться.
*/
export function assertEnvTransportable(name: string, value: string): string {
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
if (isEnvTransportable(character)) {
continue;
}
throw new Error(
`${name} contains ${describeCodePoint(code)}, which is forbidden by the documented systemd ` +
`EnvironmentFile contract: значение обязано быть валидным UTF-8 из Unicode scalar values, ` +
`без NUL, U+FEFF, суррогатов и noncharacters (U+FDD0..U+FDEF и *FFFE/*FFFF). ` +
`Файл окружения с таким значением не загрузится, и юнит не стартует.`
);
}
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`;
}
+30 -1
View File
@@ -27,8 +27,37 @@ export async function dirExists(path: string): Promise<boolean> {
return st?.isDirectory() ?? false;
}
export class InvalidUtf8Error extends Error {
readonly path: string;
constructor(path: string, cause: unknown) {
super(`файл ${path} содержит некорректную последовательность UTF-8`, { cause });
this.name = "InvalidUtf8Error";
this.path = path;
}
}
/** Явное replacement-декодирование только для диагностического вывода команд. */
export function decodeUtf8Lossy(bytes: Uint8Array): string {
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
}
/**
* Читает текстовые файлы без молчаливой подмены повреждённых байтов на U+FFFD.
*
* `Blob.text()` выполняет обычное декодирование UTF-8 с replacement mode. Для
* конфигурации и секретов это недопустимо: другое байтовое значение не должно
* незаметно превратиться в корректную строку. `ignoreBOM: true` намеренно
* сохраняет начальный BOM как U+FEFF, чтобы транспортный валидатор увидел и
* отверг его по публичному контракту EnvironmentFile.
*/
export async function readText(path: string): Promise<string> {
return await Bun.file(path).text();
const bytes = await Bun.file(path).bytes();
try {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
} catch (error) {
throw new InvalidUtf8Error(path, error);
}
}
export async function writeText(path: string, data: string, mode?: number): Promise<void> {
+19 -3
View File
@@ -1,11 +1,27 @@
import { readText } from "./fs";
import { InvalidUtf8Error, readText } from "./fs";
import { runReadOnly } from "./process";
export function isMissingPackageMetadataError(error: unknown): boolean {
return (
!(error instanceof InvalidUtf8Error) &&
error !== null &&
typeof error === "object" &&
"code" in error &&
(error as { code?: unknown }).code === "ENOENT"
);
}
export async function readPackageValue(packageDir: string, file: string, fallback: string): Promise<string> {
try {
return (await readText(`${packageDir}/metadata/${file}`)).trim();
} catch {
return fallback;
} catch (error) {
// Только отсутствие необязательного файла означает fallback. Ошибки прав,
// ввода-вывода, неверного типа и повреждённый UTF-8 описывают битый либо
// недоступный пакет и не имеют права маскироваться запасным значением.
if (isMissingPackageMetadataError(error)) {
return fallback;
}
throw error;
}
}
+70
View File
@@ -35,6 +35,7 @@
*/
import { assertMutationAllowed } from "./guard";
import { decodeUtf8Lossy } from "./fs";
import { info } from "./log";
function shellQuote(value: unknown): string {
@@ -190,8 +191,77 @@ export type MutationResult = {
exitCode: number;
stdout: string;
stderr: string;
stdoutTruncated?: boolean;
stderrTruncated?: boolean;
};
async function readDiagnosticPipe(
stream: ReadableStream<Uint8Array>,
maxBytes: number
): Promise<{ text: string; truncated: boolean }> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let captured = 0;
let truncated = false;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const remaining = maxBytes - captured;
if (remaining > 0) {
// slice, а не subarray: иначе маленький сохранённый view удерживает в
// памяти весь большой backing buffer и формально обходит лимит.
const chunk = value.byteLength <= remaining ? value : value.slice(0, remaining);
chunks.push(chunk);
captured += chunk.byteLength;
}
if (value.byteLength > remaining) {
truncated = true;
}
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(captured);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return { text: decodeUtf8Lossy(bytes), truncated };
}
/**
* Наблюдение готовым argv с сохранением ненулевого кода возврата.
* Диагностический вывод декодируется lossy намеренно; конфигурационные файлы
* всегда проходят через строгий readText.
*/
export async function runReadOnlyArgvStatus(
argv: string[],
maxOutputBytes = 8 * 1024 * 1024
): Promise<MutationResult> {
assertArgv(argv, "runReadOnlyArgvStatus");
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1) {
throw new Error("runReadOnlyArgvStatus: maxOutputBytes must be a positive integer");
}
const subprocess = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([
readDiagnosticPipe(subprocess.stdout, maxOutputBytes),
readDiagnosticPipe(subprocess.stderr, maxOutputBytes),
subprocess.exited
]);
return {
exitCode,
stdout: stdout.text,
stderr: stderr.text,
stdoutTruncated: stdout.truncated,
stderrTruncated: stderr.truncated
};
}
/**
* Мутация, которая ВОЗВРАЩАЕТ код возврата вместо броска.
*
+32 -6
View File
@@ -12,6 +12,17 @@
export const REDACTED = "<redacted>";
/**
* ENV-флаги, чьи имена похожи на секрет, но значения являются диагностически
* полезным публичным состоянием. Исключения здесь только точные: общий
* детектор секретов нельзя ослаблять совпадением по префиксу или суффиксу.
*/
const NON_SECRET_ENV_KEYS = new Set(["HY2XS_FORCE_PASSWORD_CHANGE"]);
/** Сохранённый контракт прежнего ENV-детектора, но применённый после разбора key. */
const ENV_SECRET_KEY_PATTERN =
/(PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|TOKEN|CREDENTIAL|APIKEY|AUTHORIZATION|COOKIE|BEARER|SIGNATURE)/;
/**
* Маркеры имён полей. Список покрывает известные секреты HY2XS/Hysteria и
* типовые имена, под которыми секрет может появиться в будущем upstream-поле.
@@ -36,11 +47,14 @@ export const SECRET_KEY_MARKERS: readonly string[] = [
"authorization",
"cookie",
"bearer",
"signature"
"signature",
];
/** Узлы, где секретом являются все значения карты, а не имя ключа. */
const SECRET_MAP_PATHS: readonly string[] = ["auth.userpass", "acme.dns.config"];
const SECRET_MAP_PATHS: readonly string[] = [
"auth.userpass",
"acme.dns.config",
];
export function isSecretKey(key: string): boolean {
const lowered = key.toLowerCase();
@@ -92,7 +106,9 @@ function redactValueDeep(value: unknown): unknown {
}
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
for (const [key, child] of Object.entries(
value as Record<string, unknown>
)) {
out[key] = redactValueDeep(child);
}
return out;
@@ -138,8 +154,15 @@ function redactNode(value: unknown, path: readonly string[]): unknown {
export function redactKeyValueSecrets(content: string): string {
return content.replace(
/^([A-Z0-9_]*(PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|TOKEN|CREDENTIAL|APIKEY|AUTHORIZATION|COOKIE|BEARER|SIGNATURE)[A-Z0-9_]*=).*$/gm,
`$1${REDACTED}`
/^([A-Z0-9_]+)=(.*?)(\r?)$/gm,
(line, key: string, value: string, eol: string) => {
const isKnownBoolean =
NON_SECRET_ENV_KEYS.has(key) && /^(?:true|false)$/.test(value);
if (isKnownBoolean || !ENV_SECRET_KEY_PATTERN.test(key)) {
return line;
}
return `${key}=${REDACTED}${eol}`;
}
);
}
@@ -207,7 +230,10 @@ export function redactLogText(content: string): string {
LOG_SECRET_ASSIGNMENT_PATTERN,
(match, key: string, separator: string, rawValue: string) => {
const [value, trailing] = splitTrailingPunctuation(rawValue);
if (value === REDACTED || value.toLowerCase() === ENCODED_REDACTED.toLowerCase()) {
if (
value === REDACTED ||
value.toLowerCase() === ENCODED_REDACTED.toLowerCase()
) {
return match;
}
const rendered = separator === ":" ? ": " : "=";
+89
View File
@@ -0,0 +1,89 @@
import { info } from "./log";
import {
runMutatingStatus,
runReadOnlyArgvStatus,
type MutationResult,
} from "./process";
export type ResetFailedDependencies = {
reset: (unit: string) => Promise<MutationResult>;
inspect: (unit: string) => Promise<MutationResult>;
};
const productionDependencies: ResetFailedDependencies = {
reset: async (unit) => runMutatingStatus`systemctl reset-failed ${unit}`,
inspect: async (unit) =>
runReadOnlyArgvStatus([
"systemctl",
"show",
unit,
"--property=LoadState",
"--property=ActiveState",
"--no-pager",
]),
};
function propertiesOf(output: string): Map<string, string> {
const properties = new Map<string, string>();
for (const line of output.split(/\r?\n/)) {
const separator = line.indexOf("=");
if (separator <= 0) {
continue;
}
properties.set(line.slice(0, separator), line.slice(separator + 1));
}
return properties;
}
function commandFailure(result: MutationResult): string {
return (
result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`
);
}
/**
* Сбрасывает failed-состояние юнита и доказывает postcondition наблюдением.
*
* `systemctl reset-failed` возвращает ненулевой код и для уже выгруженного
* юнита. Это не отказ уборки: у такого юнита физически нет failed-состояния,
* которое нужно было бы сбрасывать. Разбирать английское `Unit ... not loaded`
* нельзя текст зависит от версии и локали systemd. Поэтому код команды
* сохраняется для диагностики, а решение принимается по ActiveState.
*
* Ошибка чтения состояния не маскируется. Если systemd недоступен либо юнит всё
* ещё `failed`, rollback обязан оставить оператору настоящее предупреждение.
*/
export async function resetFailedUnit(
unit: string,
dependencies: ResetFailedDependencies = productionDependencies,
): Promise<void> {
const reset = await dependencies.reset(unit);
const observed = await dependencies.inspect(unit);
if (observed.exitCode !== 0) {
throw new Error(
`cannot verify systemd state for ${unit} after reset-failed: ${commandFailure(observed)}`,
);
}
const properties = propertiesOf(observed.stdout);
const loadState = properties.get("LoadState");
const activeState = properties.get("ActiveState");
if (!loadState || !activeState) {
throw new Error(
`systemctl show ${unit} did not return LoadState and ActiveState after reset-failed`,
);
}
if (activeState === "failed") {
throw new Error(
`systemd unit ${unit} remains failed after reset-failed (load state: ${loadState}; ` +
`command: ${commandFailure(reset)})`,
);
}
if (reset.exitCode !== 0) {
info(
`systemctl reset-failed ${unit} exited with ${reset.exitCode}, but the cleanup ` +
`postcondition is satisfied: LoadState=${loadState}, ActiveState=${activeState}`,
);
}
}
+15 -5
View File
@@ -1,5 +1,6 @@
import type { RuntimeContext } from "../types/context";
import { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs";
import { renderEnvFile } from "../lib/envFile";
import { runMutatingVisible } from "../lib/process";
import {
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> {
await writeTextAtomic(
context.config.bootstrapAdminSecretPath,
[
`ADMIN_USER=${context.config.adminUser}`,
`ADMIN_INITIAL_PASSWORD=${context.config.adminInitialPassword}`,
`ADMIN_CON_PASS=${context.config.adminConPass}`
].join("\n") + "\n",
renderEnvFile([
["ADMIN_USER", context.config.adminUser],
["ADMIN_INITIAL_PASSWORD", context.config.adminInitialPassword],
["ADMIN_CON_PASS", context.config.adminConPass]
]),
{
mode: 0o600,
owner: "root",
+2
View File
@@ -1,5 +1,6 @@
import type { InstallContext } from "../types/context";
import { runMutating } from "../lib/process";
import { ensureDiagnosticsStorageRoot } from "../lib/diagnosticsStorage";
async function userExists(user: string): Promise<boolean> {
try {
@@ -58,5 +59,6 @@ export async function prepareFilesystem(context: InstallContext): Promise<void>
await ensureDir(context.config.installDir, "0755", "root:root");
await ensureDir("/usr/local/lib/hy2xs", "0755", "root:root");
await ensureDir("/etc/nftables.d", "0755", "root:root");
await ensureDiagnosticsStorageRoot();
await runMutating`chmod -R go-w ${context.config.installDir}`;
}
+409 -32
View File
@@ -1,8 +1,18 @@
import { randomBytes } from "node:crypto";
import type { RuntimeContext } from "../types/context";
import { info } from "../lib/log";
import { readText } from "../lib/fs";
import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process";
import { HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile";
import { parseEnvFile } from "../lib/envFile";
import {
runReadOnly,
runReadOnlySecret,
runMutatingVisible,
} from "../lib/process";
import {
ADMIN_LOGIN_PATH,
HYSTERIA_MACHINE_AUTH_PATH,
hysteriaMachineAuthUrl,
} from "../config/profile";
import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
import { assertEffectiveFirewallIsOurs } from "./firewall";
@@ -80,7 +90,8 @@ export async function smoke(context: RuntimeContext): Promise<void> {
1000,
async () => runReadOnlySecret`systemctl is-active hysteria-server || true`,
(state) => state.trim() === "active",
(state, error) => new Error(`hysteria-server is not active: ${state ?? String(error)}`),
(state, error) =>
new Error(`hysteria-server is not active: ${state ?? String(error)}`),
);
await retry(
"systemd hy2xs-admin active",
@@ -88,7 +99,8 @@ export async function smoke(context: RuntimeContext): Promise<void> {
1000,
async () => runReadOnlySecret`systemctl is-active hy2xs-admin || true`,
(state) => state.trim() === "active",
(state, error) => new Error(`hy2xs-admin is not active: ${state ?? String(error)}`),
(state, error) =>
new Error(`hy2xs-admin is not active: ${state ?? String(error)}`),
);
if (context.options.skipSmoke) {
@@ -101,24 +113,38 @@ export async function smoke(context: RuntimeContext): Promise<void> {
15,
1000,
async () => runReadOnlySecret`ss -H -ltn`,
(lines) => hasTcpListener(lines, context.config.uiBindHost, context.config.uiPort),
(lines, error) => new Error(`ui listener not ready on ${context.config.uiBindHost}:${context.config.uiPort}: ${lines ?? String(error)}`),
(lines) =>
hasTcpListener(lines, context.config.uiBindHost, context.config.uiPort),
(lines, error) =>
new Error(
`ui listener not ready on ${context.config.uiBindHost}:${context.config.uiPort}: ${lines ?? String(error)}`,
),
);
await retry(
"hysteria udp listener readiness",
15,
1000,
async () => runReadOnlySecret`ss -H -lun`,
(lines) => hasUdpListener(lines, context.config.hysteriaBindHost, context.config.hysteriaPort),
(lines, error) => new Error(`hysteria udp listener not ready on 0.0.0.0:${context.config.hysteriaPort}: ${lines ?? String(error)}`),
(lines) =>
hasUdpListener(
lines,
context.config.hysteriaBindHost,
context.config.hysteriaPort,
),
(lines, error) =>
new Error(
`hysteria udp listener not ready on 0.0.0.0:${context.config.hysteriaPort}: ${lines ?? String(error)}`,
),
);
await retry(
"admin healthz readiness",
15,
1000,
async () => runReadOnlySecret`curl -sS --max-time 5 http://127.0.0.1:${context.config.uiPort}/healthz`,
async () =>
runReadOnlySecret`curl -sS --max-time 5 http://127.0.0.1:${context.config.uiPort}/healthz`,
(response) => /"ok"\s*:\s*true/.test(response),
(response, error) => new Error(`admin healthz is not ready: ${response ?? String(error)}`),
(response, error) =>
new Error(`admin healthz is not ready: ${response ?? String(error)}`),
);
// Всё, что ниже, — НАБЛЮДЕНИЕ, и оно выполняется read-only раннерами.
@@ -129,7 +155,9 @@ export async function smoke(context: RuntimeContext): Promise<void> {
// не меняет диагностируемую систему» невозможно было включить guard'ом — он
// отказал бы на первой же читающей команде. Классификация здесь — часть
// контракта, а не стиль.
info(`installed Hysteria: ${await runReadOnly`/usr/local/bin/hysteria version`}`);
info(
`installed Hysteria: ${await runReadOnly`/usr/local/bin/hysteria version`}`,
);
await runReadOnly`test -s /etc/hysteria/config.yaml`;
await runReadOnly`test -s /etc/hy2xs/hy2xs.env`;
await runReadOnly`test -s /etc/hysteria/post-install.env`;
@@ -154,7 +182,9 @@ export async function smoke(context: RuntimeContext): Promise<void> {
if (context.config.uiBindHost === "127.0.0.1") {
const tcp = await runReadOnlySecret`ss -H -ltn`;
if (hasTcpListener(tcp, "0.0.0.0", context.config.uiPort)) {
throw new Error(`ui listener must not be public on 0.0.0.0:${context.config.uiPort}`);
throw new Error(
`ui listener must not be public on 0.0.0.0:${context.config.uiPort}`,
);
}
}
await runReadOnly`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`;
@@ -165,61 +195,90 @@ export async function smoke(context: RuntimeContext): Promise<void> {
const machineAuthUrlNoToken = `http://127.0.0.1:${context.config.uiPort}${HYSTERIA_MACHINE_AUTH_PATH}`;
const machineAuthUrl = hysteriaMachineAuthUrl(
context.config.uiPort,
context.config.hysteriaTrafficStatsSecret
context.config.hysteriaTrafficStatsSecret,
);
const missingTokenAuthCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrlNoToken}`;
const missingTokenAuthCode =
await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrlNoToken}`;
if (missingTokenAuthCode.trim() !== "403") {
throw new Error(`unexpected auth status without machine token: ${missingTokenAuthCode}`);
throw new Error(
`unexpected auth status without machine token: ${missingTokenAuthCode}`,
);
}
const invalidAuthResponse = await retry(
"auth invalid credentials",
5,
1000,
async () => runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`,
async () =>
runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`,
(response) => /"ok"\s*:\s*false/.test(response),
(response, error) => new Error(`unexpected auth response for invalid credentials: ${response ?? String(error)}`),
(response, error) =>
new Error(
`unexpected auth response for invalid credentials: ${response ?? String(error)}`,
),
);
for (let i = 0; i < 10; i += 1) {
const response = await runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`;
const response =
await runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`;
if (!/"ok"\s*:\s*false/.test(response)) {
throw new Error(`unexpected auth response during rate-limit smoke: ${response}`);
throw new Error(
`unexpected auth response during rate-limit smoke: ${response}`,
);
}
}
const invalidTypeAuthCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' ${machineAuthUrl}`;
const invalidTypeAuthCode =
await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' ${machineAuthUrl}`;
if (invalidTypeAuthCode.trim() !== "400") {
throw new Error(`unexpected auth status for tx as string: ${invalidTypeAuthCode}`);
throw new Error(
`unexpected auth status for tx as string: ${invalidTypeAuthCode}`,
);
}
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) {
throw new Error("admin connection password is empty in bootstrap secret file");
throw new Error(
"admin connection password is empty in bootstrap secret file",
);
}
await retry(
"auth valid credentials",
10,
1000,
async () => runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"${adminConPass}","tx":0}' ${machineAuthUrl}`,
async () =>
runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"${adminConPass}","tx":0}' ${machineAuthUrl}`,
(response) => /"ok"\s*:\s*true/.test(response),
(response, error) => new Error(`unexpected auth response for valid credentials: ${response ?? String(error)}`),
(response, error) =>
new Error(
`unexpected auth response for valid credentials: ${response ?? String(error)}`,
),
);
}
await assertAdminLoginWorks(context);
await retry(
"trafficStats valid secret",
10,
1000,
async () => runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: ${context.config.hysteriaTrafficStatsSecret}' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`,
async () =>
runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: ${context.config.hysteriaTrafficStatsSecret}' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`,
(code) => /^2\d\d$/.test(code.trim()),
(code, error) => new Error(`unexpected trafficStats status for valid secret: ${code ?? String(error)}`),
(code, error) =>
new Error(
`unexpected trafficStats status for valid secret: ${code ?? String(error)}`,
),
);
const deniedCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: invalid-hy2xs-secret' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`;
const deniedCode =
await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: invalid-hy2xs-secret' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`;
if (!/(401|403)/.test(deniedCode)) {
throw new Error(`unexpected trafficStats status for invalid secret: ${deniedCode}`);
throw new Error(
`unexpected trafficStats status for invalid secret: ${deniedCode}`,
);
}
// `nft -c` только разбирает файл и правил не применяет — это проверка
@@ -233,23 +292,341 @@ export async function smoke(context: RuntimeContext): Promise<void> {
// синтаксически валидный — ruleset, проверка проходила зелёной, и операция
// объявляла успешной установку, работающую на firewall, который она же
// только что заменила.
info("verifying that the effective firewall is the one generated for this configuration");
info(
"verifying that the effective firewall is the one generated for this configuration",
);
await assertEffectiveFirewallIsOurs(context);
// Семантическая проверка установленного конфига: разбираем YAML и сверяем
// с production-профилем, а не ищем подстроки.
info("verifying effective Hysteria config against HY2XS production profile");
assertHysteriaConfigMatchesProfile(await readText("/etc/hysteria/config.yaml"), context.config);
assertHysteriaConfigMatchesProfile(
await readText("/etc/hysteria/config.yaml"),
context.config,
);
await assertEffectiveHysteriaVersion(context);
}
/**
* Панель обязана ВПУСКАТЬ, а не просто слушать порт.
*
* Почему эта проверка появилась. До неё установка отвечала на вопрос «работает
* ли панель» тремя фактами: юнит активен, `127.0.0.1:8080` в LISTEN, `/healthz`
* отвечает `ok:true`. RC2 доказал, что все три могут быть истинными
* одновременно с полностью недоступной панелью: на поле логина стоял тег
* несуществующего правила валидации, `POST /api/auth/login` паниковал ещё до
* проверки учётных данных, gin.Recovery превращал панику в HTTP 500 и
* установка завершалась `INSTALL EXIT CODE: 0`.
*
* Разница между «порт открыт» и «оператор может войти» это весь продукт,
* поэтому smoke выполняет НАСТОЯЩИЙ вход теми учётными данными, которые создал
* установщик.
*
* Что здесь важно по деталям:
*
* - тело собирается JSON.stringify, а не интерполяцией в строку. Пароль
* задаёт оператор, и кавычка или обратный слеш в нём иначе сломали бы не
* панель, а сам запрос и проверка объявила бы рабочую установку сломанной;
* - обе команды идут через runReadOnlySecret: этот раннер не кладёт команду в
* текст ошибки, а команда несёт пароль администратора. Ошибка проверки
* уезжает в журнал и в diagnostics-бандл;
* - положительная проба выполняется только на install. На reconfigure пароль в
* bootstrap-admin.secret устаревает в тот момент, когда оператор сменил его
* в панели, и требовать по нему вход значило бы ронять законную операцию;
* - отрицательная проба выполняется ВСЕГДА и от актуальности пароля не
* зависит. Именно она воспроизводит дефект RC2: заведомо неверные учётные
* данные обязаны получить осмысленный отказ, а не 500.
*/
async function assertAdminLoginWorks(context: RuntimeContext): Promise<void> {
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 rejectedBody = await retry(
"admin login rejects wrong credentials",
10,
1000,
async () =>
requestAdminLogin(loginUrl, context.config.adminUser, rejectedPassword),
(body) => isRejectedLogin(body),
(body, error) =>
new Error(
`admin login did not reject invalid credentials: ${describeRejectionFailure(body, error)}\n` +
`Панель обязана отвечать конвертом отказа с причиной ${INVALID_CREDENTIALS_CODE}. ` +
`Отсутствие такого конверта означает, что запрос не доживает до проверки учётных данных.`,
),
);
info(
`admin login rejects wrong credentials: ${describeRejection(rejectedBody)}`,
);
if (context.mode !== "install") {
return;
}
// Файл читается ЦЕЛИКОМ и разбирается тем же парсером, которым он написан.
//
// Здесь стояло `grep … | cut -d= -f2-` с последующим `.trim()`, и это был
// третий по счёту слой, срезающий пробелы у пароля, набор символов которого
// объявлен неограниченным. Даже с починенными env-транспортом и админкой
// проба брала бы не тот пароль и объявляла бы рабочую установку сломанной.
// Побочно исчез и прогон секрета через shell-конвейер.
const bootstrap = await readBootstrapAdminSecret(context);
const adminUser = bootstrap.ADMIN_USER ?? "";
const adminPassword = bootstrap.ADMIN_INITIAL_PASSWORD ?? "";
if (!adminUser) {
throw new Error("admin username is empty in bootstrap secret file");
}
if (!adminPassword) {
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 response = await retry(
"admin login with bootstrap credentials",
10,
1000,
async () => requestAdminLogin(loginUrl, adminUser, adminPassword),
(body) => isSuccessfulLogin(body),
(body, error) =>
new Error(
`admin panel refused the bootstrap login it created itself: ${describeLoginFailure(body, error)}\n` +
`Порт открыт и /healthz отвечает, но войти в панель нельзя — установка не считается выполненной.`,
),
);
info(`admin login accepted: ${describeIssuedToken(response)}`);
}
/**
* UA login-smoke называется своим именем и не маскируется под браузер.
*
* curl по умолчанию отправляет `curl/<version>`, а production middleware
* осознанно отклоняет scanner-like UA до разбора DTO. Без явного значения
* установщик проверял не вход, а собственную несовместимость с middleware.
*/
export const ADMIN_LOGIN_SMOKE_USER_AGENT = "HY2XS-Installer/1.0";
export type AdminLoginRequest = {
contentType: "application/json";
userAgent: string;
body: string;
};
/** Единственный wire-контракт обеих login-проб. */
export function buildAdminLoginRequest(
username: string,
pass: string,
): AdminLoginRequest {
return {
contentType: "application/json",
userAgent: ADMIN_LOGIN_SMOKE_USER_AGENT,
body: JSON.stringify({ username, pass }),
};
}
/**
* Отправляет login-пробу без утечки тела в текст ошибки.
*
* Positive и negative smoke намеренно проходят через одну функцию: заголовки,
* имя wire-поля и настройки транспорта не могут разойтись между пробами.
*/
async function requestAdminLogin(
loginUrl: string,
username: string,
pass: string,
): Promise<string> {
const request = buildAdminLoginRequest(username, pass);
return runReadOnlySecret`curl -sS --max-time 5 --request POST --header ${`Content-Type: ${request.contentType}`} --user-agent ${request.userAgent} --data ${request.body} ${loginUrl}`;
}
type JsonObject = Record<string, unknown>;
function objectOrNull(value: unknown): JsonObject | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as JsonObject)
: null;
}
function parseLoginEnvelope(body: string): JsonObject | null {
try {
return objectOrNull(JSON.parse(body));
} catch {
return null;
}
}
function accessTokenOf(envelope: JsonObject | null): string | null {
const data = objectOrNull(envelope?.data);
return typeof data?.accessToken === "string" && data.accessToken.length > 0
? data.accessToken
: null;
}
function carriesAccessToken(envelope: JsonObject | null): boolean {
const data = objectOrNull(envelope?.data);
return data !== null && Object.hasOwn(data, "accessToken") && data.accessToken !== null;
}
function rejectionCodesOf(envelope: JsonObject | null): string[] {
if (!Array.isArray(envelope?.errors)) {
return [];
}
return envelope.errors.flatMap((error) => {
const item = objectOrNull(error);
return typeof item?.code === "string" ? [item.code] : [];
});
}
/**
* Успех определяется по КОНВЕРТУ, а не по коду HTTP.
*
* Админка отвечает `200 OK` и на отказ тоже: причина живёт в поле `code`
* ответа. Проверка «HTTP 200» приняла бы за успешный вход любой отказ то есть
* ровно ничего бы не проверяла.
*
* Выданный токен требуется отдельно: `code: 20000` без `accessToken` означал бы
* панель, которая пускает и не выдаёт сессию.
*/
export function isSuccessfulLogin(body: string): boolean {
const envelope = parseLoginEnvelope(body);
return envelope?.code === 20000 && accessTokenOf(envelope) !== null;
}
/**
* Код причины отказа по учётным данным тот же литерал, что и
* constant.ErrCodeInvalidCredentials в админке.
*
* Он часть публичного контракта API: панель выбирает по нему локализованную
* фразу, и его значение не меняется вместе с формулировками сообщений.
*/
const INVALID_CREDENTIALS_CODE = "invalid_credentials";
/**
* Отказ определяется по КОНВЕРТУ целиком, а не по отсутствию HTTP 500.
*
* Требуются три признака сразу, и каждый закрывает свой способ ошибиться:
*
* код ответа 50000 это отказ уровня операции, а не успех (20000) и
* не отказ валидации (50001), который означал бы,
* что проба отправила негодный запрос и до проверки
* пароля он не дошёл;
* причина invalid_credentials отказ именно по учётным данным, а не,
* скажем, по недоступной базе;
* отсутствие accessToken панель не выдала сессию.
*/
export function isRejectedLogin(body: string): boolean {
const envelope = parseLoginEnvelope(body);
return (
envelope?.code === 50000 &&
rejectionCodesOf(envelope).includes(INVALID_CREDENTIALS_CODE) &&
!carriesAccessToken(envelope)
);
}
/**
* Причина, по которой отрицательная проба не признана отказом, БЕЗ тела
* ответа.
*
* Тело сюда попасть не может по той же причине, что и в describeLoginFailure:
* если панель по недоразумению впустила, в ответе лежит токен доступа, а этот
* текст уезжает в журнал установки и в diagnostics-бандл.
*/
function describeRejectionFailure(
body: string | undefined,
error: unknown,
): string {
if (body === undefined) {
return `запрос не выполнен: ${String(error)}`;
}
if (carriesAccessToken(parseLoginEnvelope(body))) {
return "панель ВЫДАЛА токен доступа на заведомо неверные учётные данные";
}
return describeRejection(body);
}
/** Как выглядит отказ: код конверта и код причины, без тела. */
function describeRejection(body: string): string {
const envelope = parseLoginEnvelope(body);
const reason = rejectionCodesOf(envelope)[0];
return `code=${typeof envelope?.code === "number" ? envelope.code : "нет"}, причина=${reason ?? "нет"}, токен не выдан`;
}
// Читает /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));
}
/**
* Причина отказа БЕЗ тела ответа.
*
* Тело сюда попасть не может: в ответе успешного входа лежит токен доступа, а
* текст этой ошибки уезжает в журнал установки и в diagnostics-бандл, который
* операторы пересылают в переписке. Поэтому наружу выдаётся только код ответа.
*/
function describeLoginFailure(
body: string | undefined,
error: unknown,
): string {
if (body === undefined) {
return `запрос не выполнен: ${String(error)}`;
}
const envelope = parseLoginEnvelope(body);
if (typeof envelope?.code === "number") {
return `ответ с code=${envelope.code} и без токена доступа`;
}
return "ответ не является конвертом API админки";
}
/** Подтверждение выдачи токена без самого токена. */
function describeIssuedToken(body: string): string {
const data = objectOrNull(parseLoginEnvelope(body)?.data);
return typeof data?.tokenType === "string" && data.tokenType.length > 0
? `выдан токен типа ${data.tokenType}`
: "выдан токен доступа";
}
/**
* Установленный бинарник обязан совпадать с версией, замороженной в metadata
* пакета. На reconfigure metadata может относиться к другому пакету, поэтому
* расхождение там предупреждение, а не отказ.
*/
async function assertEffectiveHysteriaVersion(context: RuntimeContext): Promise<void> {
async function assertEffectiveHysteriaVersion(
context: RuntimeContext,
): Promise<void> {
const packagedVersion = context.hysteriaVersion.trim();
if (!packagedVersion || packagedVersion === "unknown") {
return;
+570
View File
@@ -0,0 +1,570 @@
import { describe, expect, test } from "bun:test";
import fs from "node:fs";
import path from "node:path";
import { parseRuntimeEnv } from "../src/config/env";
import {
ADMIN_LOGIN_PATH,
ADMIN_PASSWORD_MAX_BYTES,
ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH,
ADMIN_USERNAME_CHARSET,
ADMIN_USERNAME_MAX_LENGTH,
ADMIN_USERNAME_MIN_LENGTH,
ADMIN_USERNAME_PATTERN,
isValidAdminPassword,
} from "../src/config/profile";
import {
ADMIN_LOGIN_SMOKE_USER_AGENT,
buildAdminLoginRequest,
isRejectedLogin,
isSuccessfulLogin,
} from "../src/steps/smoke";
import { baselineConfig, envText } from "./fixtures";
/**
* Контракт учётных данных администратора на стороне оркестратора.
*
* Оркестратор ПОРОЖДАЕТ имя и первый пароль администратора, а принимает их
* панель по правилам, которых он раньше не знал вовсе. RC2 показал, чем это
* заканчивается: установка завершается `INSTALL EXIT CODE: 0`, а войти в панель
* нельзя. Отказ обязан приходить установщику, которому он чинится одной строкой
* в hy2xs.env, а не оператору на форме входа, где чинить его уже нечем.
*/
const REPO_ROOT = path.resolve(import.meta.dir, "..", "..");
function goContract(): string {
return fs.readFileSync(
path.join(REPO_ROOT, "apps", "credential", "admin.go"),
"utf8",
);
}
function goConst(name: string): string {
const source = goContract();
const numeric = source.match(new RegExp(`\\b${name}\\s*=\\s*(\\d+)`));
if (numeric) {
return numeric[1];
}
const backticked = source.match(
new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``),
);
if (backticked) {
return backticked[1];
}
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("контракт учётных данных совпадает с админкой", () => {
// Копия контракта в TypeScript неизбежна: оркестратор собирается отдельно от
// Go-бинарника и импортировать его константы не может. Проверяется поэтому
// не отсутствие копии, а её совпадение с оригиналом — сверкой по исходнику.
test("границы совпадают с credential.Admin*", () => {
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(
goConst("AdminUsernameMinLength"),
);
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(
goConst("AdminUsernameMaxLength"),
);
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(
goConst("AdminPasswordMinLength"),
);
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(
goConst("AdminPasswordMaxLength"),
);
// Предел bcrypt — ОТДЕЛЬНАЯ константа и отдельная сверка: пока его тут не
// было, оркестратор пропускал в hy2xs.env пароль, который админка не могла
// захешировать, и установка заканчивалась сервером без администратора.
expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe(
goConst("AdminPasswordMaxBytes"),
);
});
test("набор символов логина совпадает с классом из Go", () => {
// Класс сравнивается как ЗНАЧЕНИЕ строки, а не как запись: в Go он живёт в
// raw-строке, в TypeScript — в обычной, где обратный слеш удваивается.
const declared = goConst("adminUsernameCharacterClass");
const mirrored = ADMIN_USERNAME_PATTERN.source.replace(
new RegExp(
`^\\^\\[|\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`,
"g",
),
"",
);
expect(mirrored).toBe(declared);
});
test("человекочитаемый набор совпадает с показываемым админкой", () => {
expect(ADMIN_USERNAME_CHARSET).toBe(goConst("AdminUsernameCharset"));
});
// Дефис в классе символов ЭКРАНИРОВАН. Без экранирования `+-=` образует
// диапазон и молча впускает `, - . / 0-9 : ; < =` — ровно тот дефект, из-за
// которого фактическое множество разошлось с задуманным на всех четырёх
// сторонах продукта.
test("дефис в классе экранирован, а не образует диапазон", () => {
expect(ADMIN_USERNAME_PATTERN.source).toContain("\\-");
expect(ADMIN_USERNAME_PATTERN.source).not.toContain("+-=");
});
test("путь формы входа собран из базового пространства имён API", () => {
expect(ADMIN_LOGIN_PATH).toBe("/api/auth/login");
const router = fs.readFileSync(
path.join(REPO_ROOT, "apps", "router", "auth.go"),
"utf8",
);
expect(router).toContain('auth.POST("/login"');
expect(router).toContain('authApi.Group("/auth")');
});
});
describe("HY2XS_ADMIN_USER проверяется при разборе окружения", () => {
test("значение по умолчанию проходит контракт панели", () => {
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_USER: null }));
expect(config.adminUser).toBe("hy2xsadmin");
expect(ADMIN_USERNAME_PATTERN.test(config.adminUser)).toBe(true);
});
// Регрессия: умолчанием здесь стояло `admin` — пять символов при минимуме в
// шесть. Установка проходила целиком, а панель не впускала никого.
test("значение по умолчанию не короче минимума", () => {
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_USER: null }));
expect(config.adminUser.length).toBeGreaterThanOrEqual(
ADMIN_USERNAME_MIN_LENGTH,
);
});
// Умолчание обязано совпадать с запасным значением админки: иначе установка
// без явного HY2XS_ADMIN_USER создаёт одну учётную запись, а админка при
// повреждённом окружении ищет другую.
test("умолчание совпадает с запасным значением админки и с packaged env", () => {
const dao = fs.readFileSync(
path.join(REPO_ROOT, "apps", "dao", "sqlite.go"),
"utf8",
);
expect(dao).toContain('adminUser = "hy2xsadmin"');
const packaged = fs.readFileSync(
path.join(REPO_ROOT, "package", "config", "hy2xs.env"),
"utf8",
);
expect(packaged).toContain("HY2XS_ADMIN_USER=hy2xsadmin");
});
test("слишком короткий логин роняет установку", () => {
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(
/invalid HY2XS_ADMIN_USER/,
);
});
test("слишком длинный логин роняет установку", () => {
expect(() =>
baselineConfig({
HY2XS_ADMIN_USER: "a".repeat(ADMIN_USERNAME_MAX_LENGTH + 1),
}),
).toThrow(/invalid HY2XS_ADMIN_USER/);
});
test("логин с пробелом, кириллицей или переводом строки роняет установку", () => {
for (const rejected of ["hy2xs admin", "админ12", "hy2xs\tadmin"]) {
expect(() => baselineConfig({ HY2XS_ADMIN_USER: rejected })).toThrow(
/invalid HY2XS_ADMIN_USER/,
);
}
});
test("отказ называет и границы, и набор символов", () => {
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(
new RegExp(`${ADMIN_USERNAME_MIN_LENGTH}.*${ADMIN_USERNAME_MAX_LENGTH}`),
);
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(
/a-z A-Z 0-9/,
);
});
// Значение уезжает в hy2xs.env и оттуда в базу. Пробел по краям превращался
// бы в часть имени учётной записи, и вход отказывал бы «неверным логином» —
// отказом, который невозможно связать с причиной.
test("окружающие пробелы снимаются, а не уезжают в имя учётной записи", () => {
expect(
baselineConfig({ HY2XS_ADMIN_USER: " hy2xsadmin " }).adminUser,
).toBe("hy2xsadmin");
});
test("точка в логине принимается: набор не сужается задним числом", () => {
expect(baselineConfig({ HY2XS_ADMIN_USER: "admin.ops" }).adminUser).toBe(
"admin.ops",
);
});
});
describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разборе окружения", () => {
test("сгенерированный пароль проходит контракт панели", () => {
for (const raw of ["", "__GENERATE__"]) {
const config = parseRuntimeEnv(
envText({ HY2XS_ADMIN_INITIAL_PASSWORD: raw }),
);
const length = [...config.adminInitialPassword].length;
expect(length).toBeGreaterThanOrEqual(ADMIN_PASSWORD_MIN_LENGTH);
expect(length).toBeLessThanOrEqual(ADMIN_PASSWORD_MAX_LENGTH);
}
});
test("слишком короткий пароль роняет установку", () => {
expect(() =>
baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: "12345" }),
).toThrow(/invalid HY2XS_ADMIN_INITIAL_PASSWORD/);
});
test("слишком длинный пароль роняет установку", () => {
expect(() =>
baselineConfig({
HY2XS_ADMIN_INITIAL_PASSWORD: "a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1),
}),
).toThrow(/invalid HY2XS_ADMIN_INITIAL_PASSWORD/);
});
// Набор символов пароля сервером не ограничивается, и оркестратор не имеет
// права быть строже: отвергнутый здесь пароль сервер принял бы.
test("пароль из произвольных символов принимается", () => {
for (const password of [
"p@ssw0rd?!",
"пароль-администратора",
"a b c d e f",
]) {
expect(
baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: password })
.adminInitialPassword,
).toBe(password);
}
});
// Границ ДВЕ, и они в разных единицах.
//
// Здесь стоял тест «длина считается в символах, а не в байтах», требовавший
// приёма 64 кириллических букв. Он закреплял как ожидаемое ровно то значение,
// на котором продукт ломался: 64 символа — это 128 байт, а
// 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);
}
});
});
describe("smoke выполняет настоящий вход в панель", () => {
const smoke = () =>
fs.readFileSync(
path.join(REPO_ROOT, "orchestrator", "src", "steps", "smoke.ts"),
"utf8",
);
// Главный барьер этого релиза. Пока установка отвечала на вопрос «работает ли
// панель» только фактом LISTEN и ответом /healthz, RC2 объявлял успешной
// установку с полностью недоступной панелью.
test("smoke обращается к форме входа, а не только к healthz", () => {
const source = smoke();
expect(source).toContain("ADMIN_LOGIN_PATH");
expect(source).toContain("assertAdminLoginWorks");
expect(source).toContain("isSuccessfulLogin");
});
test("успех определяется конвертом ответа и выданным токеном", () => {
expect(
isSuccessfulLogin(
JSON.stringify({
code: 20000,
type: "ok",
data: { accessToken: "jwt", tokenType: "Bearer" },
}),
),
).toBe(true);
expect(
isSuccessfulLogin(
JSON.stringify({ code: 50000, data: { accessToken: "jwt" } }),
),
).toBe(false);
expect(
isSuccessfulLogin(
JSON.stringify({ code: 20000, data: { accessToken: "" } }),
),
).toBe(false);
expect(isSuccessfulLogin("not json")).toBe(false);
});
test("учётные данные не попадают ни в команду ошибки, ни в сообщение", () => {
const source = smoke();
const start = source.indexOf("async function assertAdminLoginWorks");
expect(start).toBeGreaterThan(-1);
const body = source.slice(start, source.indexOf("\ntype JsonObject"));
// runReadOnly (без Secret) кладёт команду в текст ошибки, а команда несёт
// пароль администратора.
expect(body).not.toMatch(/(?<!Secret)runReadOnly`/);
expect(body.match(/runReadOnlySecret`/g)).toHaveLength(1);
// Подстановка в tagged template — это НЕ утечка: значения там проходят
// через shellQuote и в текст ошибки не попадают. Утечкой была бы подстановка
// секрета в сообщение — в info() или в new Error(), — потому что оно уезжает
// в журнал установки и в diagnostics-бандл.
const messages = [
...body.matchAll(/\binfo\(([\s\S]*?)\);/g),
...body.matchAll(/new Error\(([\s\S]*?)\);/g),
].map((match) => match[1]);
expect(messages.length).toBeGreaterThan(0);
for (const message of messages) {
for (const secret of [
"adminPassword",
"request.body",
"response",
"body}",
]) {
expect(message).not.toContain(`\${${secret}`);
}
}
// Тело успешного ответа несёт токен доступа, поэтому наружу отдаётся только
// разбор ответа, а не он сам.
expect(body).toContain("describeLoginFailure(body, error)");
expect(body).toContain("describeIssuedToken(response)");
});
test("обе пробы используют один wire-helper", () => {
const source = codeOf(smoke());
expect(source.match(/requestAdminLogin\(/g)).toHaveLength(3); // два вызова и определение
const helperStart = source.indexOf("async function requestAdminLogin");
const helper = source.slice(
helperStart,
source.indexOf("type JsonObject", helperStart),
);
expect(helperStart).toBeGreaterThan(-1);
expect(helper.match(/runReadOnlySecret`curl/g)).toHaveLength(1);
});
test("wire-тело содержит pass, а не несовместимый alias password", () => {
const request = buildAdminLoginRequest('admin"name', "pass\\word ");
expect(JSON.parse(request.body)).toEqual({
username: 'admin"name',
pass: "pass\\word ",
});
expect(request.body).not.toContain('"password"');
expect(request.contentType).toBe("application/json");
});
test("UA smoke проходит действующее scanner-правило", () => {
const filter = fs.readFileSync(
path.join(REPO_ROOT, "apps", "middleware", "filter.go"),
"utf8",
);
const declared = filter.match(/regexp\.MatchString\(`\(\?i\)([^`]+)`/);
expect(declared).not.toBeNull();
const scannerPattern = new RegExp(declared![1], "i");
expect(scannerPattern.test("curl/8.10.1")).toBe(true);
expect(scannerPattern.test(ADMIN_LOGIN_SMOKE_USER_AGENT)).toBe(false);
expect(buildAdminLoginRequest("hy2xsadmin", "secret1").userAgent).toBe(
ADMIN_LOGIN_SMOKE_USER_AGENT,
);
});
test("отрицательная проба не зависит от режима операции", () => {
// Пароль в bootstrap-admin.secret устаревает в момент, когда оператор сменил
// его в панели, поэтому положительная проба install-only. Отрицательная от
// пароля не зависит и обязана работать всегда: именно она воспроизводит
// дефект RC2.
const source = smoke();
const start = source.indexOf("async function assertAdminLoginWorks");
const body = source.slice(
start,
source.indexOf("\nfunction isSuccessfulLogin"),
);
const rejected = body.indexOf("admin login rejects wrong credentials");
const modeGate = body.indexOf('context.mode !== "install"');
expect(rejected).toBeGreaterThan(-1);
expect(modeGate).toBeGreaterThan(rejected);
});
// Отрицательная проба обязана доказывать ОТКАЗ.
//
// Она проверяла `code.trim() === "200"`, то есть ровно одно: что запрос не
// закончился пятисоткой. Админка отвечает HTTP 200 и на успешный вход тоже,
// поэтому «неверные учётные данные отвергнуты» этой проверкой не следовало.
test("отрицательная проба сверяет конверт отказа, а не код HTTP", () => {
const rejected = {
code: 50000,
type: "no",
errors: [{ code: "invalid_credentials", message: "wrong password" }],
data: null,
};
expect(isRejectedLogin(JSON.stringify(rejected))).toBe(true);
expect(isRejectedLogin(JSON.stringify({ ...rejected, code: 50001 }))).toBe(
false,
);
expect(
isRejectedLogin(
JSON.stringify({ ...rejected, errors: [{ code: "required" }] }),
),
).toBe(false);
expect(
isRejectedLogin(
JSON.stringify({
...rejected,
data: { accessToken: "unexpected-token" },
}),
),
).toBe(false);
expect(isRejectedLogin(JSON.stringify({ ...rejected, data: { accessToken: "" } }))).toBe(
false,
);
expect(
isRejectedLogin(
JSON.stringify({
code: 50000,
message: 'fake {"code":"invalid_credentials"}',
data: null,
}),
),
).toBe(false);
expect(isRejectedLogin("not json")).toBe(false);
});
// Доменный код обязан совпадать с тем, который объявляет админка.
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,69 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { prepareDiagnosticFile } from "../src/commands/diagnostics";
import { runReadOnlyArgvStatus } from "../src/lib/process";
import { redactEnv } from "../src/lib/redaction";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(
directories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
);
});
async function source(bytes: Uint8Array): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "hy2xs-diagnostics-"));
directories.push(directory);
const path = join(directory, "hy2xs.env");
await writeFile(path, bytes);
return path;
}
describe("безопасная подготовка diagnostics", () => {
test("секрет редактируется до выдачи текста на запись", async () => {
const path = await source(
new TextEncoder().encode("HY2XS_ADMIN_CON_PASS=top-secret\n")
);
const prepared = await prepareDiagnosticFile(path, redactEnv);
expect(prepared).toContain("<redacted>");
expect(prepared).not.toContain("top-secret");
});
test("диагностический boolean виден, а соседний пароль скрыт", async () => {
const path = await source(
new TextEncoder().encode(
"HY2XS_FORCE_PASSWORD_CHANGE=false\nHY2XS_ADMIN_INITIAL_PASSWORD=top-secret\n"
)
);
const prepared = await prepareDiagnosticFile(path, redactEnv);
expect(prepared).toContain("HY2XS_FORCE_PASSWORD_CHANGE=false");
expect(prepared).toContain("HY2XS_ADMIN_INITIAL_PASSWORD=<redacted>");
expect(prepared).not.toContain("top-secret");
});
test("повреждённый UTF-8 заменяется безопасной причиной, а не U+FFFD", async () => {
const path = await source(
new Uint8Array([0x53, 0x45, 0x43, 0x52, 0x45, 0x54, 0x3d, 0xff])
);
const prepared = await prepareDiagnosticFile(path, redactEnv);
expect(prepared).toContain("некорректный UTF-8");
expect(prepared).not.toContain("SECRET=");
expect(prepared).not.toContain("");
});
test("вывод внешней команды ограничен по памяти и помечен как обрезанный", async () => {
const result = await runReadOnlyArgvStatus(
[process.execPath, "-e", `process.stdout.write("x".repeat(128))`],
16
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("x".repeat(16));
expect(result.stdoutTruncated).toBe(true);
});
});
@@ -0,0 +1,145 @@
import { afterEach, describe, expect, test } from "bun:test";
import { chmod, lstat, mkdir, mkdtemp, open, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
DIAGNOSTICS_ROOT,
assertTrustedDirectory,
cleanupDiagnosticsWorkspace,
createDiagnosticsWorkspace,
discardDiagnosticsArchive,
ensureDiagnosticsStorageRoot
} from "../src/lib/diagnosticsStorage";
import { disableReadOnlyGuard, enableReadOnlyGuard } from "../src/lib/guard";
const directories: string[] = [];
afterEach(async () => {
disableReadOnlyGuard();
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
function fakeStats(options: {
directory?: boolean;
symlink?: boolean;
uid?: number;
gid?: number;
mode?: number;
}): import("node:fs").Stats {
return {
isDirectory: () => options.directory ?? true,
isSymbolicLink: () => options.symlink ?? false,
uid: options.uid ?? 0,
gid: options.gid ?? 0,
mode: options.mode ?? 0o40700
} as import("node:fs").Stats;
}
async function temporaryPolicy(): Promise<{
base: string;
stateRoot: string;
diagnosticsRoot: string;
uid: number;
gid: number;
}> {
const base = await mkdtemp(join(tmpdir(), "hy2xs-diagnostics-storage-"));
directories.push(base);
const stateRoot = join(base, "hy2xs");
const diagnosticsRoot = join(stateRoot, "diagnostics");
await mkdir(stateRoot, { mode: 0o755 });
await chmod(stateRoot, 0o755);
const stats = await lstat(stateRoot);
return { base, stateRoot, diagnosticsRoot, uid: stats.uid, gid: stats.gid };
}
describe("граница привилегий diagnostics storage", () => {
test("production path не находится внутри service-writable logDir", () => {
expect(DIAGNOSTICS_ROOT).toBe("/var/lib/hy2xs/diagnostics");
expect(DIAGNOSTICS_ROOT.startsWith("/var/log/hy2xs/")).toBe(false);
});
test("создание storage проходит через read-only guard", async () => {
const policy = await temporaryPolicy();
enableReadOnlyGuard("test phase");
await expect(ensureDiagnosticsStorageRoot(policy)).rejects.toThrow("read-only guard violation");
});
test("symlink не принимается за доверенный каталог", () => {
expect(() =>
assertTrustedDirectory("/var/lib/hy2xs/diagnostics", fakeStats({ symlink: true }), {
uid: 0,
gid: 0,
exactMode: 0o700
})
).toThrow("symbolic link запрещён");
});
test("чужой владелец и записываемый родитель отвергаются", () => {
expect(() =>
assertTrustedDirectory("/var/lib/hy2xs", fakeStats({ uid: 1001, mode: 0o40755 }), {
uid: 0,
gid: 0,
rejectGroupOrOtherWrite: true
})
).toThrow("ожидается владелец 0:0");
expect(() =>
assertTrustedDirectory("/var/lib/hy2xs", fakeStats({ mode: 0o40775 }), {
uid: 0,
gid: 0,
rejectGroupOrOtherWrite: true
})
).toThrow("могут изменять root namespace");
});
test("diagnostics root требует точный режим 0700", () => {
expect(() =>
assertTrustedDirectory("/var/lib/hy2xs/diagnostics", fakeStats({ mode: 0o40750 }), {
uid: 0,
gid: 0,
exactMode: 0o700
})
).toThrow("ожидается режим 0700");
});
test("существующая symlink вместо diagnostics root отвергается fail closed", async () => {
const policy = await temporaryPolicy();
const target = join(policy.base, "attacker-controlled");
await mkdir(target);
const sentinel = join(target, "root-file");
await writeFile(sentinel, "не изменять\n");
await symlink(target, policy.diagnosticsRoot, process.platform === "win32" ? "junction" : "dir");
await expect(ensureDiagnosticsStorageRoot(policy)).rejects.toThrow("symbolic link запрещён");
expect(await readFile(sentinel, "utf8")).toBe("не изменять\n");
});
test("рабочие каталоги уникальны, а archive path заранее занят через wx", async () => {
const policy = await temporaryPolicy();
await ensureDiagnosticsStorageRoot(policy);
const first = await createDiagnosticsWorkspace(policy);
const second = await createDiagnosticsWorkspace(policy);
expect(first.outDir).not.toBe(second.outDir);
expect(first.operationId.startsWith("diag-")).toBe(true);
await expect(open(first.archive, "wx")).rejects.toBeDefined();
await cleanupDiagnosticsWorkspace(first, policy);
await cleanupDiagnosticsWorkspace(second, policy);
await discardDiagnosticsArchive(first, policy);
await discardDiagnosticsArchive(second, policy);
});
test("очистка отвергает workspace вне доверенного diagnostics root", async () => {
const policy = await temporaryPolicy();
await expect(
cleanupDiagnosticsWorkspace(
{
operationId: "diag-ABC123",
outDir: join(policy.base, "diag-ABC123"),
archive: join(policy.base, "diag-ABC123.tar.gz")
},
policy
)
).rejects.toThrow("небезопасные пути diagnostics workspace");
});
});
+311
View File
@@ -0,0 +1,311 @@
import { describe, expect, test } from "bun:test";
import {
assertEnvTransportable,
formatEnvAssignment,
isEnvTransportable,
parseEnvFile,
renderEnvFile
} from "../src/lib/envFile";
import {
parseRuntimeEnv,
renderRuntimeEnv,
runtimeEnvEntries,
validateRuntimeEnvTransport
} 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("непредставимое значение — отказ записи, а не потеря части секрета", () => {
// Отвергается документированное множество EnvironmentFile, а не то, что
// нам не нравится.
for (const rejected of [
String.fromCodePoint(0x0000),
String.fromCodePoint(0xfdd0),
String.fromCodePoint(0xffff),
String.fromCodePoint(0x10ffff),
]) {
expect(() =>
assertEnvTransportable("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`)
).toThrow(/documented systemd EnvironmentFile contract/);
expect(() =>
formatEnvAssignment("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`)
).toThrow(/documented systemd EnvironmentFile contract/);
}
});
// Одиночный суррогат — единственный случай, где без проверки не было бы даже
// отказа: `TextEncoder` молча заменил бы его на U+FFFD, то есть в файл уехал
// бы ДРУГОЙ секрет, а не сломанный.
test("одиночный суррогат отвергается, а не подменяется на U+FFFD", () => {
const lone = String.fromCharCode(0xd800);
expect(new TextEncoder().encode(lone)).toEqual(new Uint8Array([0xef, 0xbf, 0xbd]));
expect(isEnvTransportable(lone)).toBe(false);
expect(() => assertEnvTransportable("HY2XS_ADMIN_CON_PASS", `abcde${lone}`)).toThrow(
/documented systemd EnvironmentFile contract/
);
});
// Домен транспорта — ЧУЖОЕ документированное множество systemd.
//
// Управляющие символы формат несёт: внутри двойных кавычек перевод строки
// накапливается как обычный байт и переживает round-trip. Запрещает их
// контракт учётных данных, а не транспорт, и приписывать этот запрет формату
// было бы неправдой — именно так проверка и пропустила noncharacters, о
// которых ничего не знала.
test("управляющие символы формат несёт: их запрещает контракт, а не транспорт", () => {
for (const control of ["\n", "\r", "\t", String.fromCodePoint(0x7f), String.fromCodePoint(0x85)]) {
const value = `abcde${control}fghij`;
expect(isEnvTransportable(value)).toBe(true);
const rendered = renderEnvFile([["HY2XS_ADMIN_INITIAL_PASSWORD", value]]);
expect(parseEnvFile(rendered).HY2XS_ADMIN_INITIAL_PASSWORD).toBe(value);
}
// Публичный контракт EnvironmentFile запрещает U+FEFF. Реализация v257.13
// случайно пропускает его; продукт не закрепляет это расхождение.
expect(isEnvTransportable(String.fromCodePoint(0xfeff))).toBe(false);
});
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
// множество systemd, а не окрестность подозрительных значений.
test("соседи noncharacters принимаются", () => {
for (const accepted of [0xfdcf, 0xfdf0, 0xfffd, 0x10fffd, 0x1f600]) {
expect(isEnvTransportable(String.fromCodePoint(accepted))).toBe(true);
}
});
});
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");
});
});
describe("непредставимая конфигурация отвергается до первой мутации", () => {
// Проверка транспорта жила ТОЛЬКО внутри renderRuntimeEnv, то есть
// срабатывала на шаге «write runtime env» — уже после bootstrap оркестратора,
// установки пакетов и раскладки файловой системы. Read-only
// `preflight-install` при этом говорил PASS: он зовёт parseRuntimeEnv и
// ничего не рендерит. Детерминированно известная ошибка конфигурации роняла
// операцию, оставив за собой изменённый хост.
test("parseRuntimeEnv отвергает значение, которое systemd не загрузит", () => {
const noncharacter = String.fromCodePoint(0xfdd0);
expect(() =>
parseRuntimeEnv(envText({ HY2XS_ADMIN_INITIAL_PASSWORD: `"abcde${noncharacter}"` }))
).toThrow();
});
test("исходный U+FEFF отвергается до разбора, даже вне используемого значения", () => {
for (const source of [
`\uFEFF${envText()}`,
`${envText()}\n# comment \uFEFF\n`,
`${envText()}\nUNKNOWN_KEY="value\uFEFF"\n`
]) {
expect(() => parseRuntimeEnv(source)).toThrow(/U\+FEFF/);
}
});
// Ограничение принадлежит ФОРМАТУ, а не полю пароля: любой параметр сломал бы
// загрузку юнита тем же способом. HY2XS_ADMIN_CON_PASS проходит через
// requireValue и никаких проверок содержимого раньше не имел вовсе.
test("проверяется каждое значение, а не только пароль администратора", () => {
const noncharacter = String.fromCodePoint(0xffff);
for (const key of [
"HY2XS_ADMIN_CON_PASS",
"HY2XS_HYSTERIA_BANDWIDTH_UP",
"HY2XS_ACME_EMAIL"
]) {
expect(() => parseRuntimeEnv(envText({ [key]: `"value${noncharacter}"` }))).toThrow(
/documented systemd EnvironmentFile contract/
);
}
});
// Список пар — один на запись и на проверку. Пока он существовал только
// внутри рендера, единственным способом узнать, что конфигурация не
// запишется, было её записать.
test("проверка и запись ходят по одному списку пар", () => {
const config = baselineConfig();
const entries = runtimeEnvEntries(config);
const rendered = renderRuntimeEnv(config);
expect(entries.length).toBeGreaterThan(20);
for (const [key] of entries) {
expect(rendered).toContain(`\n${key}=`);
}
expect(() => validateRuntimeEnvTransport(config)).not.toThrow();
});
// Гарантия целиком: всё, что parseRuntimeEnv принял, обязано записаться.
test("принятая конфигурация записывается без отказа", () => {
const config = parseRuntimeEnv(
envText({ HY2XS_ADMIN_INITIAL_PASSWORD: '"пароль с пробелом "' })
);
expect(() => renderRuntimeEnv(config)).not.toThrow();
});
});
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { InvalidUtf8Error } from "../src/lib/fs";
import { isMissingPackageMetadataError, readPackageValue } from "../src/lib/packageMeta";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
async function packageDir(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "hy2xs-metadata-"));
directories.push(directory);
await mkdir(join(directory, "metadata"));
return directory;
}
describe("metadata пакета", () => {
test("fallback классифицирует только ENOENT", () => {
expect(isMissingPackageMetadataError(Object.assign(new Error("missing"), { code: "ENOENT" }))).toBe(true);
for (const code of ["EACCES", "EIO", "EISDIR"]) {
expect(isMissingPackageMetadataError(Object.assign(new Error(code), { code }))).toBe(false);
}
expect(isMissingPackageMetadataError(new InvalidUtf8Error("metadata/value", new Error("decode")))).toBe(false);
});
test("отсутствующее необязательное значение использует fallback", async () => {
const directory = await packageDir();
expect(await readPackageValue(directory, "missing", "unknown")).toBe("unknown");
});
test("повреждённый UTF-8 не маскируется fallback-значением", async () => {
const directory = await packageDir();
await writeFile(join(directory, "metadata", "package.version"), new Uint8Array([0x31, 0xff]));
await expect(readPackageValue(directory, "package.version", "unknown")).rejects.toBeInstanceOf(
InvalidUtf8Error
);
});
test("каталог вместо metadata-файла не маскируется fallback-значением", async () => {
const directory = await packageDir();
await mkdir(join(directory, "metadata", "package.version"));
await expect(readPackageValue(directory, "package.version", "unknown")).rejects.toBeDefined();
});
});
+63 -12
View File
@@ -6,7 +6,7 @@ import {
redactLogText,
redactYaml,
redactYamlFallback,
sanitizeUrlValue
sanitizeUrlValue,
} from "../src/lib/redaction";
const MACHINE_TOKEN = "hy2xs-machine-token-must-not-leak";
@@ -58,7 +58,10 @@ describe("редакция серверного конфига", () => {
});
test("результат остаётся валидным YAML", () => {
const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record<string, unknown>;
const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record<
string,
unknown
>;
expect(parsed.listen).toBe("0.0.0.0:443");
const auth = parsed.auth as Record<string, unknown>;
const http = auth.http as Record<string, unknown>;
@@ -69,7 +72,10 @@ describe("редакция серверного конфига", () => {
});
test("несекретные поля сохраняются: диагностика должна оставаться полезной", () => {
const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record<string, unknown>;
const parsed = Bun.YAML.parse(redactYaml(SERVER_CONFIG)) as Record<
string,
unknown
>;
const acme = parsed.acme as Record<string, unknown>;
expect(acme.email).toBe("admin@example.com");
expect(acme.ca).toBe("letsencrypt");
@@ -92,7 +98,10 @@ describe("редакция серверного конфига", () => {
test("вложенное дерево под секретным ключом вырезается целиком", () => {
const yaml = `acme:\n dns:\n name: cloudflare\n config:\n api_token: leak-me\n zone: example.com\n`;
const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record<string, unknown>;
const dns = (parsed.acme as Record<string, unknown>).dns as Record<string, unknown>;
const dns = (parsed.acme as Record<string, unknown>).dns as Record<
string,
unknown
>;
const cfg = dns.config as Record<string, unknown>;
expect(cfg.api_token).toBe(REDACTED);
// acme.dns.config — карта, где секретны все значения, а не только ключ.
@@ -158,7 +167,7 @@ describe("редакция env-артефактов", () => {
"HY2_AUTH_MODE=http",
`HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}`,
`HY2_OBFS_PASSWORD=${OBFS_PASSWORD}`,
"HY2_PORT=443"
"HY2_PORT=443",
].join("\n");
// Регрессия: HY2_AUTH_URL не подходит ни под один key-маркер, но несёт
@@ -166,7 +175,9 @@ describe("редакция env-артефактов", () => {
test("секрет внутри URL-значения вырезается, даже если имя ключа несекретное", () => {
const redacted = redactEnv(POST_INSTALL);
expect(redacted).not.toContain(MACHINE_TOKEN);
expect(redacted).toContain("HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth");
expect(redacted).toContain(
"HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth"
);
});
test("ключи-секреты вырезаются по имени", () => {
@@ -180,13 +191,40 @@ describe("редакция env-артефактов", () => {
expect(redacted).toContain("HY2_PORT=443");
});
test("boolean-флаг принудительной смены пароля остаётся диагностируемым", () => {
const flags = [
"HY2XS_FORCE_PASSWORD_CHANGE=false",
"HY2XS_FORCE_PASSWORD_CHANGE=true",
].join("\n");
expect(redactEnv(flags)).toBe(flags);
});
test("исключение для boolean-флага точное и не ослабляет защиту", () => {
const input = [
"HY2XS_FORCE_PASSWORD_CHANGE_SUFFIX=must-hide",
"HY2XS_FORCE_PASSWORD_CHANGE=malformed-secret",
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password",
"HY2XS_ADMIN_CON_PASS=connection-password",
`HY2XS_HYSTERIA_OBFS_PASSWORD=${OBFS_PASSWORD}`,
].join("\n");
const redacted = redactEnv(input);
expect(redacted).not.toContain("must-hide");
expect(redacted).not.toContain("initial-password");
expect(redacted).not.toContain("connection-password");
expect(redacted).not.toContain(OBFS_PASSWORD);
expect(redacted.match(/<redacted>/g)).toHaveLength(5);
});
test("runtime-конфиг теряет все секреты", () => {
const runtime = [
"HY2XS_CONFIG_SCHEMA_VERSION=2",
"HY2XS_FORCE_PASSWORD_CHANGE=false",
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${MACHINE_TOKEN}`,
`HY2XS_HYSTERIA_OBFS_PASSWORD=${OBFS_PASSWORD}`,
"HY2XS_ADMIN_CON_PASS=connection-password",
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password"
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password",
].join("\n");
const redacted = redactEnv(runtime);
@@ -195,16 +233,21 @@ describe("редакция env-артефактов", () => {
expect(redacted).not.toContain("connection-password");
expect(redacted).not.toContain("initial-password");
expect(redacted).toContain("HY2XS_CONFIG_SCHEMA_VERSION=2");
expect(redacted).toContain("HY2XS_FORCE_PASSWORD_CHANGE=false");
});
});
describe("санитизация URL", () => {
test("креденшелы в URL вырезаются", () => {
expect(sanitizeUrlValue("https://user:hunter2@example.com/x")).not.toContain("hunter2");
expect(
sanitizeUrlValue("https://user:hunter2@example.com/x")
).not.toContain("hunter2");
});
test("секретные query-параметры вырезаются, остальные сохраняются", () => {
const out = sanitizeUrlValue("https://example.com/x?access_token=abc&page=2");
const out = sanitizeUrlValue(
"https://example.com/x?access_token=abc&page=2"
);
expect(out).not.toContain("abc");
expect(out).toContain("page=2");
});
@@ -245,7 +288,7 @@ describe("редакция журналов в diagnostics-бандле", () =>
for (const line of [
`starting with access_token=${MACHINE_TOKEN}`,
`trafficStats secret=${MACHINE_TOKEN} rejected`,
`obfs password: ${OBFS_PASSWORD}`
`obfs password: ${OBFS_PASSWORD}`,
]) {
const out = redactLogText(line);
expect(out).not.toContain(MACHINE_TOKEN);
@@ -288,14 +331,22 @@ describe("маркеры секретных имён", () => {
"cookie",
"bearer",
"signature",
"passphrase"
"passphrase",
]) {
expect(isSecretKey(key)).toBe(true);
}
});
test("не задевают обычные поля", () => {
for (const key of ["listen", "type", "domains", "minPacketSize", "email", "ca", "dir"]) {
for (const key of [
"listen",
"type",
"domains",
"minPacketSize",
"email",
"ca",
"dir",
]) {
expect(isSecretKey(key)).toBe(false);
}
});
+16 -10
View File
@@ -5,7 +5,7 @@ import {
HYSTERIA_HASHES_ASSET,
HYSTERIA_LINUX_AMD64_ASSET,
selectLatestStableRelease,
type GithubRelease
type GithubRelease,
} from "../src/build/hysteriaRelease";
function assetUrl(tag: string, name: string): string {
@@ -21,13 +21,13 @@ function release(tag: string): GithubRelease {
assets: [
{
name: HYSTERIA_LINUX_AMD64_ASSET,
browser_download_url: assetUrl(tag, HYSTERIA_LINUX_AMD64_ASSET)
browser_download_url: assetUrl(tag, HYSTERIA_LINUX_AMD64_ASSET),
},
{
name: HYSTERIA_HASHES_ASSET,
browser_download_url: assetUrl(tag, HYSTERIA_HASHES_ASSET)
}
]
browser_download_url: assetUrl(tag, HYSTERIA_HASHES_ASSET),
},
],
};
}
@@ -63,7 +63,7 @@ describe("install-time никогда не резолвит latest", () => {
["commands", "install.ts"],
["steps", "hysteria.ts"],
["lib", "packageMeta.ts"],
["commands", "reconfigure.ts"]
["commands", "reconfigure.ts"],
];
test("install-time код не импортирует build-time резолвер", () => {
@@ -83,13 +83,19 @@ describe("install-time никогда не резолвит latest", () => {
test("установка берёт версию, url и sha256 только из metadata пакета", () => {
const install = srcFile("commands", "install.ts");
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.version"');
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.url"');
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.sha256"');
for (const key of ["hysteria.version", "hysteria.url", "hysteria.sha256"]) {
expect(install).toMatch(
new RegExp(
`readPackageValue\\(\\s*options\\.packageDir,\\s*"${key.replace(".", "\\.")}"`,
),
);
}
});
test("отсутствие любой части lock-метаданных прерывает установку", () => {
expect(srcFile("commands", "install.ts")).toContain("missing Hysteria lock metadata in package");
expect(srcFile("commands", "install.ts")).toContain(
"missing Hysteria lock metadata in package",
);
});
test("установленный бинарник сверяется с закреплённой версией", () => {
+58 -27
View File
@@ -25,7 +25,10 @@ import { persistFailureState, runRollbackStages } from "../src/lib/rollback";
// Пути считаются от файла теста, а не от cwd: `bun test` запускается и из корня
// репозитория (сборка), и из orchestrator/ (разработчик).
function source(relativeToSrc: string): string {
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
return readFileSync(
join(import.meta.dir, "..", "src", relativeToSrc),
"utf8",
);
}
describe("стадии отката независимы", () => {
@@ -33,7 +36,7 @@ describe("стадии отката независимы", () => {
const executed: string[] = [];
const failures = await runRollbackStages([
{ name: "firewall", run: async () => void executed.push("firewall") },
{ name: "services", run: async () => void executed.push("services") }
{ name: "services", run: async () => void executed.push("services") },
]);
expect(executed).toEqual(["firewall", "services"]);
@@ -49,10 +52,13 @@ describe("стадии отката независимы", () => {
run: async () => {
executed.push("firewall");
throw new Error("nft: command failed");
}
},
},
{ name: "restore configuration", run: async () => void executed.push("restore") },
{ name: "stop services", run: async () => void executed.push("stop") }
{
name: "restore configuration",
run: async () => void executed.push("restore"),
},
{ name: "stop services", run: async () => void executed.push("stop") },
]);
expect(executed).toEqual(["firewall", "restore", "stop"]);
@@ -67,15 +73,15 @@ describe("стадии отката независимы", () => {
name: "firewall",
run: async () => {
throw new Error("first");
}
},
},
{ name: "healthy", run: async () => undefined },
{
name: "restore configuration",
run: async () => {
throw new Error("second");
}
}
},
},
]);
expect(failures).toHaveLength(2);
@@ -91,14 +97,14 @@ describe("стадии отката независимы", () => {
name: "a",
run: async () => {
throw new Error("boom");
}
},
},
{
name: "b",
run: async () => {
throw new Error("boom");
}
}
},
},
]);
expect(failures).toHaveLength(2);
});
@@ -109,8 +115,8 @@ describe("стадии отката независимы", () => {
name: "weird",
run: async () => {
throw "строковая ошибка";
}
}
},
},
]);
expect(failures[0]).toContain("строковая ошибка");
});
@@ -135,7 +141,7 @@ describe("состояние отказа пишется best effort", () => {
await expect(
persistFailureState(async () => {
throw new Error("ENOSPC: no space left on device");
})
}),
).resolves.toBeUndefined();
});
});
@@ -173,7 +179,10 @@ describe("install: откат обязателен после операцион
// Прямых await-вызовов отката в теле rollbackFailedInstall быть не должно:
// именно они и образовывали отменяемую цепочку.
const start = installSource.indexOf("async function rollbackFailedInstall");
const body = installSource.slice(start, installSource.indexOf("export async function install"));
const body = installSource.slice(
start,
installSource.indexOf("export async function install"),
);
expect(body).toContain("await rollbackFirewallNow(context)");
// Вызов существует только внутри стадии.
const firewallAt = body.indexOf("await rollbackFirewallNow(context)");
@@ -182,14 +191,19 @@ describe("install: откат обязателен после операцион
});
test("остановка сервисов остаётся отдельными стадиями", () => {
for (const stage of ["stop services", "disable services", "reset failed services"]) {
for (const stage of [
"stop services",
"disable services",
"reset failed hysteria-server",
"reset failed hy2xs-admin",
]) {
expect(installSource).toContain(`name: "${stage}"`);
}
});
test("чужие сервисы по-прежнему не трогаются", () => {
expect(installSource).toContain(
"systemd units were not deployed by this operation, leaving services untouched"
"systemd units were not deployed by this operation, leaving services untouched",
);
});
});
@@ -202,7 +216,7 @@ describe("reconfigure: откат обязателен после операци
// выполняться ВНУТРИ persistFailureState, в какую бы строку его ни перенёс
// редактор.
const wrapped = reconfigureSource.match(
/await persistFailureState\(\(\) =>\s*markPhase\(context,\s*classifyReconfigureFailure\(ownership, error\), message\)\s*\)/
/await persistFailureState\(\(\) =>\s*markPhase\(context,\s*classifyReconfigureFailure\(ownership, error\), message\)\s*\)/,
);
expect(wrapped).not.toBeNull();
});
@@ -222,8 +236,12 @@ describe("reconfigure: откат обязателен после операци
// Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком.
test("порядок сохранён: сначала firewall, затем конфиги", () => {
const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
const restore = reconfigureSource.indexOf("await rollbackCurrentState(context)");
const firewall = reconfigureSource.indexOf(
"await rollbackFirewallNow(context)",
);
const restore = reconfigureSource.indexOf(
"await rollbackCurrentState(context)",
);
const stages = reconfigureSource.indexOf("await runRollbackStages(stages)");
expect(firewall).toBeGreaterThan(-1);
expect(firewall).toBeLessThan(restore);
@@ -242,14 +260,19 @@ describe("reconfigure: откат обязателен после операци
const start = reconfigureSource.indexOf("function restoreStages(");
const body = reconfigureSource.slice(
start,
reconfigureSource.indexOf("async function rollbackCurrentState")
reconfigureSource.indexOf("async function rollbackCurrentState"),
);
const muted = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
.filter(
(line) => line.includes("|| true") || line.includes("2>/dev/null"),
);
expect(muted, `восстановление снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
expect(
muted,
`восстановление снова скрывает ошибки: ${muted.join("; ")}`,
).toEqual([]);
});
test("восстановление разбито на независимые стадии", () => {
@@ -260,7 +283,7 @@ describe("reconfigure: откат обязателен после операци
"restore nftables files",
"apply restored ruleset",
"reload systemd units",
"restart services"
"restart services",
]) {
expect(reconfigureSource).toContain(`"${stage}"`);
}
@@ -269,7 +292,9 @@ describe("reconfigure: откат обязателен после операци
// Неполное восстановление обязано доходить до итоговой сводки как отказ.
test("частичное восстановление не выдаётся за успешное", () => {
expect(reconfigureSource).toContain("restoreFailures.length > 0");
expect(reconfigureSource).toContain("восстановление состояния выполнено не полностью");
expect(reconfigureSource).toContain(
"восстановление состояния выполнено не полностью",
);
});
});
@@ -282,12 +307,18 @@ describe("install: команды отката не глушат собстве
// них не выполнялось никогда.
test("стадии остановки сервисов не подавляют код возврата", () => {
const start = installSource.indexOf("async function rollbackFailedInstall");
const body = installSource.slice(start, installSource.indexOf("export async function install"));
const body = installSource.slice(
start,
installSource.indexOf("export async function install"),
);
const muted = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true"));
expect(muted, `стадии отката снова скрывают ошибки: ${muted.join("; ")}`).toEqual([]);
expect(
muted,
`стадии отката снова скрывают ошибки: ${muted.join("; ")}`,
).toEqual([]);
});
});
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { InvalidUtf8Error, readText } from "../src/lib/fs";
import { disableReadOnlyGuard } from "../src/lib/guard";
import { preflightInstall } from "../src/commands/preflight-install";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
async function fixture(bytes: Uint8Array): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "hy2xs-utf8-"));
directories.push(directory);
const path = join(directory, "config.env");
await writeFile(path, bytes);
return path;
}
describe("строгое чтение текстовых файлов", () => {
for (const [name, bytes] of [
["байт FF", new Uint8Array([0x61, 0xff, 0x62])],
["оборванная последовательность C3", new Uint8Array([0x61, 0xc3])],
["UTF-8-кодирование суррогата", new Uint8Array([0x61, 0xed, 0xa0, 0x80])]
] as const) {
test(`отвергает ${name} без U+FFFD`, async () => {
const path = await fixture(bytes);
await expect(readText(path)).rejects.toBeInstanceOf(InvalidUtf8Error);
});
}
test("сохраняет начальный BOM как U+FEFF для транспортной проверки", async () => {
const path = await fixture(new Uint8Array([0xef, 0xbb, 0xbf, 0x41, 0x3d, 0x31, 0x0a]));
expect(await readText(path)).toBe("\uFEFFA=1\n");
});
test("не путает настоящий U+FFFD с ошибкой декодирования", async () => {
const text = "A=abcde\n";
const path = await fixture(new TextEncoder().encode(text));
expect(await readText(path)).toBe(text);
});
test("byte-level отказ preflight не изменяет каталог пакета", async () => {
for (const bytes of [
new Uint8Array([0xff]),
new Uint8Array([0xc3]),
new Uint8Array([0xed, 0xa0, 0x80])
]) {
const path = await fixture(bytes);
const packageDir = join(path, "..");
const before = await readdir(packageDir);
try {
await expect(
preflightInstall({
packageDir,
sourceConfigPath: path,
runtimeConfigPath: path,
nonInteractive: true,
skipFirewall: false,
skipServiceStart: false,
skipSmoke: false
})
).rejects.toBeInstanceOf(InvalidUtf8Error);
} finally {
disableReadOnlyGuard();
}
expect(await readdir(packageDir)).toEqual(before);
}
});
});
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, test } from "bun:test";
import {
resetFailedUnit,
type ResetFailedDependencies,
} from "../src/lib/systemd";
import type { MutationResult } from "../src/lib/process";
import { runRollbackStages } from "../src/lib/rollback";
function result(exitCode: number, stdout = "", stderr = ""): MutationResult {
return { exitCode, stdout, stderr };
}
function dependencies(
reset: MutationResult,
inspect: MutationResult,
calls: string[] = [],
): ResetFailedDependencies {
return {
reset: async (unit) => {
calls.push(`reset:${unit}`);
return reset;
},
inspect: async (unit) => {
calls.push(`inspect:${unit}`);
return inspect;
},
};
}
describe("systemd rollback cleanup", () => {
test("успешный reset-failed подтверждается наблюдением", async () => {
const calls: string[] = [];
await expect(
resetFailedUnit(
"hy2xs-admin",
dependencies(
result(0),
result(0, "LoadState=loaded\nActiveState=inactive\n"),
calls,
),
),
).resolves.toBeUndefined();
expect(calls).toEqual(["reset:hy2xs-admin", "inspect:hy2xs-admin"]);
});
test("уже выгруженный юнит является успешным конечным состоянием", async () => {
await expect(
resetFailedUnit(
"hy2xs-admin",
dependencies(
result(5, "", "Unit hy2xs-admin.service not loaded."),
result(0, "LoadState=not-found\nActiveState=inactive\n"),
),
),
).resolves.toBeUndefined();
});
test("уже выгруженный юнит не создаёт manual-recovery failure", async () => {
const failures = await runRollbackStages([
{
name: "reset failed hy2xs-admin",
run: async () =>
resetFailedUnit(
"hy2xs-admin",
dependencies(
result(5, "", "Unit hy2xs-admin.service not loaded."),
result(0, "LoadState=not-found\nActiveState=inactive\n"),
),
),
},
]);
expect(failures).toEqual([]);
});
test("решение не зависит от английского текста stderr", async () => {
await expect(
resetFailedUnit(
"hysteria-server",
dependencies(
result(5, "", "модуль не загружен"),
result(0, "LoadState=not-found\nActiveState=inactive\n"),
),
),
).resolves.toBeUndefined();
});
test("сохранившееся failed-состояние остаётся настоящим отказом", async () => {
await expect(
resetFailedUnit(
"hysteria-server",
dependencies(
result(1, "", "access denied"),
result(0, "LoadState=loaded\nActiveState=failed\n"),
),
),
).rejects.toThrow("remains failed");
});
test("ошибка наблюдения не выдаётся за успешную уборку", async () => {
await expect(
resetFailedUnit(
"hysteria-server",
dependencies(result(1), result(1, "", "Failed to connect to bus")),
),
).rejects.toThrow("cannot verify systemd state");
});
test("неполный ответ systemctl show отклоняется fail-closed", async () => {
await expect(
resetFailedUnit(
"hysteria-server",
dependencies(result(0), result(0, "LoadState=loaded\n")),
),
).rejects.toThrow("did not return LoadState and ActiveState");
});
});
+2 -1
View File
@@ -8,6 +8,7 @@
*/
import { parseUpstreamHashes } from "../src/build/hysteriaRelease";
import { readText } from "../src/lib/fs";
function fail(message: string): never {
console.error(`[hy2xs-build] ERROR: ${message}`);
@@ -47,7 +48,7 @@ function parseArgs(argv: string[]): { file: string; asset: string } {
const { file, asset } = parseArgs(Bun.argv.slice(2));
try {
const text = await Bun.file(file).text();
const text = await readText(file);
process.stdout.write(`${parseUpstreamHashes(text, asset)}\n`);
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
@@ -12,6 +12,7 @@
import { parseRuntimeEnv } from "../src/config/env";
import { HYSTERIA_OBFS_TYPES } from "../src/config/profile";
import { readText } from "../src/lib/fs";
import { hysteriaConfigTemplatePath, renderHysteriaConfig } from "../src/steps/config";
import type { RuntimeContext } from "../src/types/context";
@@ -138,7 +139,7 @@ async function main(): Promise<void> {
overrides.HY2XS_HYSTERIA_TRAFFIC_STATS_PORT = options.trafficStatsPort;
}
const sourceEnv = await Bun.file(options.configPath).text();
const sourceEnv = await readText(options.configPath);
const config = parseRuntimeEnv(overrideEnv(sourceEnv, overrides));
const context = {
@@ -160,7 +161,7 @@ async function main(): Promise<void> {
hysteriaResolution: "compat-gate"
} satisfies RuntimeContext;
const template = await Bun.file(options.templatePath).text();
const template = await readText(options.templatePath);
await Bun.write(options.outPath, renderHysteriaConfig(context, template));
}
+5
View File
@@ -22,6 +22,11 @@
./install.sh --config /root/custom-hy2xs.env --non-interactive
```
Файл конфигурации обязан быть корректным UTF-8 без BOM/U+FEFF, NUL,
суррогатов и Unicode noncharacters. Установщик читает исходные байты строго и
отвергает повреждение на read-only preflight: молчаливой замены на U+FFFD и
изменений хоста при таком отказе нет.
В baseline нет target-side JavaScript, TypeScript, frontend или Go build step.
## Установка выполняется в две фазы
+14 -9
View File
@@ -63,15 +63,16 @@ Windows и macOS можно использовать для редактиров
и сверяет каждый архив с контрольной суммой из `versions.env`.
6. Выполняет `verify_versions_contract`: рассинхрон версий роняет сборку до создания tarball.
7. Прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`).
8. Разрешает upstream-версию Hysteria, берёт ожидаемый SHA-256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт.
9. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS.
10. Копирует package skeleton.
11. Собирает install-only orchestrator в standalone binary.
12. Собирает frontend и backend HY2XS admin в Linux amd64 binary, проставляя версию админки через ldflags.
13. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов).
14. Записывает metadata и checksums.
15. Создаёт `dist/hy2xs-install-<version>.tar.gz`.
16. Проверяет архив и прогоняет acceptance-проверки.
8. Рано прогоняет dependency-free контракты панели.
9. Разрешает upstream-версию Hysteria, берёт ожидаемый SHA-256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт.
10. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS.
11. Копирует package skeleton и собирает install-only orchestrator.
12. Устанавливает frozen frontend lock-граф, runtime-компилирует все сообщения RU/EN реальным `vue-i18n`, проверяет типы и собирает production bundle.
13. Собирает backend HY2XS admin в Linux amd64 binary, проставляя версию через ldflags.
14. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов).
15. Проверяет зависимости через `govulncheck ./...` и `pnpm audit` по всему lock-графу.
16. Записывает metadata и checksums.
17. Создаёт архив и прогоняет его acceptance-проверки.
## Контракт версий
@@ -99,6 +100,10 @@ Go-коде больше нет: она уже успела разъехатьс
только политика `HYSTERIA_CHANNEL`, результат резолва — в
[`hysteria-lock.env`](hysteria-lock.env)).
Security overrides frontend находятся в `apps/frontend/pnpm-workspace.yaml`:
это канонический файл настроек pnpm. Поле `pnpm` в `package.json` для этой цели
не используется, потому что новые версии package manager его игнорируют.
## Версия Hysteria: разрешение и compatibility gate
Builder не хранит версию Hysteria вручную. По умолчанию он определяет последнюю стабильную версию сам и замораживает её в пакете.
+3 -3
View File
@@ -43,9 +43,9 @@ main() {
log_step "Checking orchestrator contracts"
run_orchestrator_tests
# Контракты панели проверяются рано: они не требуют ни собранного bundle, ни
# установленных зависимостей, и падать на них после резолва Hysteria и сборки
# артефактов означало бы платить минутами за ошибку, видимую сразу.
# Dependency-free контракты панели проверяются рано. Runtime-компиляция
# словарей запускается внутри bundle_ui сразу после frozen install, потому
# что ей нужен реальный vue-i18n из frontend lock-графа.
log_step "Checking HY2XS admin frontend contracts"
run_frontend_tests
+437 -7
View File
@@ -71,6 +71,17 @@ code_without_comments() {
# bash отключает errexit на весь вызов, поэтому неудачное чтение прошло бы
# незамеченным и снова дало бы ложный PASS. `fail` выполняется в самой функции,
# а не в подоболочке подстановки, поэтому его `exit` останавливает сборку.
# fenced_code_of отдаёт КОМАНДЫ документа — строки внутри ```-блоков.
#
# Нужен по той же причине, что и code_without_comments: комментарий или абзац,
# объясняющий, почему конструкции здесь больше нет, обязан называть её по имени,
# иначе он бесполезен. Гейт «такой команды в документации нет» без этого падал бы
# ровно на том объяснении, которое описывает выполненную им же работу.
fenced_code_of() {
[ -f "$1" ] || fail "fenced_code_of: файл не найден: $1"
awk '/^```/ { inside = !inside; next } inside { print }' "$1"
}
code_has() {
local file="$1"
shift
@@ -172,11 +183,23 @@ run_fix20_acceptance_subset() {
|| fail "acceptance: the default obfs type must not be re-declared in: $default_declarations"
log_step "Acceptance: runtime env is derived from config, not literals"
! grep -Eq 'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' orchestrator/src/config/env.ts \
|| fail "acceptance: renderRuntimeEnv must not print a hardcoded obfs type"
grep -q 'HY2XS_HYSTERIA_OBFS_TYPE=\${config.hysteriaObfsType}' orchestrator/src/config/env.ts \
|| fail "acceptance: renderRuntimeEnv must derive the obfs type from the parsed config"
! grep -Eq '\|\|\s*"(gecko|salamander)"' orchestrator/src/config/env.ts \
# runtime env mappings live in runtimeEnvEntries(); renderRuntimeEnv only
# serializes that shared list. Do not require the old inline interpolation.
! grep -Eq \
'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' \
orchestrator/src/config/env.ts \
|| fail "acceptance: runtime env must not contain a hardcoded obfs type"
grep -Eq \
'\["HY2XS_HYSTERIA_OBFS_TYPE",[[:space:]]*config\.hysteriaObfsType\]' \
orchestrator/src/config/env.ts \
|| fail "acceptance: runtimeEnvEntries must derive the obfs type from the parsed config"
grep -Eq \
'renderEnvFile\(runtimeEnvEntries\(config\)\)' \
orchestrator/src/config/env.ts \
|| fail "acceptance: renderRuntimeEnv must render the shared runtimeEnvEntries list"
! grep -Eq \
'\|\|[[:space:]]*"(gecko|salamander)"' \
orchestrator/src/config/env.ts \
|| fail "acceptance: env.ts must not carry its own obfs fallback default"
log_step "Acceptance: package metadata records how the Hysteria version was chosen"
@@ -768,9 +791,353 @@ run_clean_install_acceptance() {
run_legacy_account_acceptance
run_scheduler_lifecycle_acceptance
run_dead_config_acceptance
run_admin_login_acceptance
run_dependency_hygiene_acceptance "$package_dir"
}
# Вход в панель: барьеры, без которых RC2 повторяется.
#
# Гейт существует по той же причине, что и гейт детектора гонок: проверки,
# закрывающие класс дефекта, не имеют права тихо исчезнуть. Удалённый тест
# оставляет сборку зелёной, а выпускаемый артефакт — неотличимым.
#
# Проверяется НАЛИЧИЕ барьеров, а не поведение: поведение доказывают сами
# тесты, и они обязательны (run_admin_tests, run_orchestrator_tests,
# run_frontend_tests). Здесь удерживается то, что их есть кому доказывать.
run_admin_login_acceptance() {
log_step "Acceptance: every validation tag is proven to exist before release"
# Дефект RC2 целиком: тег `validateStr` пережил переименование правила в
# `credentialStr`, валидатор паниковал на КАЖДОМ входе, gin.Recovery отдавал
# HTTP 500, установка при этом объявляла успех.
code_has apps/controller/auth_test.go -F -- 'func TestEveryValidationTagIsRegistered' \
|| fail "acceptance: барьер существования правил валидации удалён — stale-тег снова станет runtime-дефектом"
code_has apps/controller/auth_test.go -F -- 'validate.Var(' \
|| fail "acceptance: барьер обязан предъявлять тег валидатору, а не сравнивать строки"
# Наивная замена барьера не работает: цепочка правил поля обрывается на
# первом несработавшем, поэтому нулевое DTO отказывает по `required` и до
# испорченного тега не доходит. Барьер обязан ходить по ИСХОДНИКАМ.
code_has apps/controller/auth_test.go -F -- 'filepath.WalkDir' \
|| fail "acceptance: барьер перестал обходить исходники модели"
log_step "Acceptance: the admin credential contract has a single owner"
# Контракт живёт в LEAF-пакете, а не в service. Это не вкусовщина: его зовут
# util.HashPassword и dao (bootstrap), а `service` импортирует `util` —
# обратный импорт был бы циклическим. Пока контракт лежал в service,
# HashPassword завёл собственную проверку `len(TrimSpace(p)) < 6`, и она
# разошлась с остальным продуктом.
[ -f apps/credential/admin.go ] \
|| fail "acceptance: контракт учётных данных администратора отсутствует"
[ ! -f apps/service/admin_credentials.go ] \
|| fail "acceptance: контракт вернулся в service, откуда его не может позвать util.HashPassword"
code_has apps/controller/validator.go -F -- 'credential.IsValidAdminUsername' \
|| 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' \
|| 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(runtimeEnvEntries(config))' \
|| 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 transport domain is systemd's, not ours"
# systemd прогоняет ключ и значение через utf8_is_valid перед тем, как принять
# пару (src/basic/env-file.c, check_utf8ness_and_warn), и отказ там - это
# -EINVAL, то есть НЕзагруженный EnvironmentFile и юнит, который не стартует.
# Пока контракт знал только про управляющие символы, пароль `abcde` плюс
# U+FDD0 - шесть символов, восемь байт - проходил все двери продукта и
# оставлял сервер без работающей панели.
code_has apps/credential/admin.go -F -- 'func IsEnvTransportableText' \
|| fail "acceptance: контракт снова не знает домена значений systemd"
code_has apps/credential/admin.go -F -- 'IsEnvTransportableText(password)' \
|| fail "acceptance: правило пароля перестало проверять домен транспорта"
code_has orchestrator/src/lib/envFile.ts -F -- 'export function isEnvTransportable' \
|| fail "acceptance: оркестратор снова не знает домена значений systemd"
code_has orchestrator/src/config/profile.ts -F -- 'isEnvTransportable(value)' \
|| fail "acceptance: правило пароля оркестратора перестало проверять домен транспорта"
# Границы взяты из unichar_is_valid: U+FDD0..U+FDEF и (cp & 0xFFFE) == 0xFFFE.
local unicode_domain_owner
for unicode_domain_owner in apps/credential/admin.go \
orchestrator/src/lib/envFile.ts \
apps/frontend/src/constants/credentials.ts; do
code_has "$unicode_domain_owner" -E -- '0[xX][fF][dD][dD]0' \
|| fail "acceptance: $unicode_domain_owner не знает диапазон noncharacters U+FDD0..U+FDEF"
code_has "$unicode_domain_owner" -E -- '0[xX][fF][fF][fF][eE]' \
|| fail "acceptance: $unicode_domain_owner не знает noncharacters вида *FFFE/*FFFF"
code_has "$unicode_domain_owner" -E -- '0[xX][fF][eE][fF][fF]' \
|| fail "acceptance: $unicode_domain_owner не отвергает документированный U+FEFF"
done
# Одиночный суррогат в JS-строке TextEncoder молча заменяет на U+FFFD: без
# проверки это была бы не ошибка, а тихая подмена секрета.
local surrogate_owner
for surrogate_owner in orchestrator/src/lib/envFile.ts \
apps/frontend/src/constants/credentials.ts; do
code_has "$surrogate_owner" -E -- '0[xX][dD]800' \
|| fail "acceptance: $surrogate_owner не отвергает одиночные суррогаты"
done
log_step "Acceptance: text files use fatal UTF-8 decoding"
code_has orchestrator/src/lib/fs.ts -F -- '.bytes()' \
|| fail "acceptance: readText снова не читает исходные байты"
code_has orchestrator/src/lib/fs.ts -F -- 'fatal: true' \
|| fail "acceptance: readText снова допускает replacement decoding"
code_has orchestrator/src/lib/fs.ts -F -- 'ignoreBOM: true' \
|| fail "acceptance: reader снова съедает BOM до проверки U+FEFF"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8");
const start = source.indexOf("export async function readText");
const end = source.indexOf("\nexport async function writeText", start);
const body = source.slice(start, end);
if (body.includes(".text()")) throw new Error("readText использует replacement decoding");
' || fail "acceptance: строгий файловый reader заменён на Blob.text()"
! grep -R -F --include='*.ts' 'Bun.file(' orchestrator/tools \
| grep -F '.text()' >/dev/null \
|| fail "acceptance: build-time tool обходит строгий файловый reader"
# Порт автомата обязан повторять upstream буквально: в состоянии
# DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не проверку на любой
# перевод строки, - иначе `\<CR>` мы прочитали бы иначе, чем systemd.
code_has orchestrator/src/lib/envFile.ts -F -- 'c !== "\n"' \
|| fail "acceptance: escape в двойных кавычках разошёлся с автоматом systemd"
log_step "Acceptance: unusable configuration is rejected before the first mutation"
# Проверка транспорта жила только внутри renderRuntimeEnv, то есть срабатывала
# на шаге записи файла - уже после bootstrap оркестратора, apt и раскладки
# файловой системы, а read-only preflight-install про неё говорил PASS.
code_has orchestrator/src/config/env.ts -F -- 'validateRuntimeEnvTransport(config);' \
|| fail "acceptance: конфигурация снова проверяется на транспорт только при записи"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/config/env.ts", "utf8");
const start = source.indexOf("export function parseRuntimeEnv");
if (start < 0) throw new Error("parseRuntimeEnv отсутствует");
const body = source.slice(start, source.indexOf("\nexport function validateRuntimeConfig"));
const rawValidation = body.indexOf("assertEnvTransportable");
const parsing = body.indexOf("parseEnv(content)");
if (rawValidation < 0 || parsing < 0 || rawValidation > parsing) {
throw new Error("исходный EnvironmentFile не проверяется до разбора");
}
if (!body.includes("validateRuntimeEnvTransport(config)")) {
throw new Error("parseRuntimeEnv не проверяет транспорт: отказ придёт уже после мутаций хоста");
}
' || fail "acceptance: проверка транспорта не входит в разбор конфигурации"
log_step "Acceptance: JSON is strict before encoding/json"
code_has apps/controller/json_body.go -F -- 'utf8.Valid(raw)' \
|| fail "acceptance: HTTP JSON снова допускает повреждённый UTF-8"
code_has apps/controller/json_body.go -F -- 'validateStrictJSONEncoding(raw)' \
|| fail "acceptance: strict JSON binder не проверяет Unicode escapes"
code_has apps/controller/validator.go -F -- 'strictBindJSON(c, &field)' \
|| fail "acceptance: DTO снова разбираются напрямую через Gin/encoding/json"
code_has apps/controller/hysteria2.go -F -- 'strictBindJSON(c, &req)' \
|| fail "acceptance: Hysteria auth обходит строгую JSON-границу"
code_has apps/controller/peer.go -F -- 'validateStrictJSONEncoding(content)' \
|| fail "acceptance: импорт пиров обходит строгую JSON-границу"
log_step "Acceptance: diagnostics never stages raw secrets"
! code_has orchestrator/src/commands/diagnostics.ts -E -- 'cp -a .*(hy2xs\.env|post-install\.env|config\.yaml)' \
|| fail "acceptance: diagnostics снова копирует секретный конфиг в staging до редакции"
code_has orchestrator/src/commands/diagnostics.ts -F -- 'prepareDiagnosticFile(source, sanitize)' \
|| fail "acceptance: diagnostics не проводит файл через строгую подготовку до записи"
! code_has orchestrator/src/commands/diagnostics.ts -F -- 'Bun.write' \
|| fail "acceptance: diagnostics снова пишет сырой промежуточный файл напрямую"
log_step "Acceptance: env files are read as data, never executed"
# Файл научился нести произвольные значения, и это сделало `source` опасным:
# для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(...)"` - буквальное значение
# (подстановок в EnvironmentFile нет), а bash внутри двойных кавычек выполнил
# бы подстановку команд - от root, прямо в рецепте восстановления доступа.
#
# Проверяются КОМАНДЫ: объяснение, почему такой строки здесь больше нет,
# обязано называть её по имени, поэтому текст вне ```-блоков не считается.
local env_doc fenced_code
while IFS= read -r env_doc; do
[ -n "$env_doc" ] || continue
# fenced_code_of сначала обязан дочитать документ целиком. `grep -q` в
# pipeline под pipefail закрывал канал после первого совпадения и мог
# превратить найденную запрещённую команду в ложный PASS через SIGPIPE 141.
fenced_code="$(fenced_code_of "$env_doc")" \
|| fail "acceptance: не удалось прочитать fenced code из $env_doc"
if grep -qE \
'(^|[;&|[:space:]])(source|\.)[[:space:]]+[^[:space:]]*(/etc/hy2xs/|hy2xs\.env|bootstrap-admin\.secret)' \
<<<"$fenced_code"; then
fail "acceptance: $env_doc снова предлагает исполнить env-файл шеллом (source/.)"
fi
if grep -qE \
'eval[[:space:]]+[^|]*(hy2xs\.env|bootstrap-admin\.secret)' \
<<<"$fenced_code"; then
fail "acceptance: $env_doc снова предлагает eval над env-файлом"
fi
done <<EOF
$(find docs package/docs -name '*.md' 2>/dev/null; echo README.md)
EOF
if grep -rqE '(^|[;&|[:space:]])(source|\.)[[:space:]]+[^[:space:]]*(/etc/hy2xs/|bootstrap-admin\.secret)' \
--include='*.sh' tools package 2>/dev/null; then
fail "acceptance: скрипт снова исполняет env-файл шеллом"
fi
log_step "Acceptance: the panel is never stricter than the server"
[ -f apps/frontend/src/constants/credentials.ts ] \
|| fail "acceptance: контракт учётных данных панели отсутствует"
local form
for form in apps/frontend/src/views/login/index.vue \
apps/frontend/src/views/admin/change-password/index.vue; do
code_has "$form" -F -- '@/constants/credentials' \
|| fail "acceptance: $form снова заводит собственное правило учётных данных"
# Неэкранированный дефис превращает `+-=` в диапазон: набор совпадал с
# серверным только потому, что обе стороны несли одну опечатку.
! code_has "$form" -F -- '_+-=' \
|| 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
log_step "Acceptance: install proves that the panel lets an operator in"
# `LISTEN` и `/healthz` не отвечают на вопрос, ради которого проверяются:
# в RC2 оба были истинны при полностью недоступной панели.
code_has orchestrator/src/steps/smoke.ts -F -- 'assertAdminLoginWorks' \
|| fail "acceptance: smoke больше не выполняет настоящий вход в панель"
code_has orchestrator/src/steps/smoke.ts -F -- 'ADMIN_LOGIN_PATH' \
|| fail "acceptance: адрес формы входа перестал браться из профиля"
# Успех определяется разобранным КОНВЕРТОМ: админка отвечает 200 OK и на
# отказ тоже, а regex по сырому тексту способен принять подстроку в message.
code_has orchestrator/src/steps/smoke.ts -F -- 'envelope?.code === 20000' \
|| fail "acceptance: успех входа снова определяется кодом HTTP, а не конвертом ответа"
# Positive и negative probe обязаны пользоваться одной транспортной функцией:
# две независимые curl-команды и разошлись в RC3 с production middleware.
local login_body login_request_body
login_body="$(sed -n '/^async function assertAdminLoginWorks/,/^}/p' orchestrator/src/steps/smoke.ts)" \
|| fail "acceptance: не удалось прочитать проверку входа в панель"
[ -n "$login_body" ] \
|| fail "acceptance: функция проверки входа в панель не найдена"
[ "$(grep -c 'requestAdminLogin(' <<<"$login_body")" -eq 2 ] \
|| fail "acceptance: positive и negative login probe не используют один request helper"
! grep -q 'curl ' <<<"$login_body" \
|| fail "acceptance: assertAdminLoginWorks снова содержит отдельную curl-команду"
login_request_body="$(sed -n '/^async function requestAdminLogin/,/^}/p' orchestrator/src/steps/smoke.ts)" \
|| fail "acceptance: не удалось прочитать transport helper входа"
[ -n "$login_request_body" ] \
|| fail "acceptance: общий transport helper входа не найден"
grep -q 'runReadOnlySecret`curl' <<<"$login_request_body" \
|| fail "acceptance: запрос входа не выполняется секретным раннером"
# `runReadOnly`curl` совпадает только с ГОЛОЙ формой: у секретной после
# `runReadOnly` идёт `Secret`, а не обратная кавычка.
! grep -q 'runReadOnly`curl' <<<"$login_request_body" \
|| fail "acceptance: запрос входа выполняется раннером, кладущим команду в текст ошибки"
grep -q -- '--user-agent' <<<"$login_request_body" \
|| fail "acceptance: login smoke снова наследует блокируемый curl User-Agent"
# Wire-contract проверяется значением, а не поиском форматирования в исходнике.
"$BUN_BIN" -e '
const smoke = await import("./orchestrator/src/steps/smoke.ts");
const request = smoke.buildAdminLoginRequest("hy2xsadmin", "quote\" slash\\ tail ");
const body = JSON.parse(request.body);
if (body.username !== "hy2xsadmin" || body.pass !== "quote\" slash\\ tail ") {
throw new Error("login helper искажает username/pass");
}
if (Object.hasOwn(body, "password")) throw new Error("login helper отправляет alias password");
if (!request.userAgent || /fofa|shodan|curl|wget/i.test(request.userAgent)) {
throw new Error("login helper отправляет scanner-like User-Agent");
}
' || fail "acceptance: общий login helper нарушает wire-контракт или scanner middleware"
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 -- 'JSON.parse(body)' \
|| fail "acceptance: login envelope снова проверяется regex по сырому тексту"
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"
code_has orchestrator/src/config/env.ts -F -- 'assertValidAdminUsername(' \
|| fail "acceptance: HY2XS_ADMIN_USER снова не проверяется против контракта панели"
code_has orchestrator/src/config/env.ts -F -- 'assertValidAdminPassword(' \
|| fail "acceptance: HY2XS_ADMIN_INITIAL_PASSWORD снова не проверяется против контракта панели"
# Умолчание про одно и то же имя обязано быть одним. Их было три, и одно из
# них — `admin` — короче минимума панели.
code_has orchestrator/src/config/env.ts -F -- '"hy2xsadmin"' \
|| fail "acceptance: умолчание логина администратора разошлось с админкой и packaged env"
code_has apps/dao/sqlite.go -F -- 'adminUser = "hy2xsadmin"' \
|| fail "acceptance: запасное значение логина в админке разошлось с оркестратором"
grep -q '^HY2XS_ADMIN_USER=hy2xsadmin$' package/config/hy2xs.env \
|| fail "acceptance: packaged env разошёлся с умолчанием оркестратора"
}
# Планировщик принадлежит процессу и не перезапускает HTTP-сервер.
run_scheduler_lifecycle_acceptance() {
log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server"
@@ -978,14 +1345,20 @@ $piped_matcher"
|| fail "acceptance: прогон тестов оркестратора не фиксируется результатом"
grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \
|| fail "acceptance: прогон тестов админки не фиксируется результатом"
grep -q 'FRONTEND_CONTRACT_TESTS_PASSED' tools/build/lib/package.sh \
|| fail "acceptance: ранние контракты панели не фиксируются результатом"
grep -q 'FRONTEND_RUNTIME_TESTS_PASSED' tools/build/lib/package.sh \
|| fail "acceptance: runtime-компиляция локализации не фиксируется результатом"
grep -q 'FRONTEND_TESTS_PASSED' tools/build/lib/package.sh \
|| fail "acceptance: прогон контрактных тестов панели не фиксируется результатом"
|| fail "acceptance: общий результат тестов панели не фиксируется"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
for (const [fn, flag] of [
["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""],
["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""],
["run_frontend_tests()", "FRONTEND_TESTS_PASSED=\"true\""]
["run_frontend_tests()", "FRONTEND_CONTRACT_TESTS_PASSED=\"true\""],
["bundle_ui()", "FRONTEND_RUNTIME_TESTS_PASSED=\"true\""],
["bundle_ui()", "FRONTEND_TESTS_PASSED=\"true\""]
]) {
const start = source.indexOf(fn);
if (start < 0) throw new Error("не найдена функция " + fn);
@@ -999,6 +1372,19 @@ $piped_matcher"
if (set < 0) throw new Error(fn + ": результат прогона не фиксируется");
if (set < run) throw new Error(fn + ": результат объявляется раньше, чем получен");
}
const bundleStart = source.indexOf("bundle_ui()");
const bundleRest = source.slice(bundleStart);
const bundleEnd = bundleRest.search(/\n\}[\r\n]/);
const bundle = bundleEnd < 0 ? bundleRest : bundleRest.slice(0, bundleEnd);
const install = bundle.indexOf("install --frozen-lockfile");
const runtime = bundle.indexOf("test test/i18n-runtime.test.ts");
const typecheck = bundle.indexOf("run typecheck");
const build = bundle.indexOf("run build:prod");
if (install < 0 || runtime < 0) throw new Error("runtime i18n gate не найден в bundle_ui");
if (!(install < runtime && runtime < typecheck && typecheck < build)) {
throw new Error("порядок frontend gate обязан быть install -> i18n runtime -> typecheck -> build");
}
' || fail "acceptance: утверждение о пройденных тестах обязано следовать за прогоном"
# Детектор гонок — часть прогона админки, а не пожелание.
@@ -1192,6 +1578,39 @@ run_single_owner_acceptance() {
}
' || fail "acceptance: a diagnostics failure must never cancel the rollback"
log_step "Acceptance: diagnostics writes only into a trusted root namespace"
grep -qF 'export const DIAGNOSTICS_ROOT = `${INSTALL_STATE_DIR}/diagnostics`' \
orchestrator/src/lib/diagnosticsStorage.ts \
|| fail "acceptance: diagnostics root must live below /var/lib/hy2xs"
! grep -qF '/var/log/hy2xs/diagnostics' orchestrator/src/commands/diagnostics.ts \
|| fail "acceptance: root diagnostics must not use the service-writable log namespace"
grep -q 'ensureDiagnosticsStorageRoot' orchestrator/src/steps/filesystem.ts \
|| fail "acceptance: install must prepare the root-only diagnostics directory"
"$BUN_BIN" -e '
const fs = require("node:fs");
const storage = fs.readFileSync("orchestrator/src/lib/diagnosticsStorage.ts", "utf8");
const diagnostics = fs.readFileSync("orchestrator/src/commands/diagnostics.ts", "utf8");
for (const marker of ["lstat(", "isSymbolicLink()", "mkdtemp(", "open(archive, \"wx\"", "exactMode: DIAGNOSTICS_MODE"]) {
if (!storage.includes(marker)) throw new Error("missing diagnostics trust-boundary marker: " + marker);
}
if (!diagnostics.includes("createDiagnosticsWorkspace()")) {
throw new Error("diagnostics does not enter the trusted storage boundary");
}
if (!diagnostics.includes("runMutatingArgv([\"tar\"")) {
throw new Error("diagnostics archive creation must not use a shell string");
}
' || fail "acceptance: diagnostics storage is not fail-closed against symlink substitution"
log_step "Acceptance: metadata fallback is ENOENT-only"
"$BUN_BIN" -e '
const fs = require("node:fs");
const source = fs.readFileSync("orchestrator/src/lib/packageMeta.ts", "utf8");
if (!source.includes(".code === \"ENOENT\"")) throw new Error("missing explicit ENOENT branch");
if (!source.includes("throw error")) throw new Error("non-ENOENT metadata errors are not rethrown");
const tests = fs.readFileSync("orchestrator/test/package-meta-utf8.test.ts", "utf8");
if (!tests.includes("каталог вместо metadata-файла")) throw new Error("missing non-ENOENT regression test");
' || fail "acceptance: package metadata errors may still be masked by fallback"
log_step "Acceptance: persisting the failure state never blocks the rollback"
# Тот же класс, что и «диагностика не отменяет откат», но уровнем раньше.
# Запись маркера отказа — это mkdir/write/chown в /var/lib/hy2xs, то есть она
@@ -1205,6 +1624,17 @@ run_single_owner_acceptance() {
grep -q 'persistFailureState(' "$rollback_command" \
|| fail "acceptance: запись состояния отказа в $rollback_command не помечена как best effort"
done
# reset-failed — cleanup с допустимым исходом «юнит уже выгружен». Ненулевой
# код нельзя ни превращать в ложный manual-recovery warning, ни глушить:
# решение принимается после наблюдения ActiveState каждого юнита.
grep -q 'resetFailedUnit("hysteria-server")' orchestrator/src/commands/install.ts \
|| fail "acceptance: rollback install не проверяет postcondition reset-failed для hysteria-server"
grep -q 'resetFailedUnit("hy2xs-admin")' orchestrator/src/commands/install.ts \
|| fail "acceptance: rollback install не проверяет postcondition reset-failed для hy2xs-admin"
code_has orchestrator/src/lib/systemd.ts -F -- 'runMutatingStatus`systemctl reset-failed' \
|| fail "acceptance: reset-failed снова бросает до проверки допустимого not-loaded состояния"
code_has orchestrator/src/lib/systemd.ts -F -- 'properties.get("ActiveState")' \
|| fail "acceptance: успех reset-failed не доказывается наблюдаемым ActiveState"
"$BUN_BIN" -e '
const fs = require("node:fs");
for (const [file, write] of [
+19 -4
View File
@@ -64,7 +64,7 @@ run_orchestrator_tests() {
}
run_frontend_tests() {
# Контракты панели, которые не проверяются ни типами, ни сборкой bundle:
# Ранние контракты панели, которые не требуют установленных зависимостей:
# цвет иконок в спрайте, совпадение словарей локализации, соответствие кодов
# ошибок серверным константам, единственность адреса атрибуции.
#
@@ -80,8 +80,8 @@ run_frontend_tests() {
"$BUN_BIN" test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts \
|| fail "HY2XS admin frontend contract tests failed"
FRONTEND_TESTS_PASSED="true"
export FRONTEND_TESTS_PASSED
FRONTEND_CONTRACT_TESTS_PASSED="true"
export FRONTEND_CONTRACT_TESTS_PASSED
}
run_admin_tests() {
@@ -146,6 +146,9 @@ build_orchestrator() {
bundle_ui() {
local ui_src="${UI_SRC:-apps}"
[ "${FRONTEND_CONTRACT_TESTS_PASSED:-false}" = "true" ] \
|| fail "bundle_ui: ранние контракты панели не проверялись"
rm -rf "$ADMIN_BUILD_DIR"
mkdir -p "$ADMIN_BUILD_DIR"
@@ -164,6 +167,12 @@ bundle_ui() {
"$PNPM_BIN" install --frozen-lockfile
# Словари являются JavaScript-объектами и компилируются vue-i18n только
# при вызове t(). Поэтому ни vue-tsc, ни Vite не видят синтаксическую
# ошибку сообщения. Gate использует тот же установленный runtime, обходит
# все строки обоих языков и выполняется до typecheck и production bundle.
"$BUN_BIN" test test/i18n-runtime.test.ts
# Проверка типов идёт ПЕРЕД сборкой bundle, и это обязательный шаг релиза.
#
# Раньше её здесь не было вовсе: `build:prod` означал
@@ -176,7 +185,7 @@ bundle_ui() {
# кода, который не проходит проверку типов, нет.
"$PNPM_BIN" run typecheck
"$PNPM_BIN" run build:prod
)
) || fail "HY2XS admin frontend runtime tests or bundle failed"
# Версия админки приходит из versions.env через ldflags: один и тот же
# исполняемый файл обязан знать версию пакета, из которого он собран.
@@ -196,6 +205,12 @@ bundle_ui() {
mkdir -p "$STAGE_DIR/ui/hy2xs-admin/docs/sql"
install -m 0644 "$ui_src/docs/sql/schema.sql" "$STAGE_DIR/ui/hy2xs-admin/docs/sql/schema.sql"
fi
# Оба утверждения выставляются только после dependency-free контрактов,
# runtime-компиляции словарей и успешной сборки production bundle.
FRONTEND_RUNTIME_TESTS_PASSED="true"
FRONTEND_TESTS_PASSED="true"
export FRONTEND_RUNTIME_TESTS_PASSED FRONTEND_TESTS_PASSED
}
write_metadata() {
+467 -21
View File
@@ -7,6 +7,23 @@ import {
FLAMY_NAME,
FLAMY_URL,
} from "../../apps/frontend/src/constants/branding";
import {
ADMIN_PASSWORD_MAX_BYTES,
ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH,
ADMIN_USERNAME_CHARSET,
ADMIN_USERNAME_MAX_LENGTH,
ADMIN_USERNAME_MIN_LENGTH,
ADMIN_USERNAME_PATTERN,
isValidAdminPassword,
} from "../../apps/frontend/src/constants/credentials";
import {
PEER_NAME_CHARSET,
PEER_NAME_MAX_LENGTH,
PEER_NAME_MIN_LENGTH,
PEER_NAME_PATTERN,
PEER_NAME_PUNCTUATION,
} from "../../apps/frontend/src/constants/peer";
import ru from "../../apps/frontend/src/lang/package/ru";
import en from "../../apps/frontend/src/lang/package/en";
@@ -300,9 +317,9 @@ describe("страницы журнала", () => {
// Без width/min-width Element Plus делит ширину практически поровну, и
// сообщение — единственное содержимое журнала — получает треть строки.
const columns = [...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g)].map(
(match) => match[0]
);
const columns = [
...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g),
].map((match) => match[0]);
expect(columns.length).toBeGreaterThanOrEqual(3);
for (const column of columns) {
expect(column).toMatch(/\b(width|min-width)=/);
@@ -398,7 +415,9 @@ describe("действия над пиром", () => {
// вызов здесь больше не стоит, обязан называть его по имени — иначе он
// бесполезен. Это тот же урок, что записан в code_without_comments в
// acceptance.sh, и он повторился прямо на этой проверке.
const confirms = [...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g)];
const confirms = [
...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g),
];
// Единственный вызов — внутри confirmAction, который переводит отмену в
// обычное `false`.
expect(confirms.length).toBe(1);
@@ -513,7 +532,9 @@ describe("страница конфигурации Hysteria", () => {
// Сам модуль view-модели удалён вместе с дефолтами: пока он существует,
// его легко подключить обратно.
expect(
fs.existsSync(path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts"))
fs.existsSync(
path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts")
)
).toBe(false);
});
@@ -522,7 +543,12 @@ describe("страница конфигурации Hysteria", () => {
test("страница не изображает редактируемую форму", () => {
const source = vueCodeOf(configPage());
for (const editable of ["<el-input", "<el-switch", "<el-select", "v-model"]) {
for (const editable of [
"<el-input",
"<el-switch",
"<el-select",
"v-model",
]) {
expect(source).not.toContain(editable);
}
});
@@ -596,7 +622,15 @@ describe("страница конфигурации Hysteria", () => {
expect(types).not.toMatch(/\bsecret\s*[?]?:/);
// Секции, которых production-профиль не описывает, в модели ответа не
// объявляются вовсе: они попадают в `drift` списком имён.
for (const section of ["outbounds", "masquerade", "resolver", "sniff", "acl", "mimic", "realm"]) {
for (const section of [
"outbounds",
"masquerade",
"resolver",
"sniff",
"acl",
"mimic",
"realm",
]) {
expect(types).not.toContain(`${section}:`);
}
});
@@ -641,10 +675,14 @@ describe("страница конфигурации Hysteria", () => {
// существуют, «универсальный редактор Hysteria» отрастает заново.
test("компоненты генерического редактора удалены", () => {
for (const component of ["MapAdd", "ImputMultiple", "UnitSelect"]) {
expect(fs.existsSync(path.join(FRONTEND_SRC, "components", component))).toBe(false);
expect(
fs.existsSync(path.join(FRONTEND_SRC, "components", component))
).toBe(false);
}
expect(
fs.existsSync(path.join(FRONTEND_SRC, "views", "hysteria", "list", "components"))
fs.existsSync(
path.join(FRONTEND_SRC, "views", "hysteria", "list", "components")
)
).toBe(false);
});
});
@@ -678,28 +716,436 @@ describe("правдивая диагностика", () => {
});
});
describe("контракт учётных данных администратора", () => {
const goContract = () =>
fs.readFileSync(
path.join(REPO_ROOT, "apps", "credential", "admin.go"),
"utf8"
);
function goConst(name: string): string {
const source = goContract();
const numeric = source.match(new RegExp(`\\b${name}\\s*=\\s*(\\d+)`));
if (numeric) {
return numeric[1];
}
const backticked = source.match(
new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``)
);
if (backticked) {
return backticked[1];
}
throw new Error(`константа ${name} не найдена в apps/credential/admin.go`);
}
const loginForm = () => readSource("views", "login", "index.vue");
const changePasswordForm = () =>
readSource("views", "admin", "change-password", "index.vue");
// Панель не имеет права быть строже сервера: отвергнутое ею значение сервер
// принял бы, и оператор остаётся снаружи с паролем, который на самом деле
// подходит.
test("границы и набор символов совпадают с Go-контрактом", () => {
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(
goConst("AdminUsernameMinLength")
);
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(
goConst("AdminUsernameMaxLength")
);
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(
goConst("AdminPasswordMinLength")
);
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"));
const declared = goConst("adminUsernameCharacterClass");
const mirrored = ADMIN_USERNAME_PATTERN.source
.replace(/^\^\[/, "")
.replace(
new RegExp(
`\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`
),
""
);
expect(mirrored).toBe(declared);
});
// Регрессия RC2-UI-01. Дефис внутри класса символов не был экранирован, из-за
// чего `+-=` образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Набор
// совпадал с серверным только по совпадению — оба несли одну и ту же
// опечатку, — и любая попытка «навести порядок» развела бы их обратно.
test("класс символов записан явно, а не диапазоном по опечатке", () => {
expect(ADMIN_USERNAME_PATTERN.source).toContain("\\-");
// Считается КОД: комментарий, объясняющий, почему прежней записи здесь
// больше нет, обязан называть её по имени — иначе он бесполезен. Это тот же
// урок, что записан в vueCodeOf этажом выше, и он повторился прямо здесь.
for (const form of [loginForm(), changePasswordForm()]) {
expect(vueCodeOf(form)).not.toContain("_+-=");
}
});
// Правило объявлено ОДИН раз. Пока каждая форма несла своё, они разошлись и с
// сервером, и друг с другом.
test("обе формы берут правило из общего модуля, а не заводят своё", () => {
for (const form of [loginForm(), changePasswordForm()]) {
expect(form).toContain("@/constants/credentials");
// Собственных регекспов и собственных чисел в формах быть не должно.
expect(vueCodeOf(form)).not.toMatch(/pattern\s*[:=]\s*\//);
expect(vueCodeOf(form)).not.toMatch(/\{\s*6\s*,\s*\d+\s*\}/);
}
});
// Регрессия RC2-UI-02: форма входа ограничивала пароль 32 символами при
// серверном пределе в 64, а форма смены пароля назначала до 64. Пароль,
// назначенный штатной операцией, становился паролем, с которым невозможно
// войти.
test("правило пароля на обеих формах — одно и то же", () => {
for (const form of [loginForm(), changePasswordForm()]) {
const code = vueCodeOf(form);
expect(code).toContain("adminPasswordFormRule(");
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",
// Cc целиком, а не только C0 и DEL: документация обещала «без
// управляющих символов», а проверка знала лишь про часть из них.
`abcde${String.fromCodePoint(0x7f)}`,
`abcde${String.fromCodePoint(0x85)}`,
`abcde${String.fromCodePoint(0x9f)}`,
// Домен systemd: шесть символов, ни одного управляющего — и юнит после
// такого пароля не стартует, потому что EnvironmentFile не загрузится.
`abcde${String.fromCodePoint(0xfdd0)}`,
`abcde${String.fromCodePoint(0xffff)}`,
`abcde${String.fromCodePoint(0x1ffff)}`,
// U+FEFF запрещён публичным транспортным контрактом EnvironmentFile.
`abcde${String.fromCodePoint(0xfeff)}`,
// Одиночный суррогат: без проверки TextEncoder молча подменил бы его на
// U+FFFD, то есть на сервер уехал бы другой пароль, а не отказ.
`abcde${String.fromCharCode(0xd800)}`,
]) {
expect(isValidAdminPassword(rejected)).toBe(false);
}
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
// множество systemd, а не окрестность подозрительных значений.
for (const accepted of [0xfdcf, 0xfdf0, 0xfffd, 0x10fffd]) {
expect(
isValidAdminPassword(`abcde${String.fromCodePoint(accepted)}`)
).toBe(true);
}
// Именно тот случай, ради которого встроенные 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("на пароль не навешен набор символов", () => {
for (const form of [loginForm(), changePasswordForm()]) {
const code = vueCodeOf(form);
expect(code).not.toContain(
'ADMIN_USERNAME_PATTERN,\n message: t("credentials.passwordFormat"'
);
expect(code).not.toMatch(/passwordPattern/);
}
// Правило логина — единственное место, где вообще упоминается набор.
expect(vueCodeOf(changePasswordForm())).not.toContain(
"ADMIN_USERNAME_PATTERN"
);
});
// Требование НАЗЫВАЕТСЯ, а не сообщается фактом нарушения: набор символов
// логина приходит из hy2xs.env, и посмотреть его в панели больше негде.
test("фразы называют границы и набор, и есть в обоих словарях", () => {
for (const dictionary of [ru, en] as const) {
const credentials = (dictionary as any).credentials ?? {};
expect(typeof credentials.usernameFormat).toBe("string");
expect(typeof credentials.passwordFormat).toBe("string");
for (const placeholder of ["{min}", "{max}", "{charset}"]) {
expect(credentials.usernameFormat).toContain(placeholder);
}
// Границ у пароля ДВЕ, и они в разных единицах: пароль из 40 эмодзи
// укладывается в 64 символа и не укладывается в 72 байта. Фраза «не
// длиннее {max} символов» отправила бы оператора сокращать пароль,
// отвергнутый не за это.
for (const placeholder of ["{min}", "{max}", "{maxBytes}"]) {
expect(credentials.passwordFormat).toContain(placeholder);
}
// Причина отказа от сервера несёт те же params и обязана их использовать.
const reason = (dictionary as any).error?.code?.credential_format ?? "";
for (const placeholder of ["{min}", "{max}", "{charset}"]) {
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);
}
}
});
});
describe("индикация ошибки на форме входа", () => {
const loginForm = () => readSource("views", "login", "index.vue");
// Регрессия RC2-UI-03, видимая на скриншоте формы.
//
// Element Plus рисует состояние отказа на `el-input__wrapper` селектором
//
// .el-form-item.is-error .el-form-item__content .el-input__wrapper
//
// то есть из ЧЕТЫРЁХ классов. Форма входа рисует видимую рамку поля на
// `el-form-item` — потому что в поле лежат ещё иконка и переключатель
// видимости пароля, — а тень враппера гасила селектором из трёх классов и
// проигрывала по специфичности. Красная рамка ложилась вокруг одного лишь
// поля ввода: у логина начиналась после иконки, у пароля обрывалась перед
// «глазом».
test("отказ подсвечивает поле целиком, а не один только ввод", () => {
const styles = loginForm();
// Индикация переехала на элемент, который оператор и видит полем.
expect(styles).toMatch(
/&\.is-error\s*\{[\s\S]*?border-color:\s*var\(--el-color-danger\)/
);
// Штатная тень враппера гасится селектором, который повторяет чужой и
// добавляет атрибут scoped-стиля, — то есть выигрывает по специфичности.
expect(styles).toContain(
":deep(.el-form-item__content .el-input__wrapper)"
);
// Победа специфичностью, а не силой: `!important` перекрыл бы и те
// состояния, о которых правило ничего не знает.
expect(styles).not.toContain("box-shadow: none !important");
});
// Сообщение об отказе позиционируется абсолютно от `top: 100%`, то есть
// висит ВНЕ рамки. При стандартных 18px оно прижималось к границе снизу и к
// следующему полю сверху и выглядело принадлежащим не тому полю.
test("сообщению об отказе оставлено место под полем", () => {
const styles = loginForm();
expect(styles).toContain(":deep(.el-form-item__error)");
expect(styles).toMatch(/margin-bottom:\s*(1[9]|[2-9]\d)px/);
});
});
describe("подсказки формы пира", () => {
const peerContract = () =>
fs.readFileSync(
path.join(REPO_ROOT, "apps", "service", "peer_import.go"),
"utf8"
);
function peerGoConst(name: string): string {
const source = peerContract();
const numeric = source.match(new RegExp(`\\b${name}\\s*=\\s*(\\d+)`));
if (numeric) {
return numeric[1];
}
const backticked = source.match(
new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``)
);
if (backticked) {
return backticked[1];
}
throw new Error(
`константа ${name} не найдена в apps/service/peer_import.go`
);
}
test("frontend-контракт совпадает с серверным", () => {
expect(String(PEER_NAME_MIN_LENGTH)).toBe(peerGoConst("PeerNameMinLength"));
expect(String(PEER_NAME_MAX_LENGTH)).toBe(peerGoConst("PeerNameMaxLength"));
expect(PEER_NAME_CHARSET).toBe(peerGoConst("PeerNameCharset"));
expect(PEER_NAME_CHARSET.split(/\s+/).at(-1)).toBe(PEER_NAME_PUNCTUATION);
const declaredPattern = peerContract().match(
/peerNamePattern\s*=\s*regexp\.MustCompile\(`([^`]+)`\)/
);
expect(declaredPattern).not.toBeNull();
expect(PEER_NAME_PATTERN.source).toBe(declaredPattern![1]);
});
test("форма использует общий контракт без собственных копий", () => {
const form = readSource("views", "peer", "list", "index.vue");
expect(form).toContain("@/constants/peer");
expect(form).toContain("pattern: PEER_NAME_PATTERN");
expect(form).toContain("min: PEER_NAME_MIN_LENGTH");
expect(form).toContain("max: PEER_NAME_MAX_LENGTH");
expect(form).toContain("charset: PEER_NAME_CHARSET");
expect(form).toContain("punctuation: PEER_NAME_PUNCTUATION");
expect(form).not.toMatch(/const\s+PEER_NAME_PATTERN\s*=/);
expect(form).not.toContain('charset: "a-z A-Z 0-9');
});
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
// пробовать разрешённые символы.
test("подсказка имени описывает действующий набор символов", () => {
// Набор символов объявлен на сервере ровно один раз; подсказка обязана
// описывать именно его, а не набор из предыдущего поколения правила.
const rule = fs.readFileSync(
path.join(REPO_ROOT, "apps", "service", "peer_import.go"),
"utf8"
);
const declared = rule.match(/PeerNameCharset\s*=\s*`([^`]+)`/);
expect(declared).not.toBeNull();
// «a-z A-Z 0-9 !@#$%^&*()_+-=» -> «!@#$%^&*()_+-=»
const punctuation = declared![1].split(/\s+/).at(-1)!;
expect(punctuation).toContain("!@#");
expect(PEER_NAME_PUNCTUATION).toContain("!@#");
for (const dictionary of [ru, en] as const) {
const hint = (dictionary as any).peer?.form?.nameHint ?? "";
expect(hint).toContain(punctuation);
expect(hint).toContain("32");
expect(hint).toContain("{punctuation}");
expect(hint).toContain("{min}");
expect(hint).toContain("{max}");
expect(hint).not.toContain(PEER_NAME_PUNCTUATION);
expect(hint).not.toContain(
`${PEER_NAME_MIN_LENGTH} to ${PEER_NAME_MAX_LENGTH}`
);
expect(hint).not.toContain(
`от ${PEER_NAME_MIN_LENGTH} до ${PEER_NAME_MAX_LENGTH}`
);
}
});
});
describe("контракт Element Plus", () => {
test("radio-компоненты не используют label как value", () => {
const deprecated: string[] = [];
for (const file of sourceFiles(FRONTEND_SRC).filter((candidate) =>
candidate.endsWith(".vue")
)) {
const source = fs.readFileSync(file, "utf8");
for (const match of source.matchAll(
/<el-radio(?:-button)?\b[\s\S]*?>/g
)) {
const tag = match[0];
if (/\blabel\s*=/.test(tag) && !/\bvalue\s*=/.test(tag)) {
deprecated.push(`${path.relative(REPO_ROOT, file)}: ${tag}`);
}
}
}
expect(deprecated).toEqual([]);
});
});
describe("политика frontend-зависимостей", () => {
test("security overrides находятся в читаемой pnpm конфигурации", () => {
const manifest = JSON.parse(
fs.readFileSync(
path.join(REPO_ROOT, "apps", "frontend", "package.json"),
"utf8"
)
) as Record<string, unknown>;
expect(manifest.pnpm).toBeUndefined();
const workspace = fs.readFileSync(
path.join(REPO_ROOT, "apps", "frontend", "pnpm-workspace.yaml"),
"utf8"
);
expect(workspace).toMatch(/^overrides:/m);
for (const [dependency, resolution] of Object.entries({
lodash: ">=4.18.1",
"lodash-es": ">=4.18.1",
"brace-expansion": ">=1.1.18",
picomatch: ">=2.3.2",
"js-yaml": ">=4.3.1",
"table>ajv": ">=8.18.0",
browserslist: "4.28.7",
})) {
const dependencyKey = dependency.includes(">")
? `"${dependency}"`
: dependency;
expect(workspace).toContain(`${dependencyKey}: "${resolution}"`);
}
});
});