Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7e478f014 | |||
| 079094591b | |||
| bf10810cfc | |||
| 6c286d1ff5 | |||
| a3445f79c5 | |||
| af9f476658 | |||
| ab788725cf | |||
| 65042ee335 | |||
| a8407cf16b |
+299
@@ -8,6 +8,56 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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`. Основная тема — сделать политику
|
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).
|
[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.
|
||||||
|
|
||||||
### Исправлено — правило доступа
|
### Исправлено — правило доступа
|
||||||
|
|
||||||
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
|
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
|
||||||
|
|||||||
@@ -634,8 +634,8 @@ hy2xs-orchestrator status \
|
|||||||
| `HY2XS_UI_BIND_HOST` | IPv4 bind HY2XS admin | `127.0.0.1` |
|
| `HY2XS_UI_BIND_HOST` | IPv4 bind HY2XS admin | `127.0.0.1` |
|
||||||
| `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` |
|
| `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` |
|
||||||
| `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` |
|
| `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` |
|
||||||
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора | `hy2xsadmin` |
|
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора: 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`. Значение вне контракта роняет установку — панель его не приняла бы на форме входа | `hy2xsadmin` |
|
||||||
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
| `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_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||||
| `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` |
|
| `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` |
|
||||||
| `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` |
|
| `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` |
|
||||||
@@ -749,7 +749,7 @@ hy2xs-orchestrator reconfigure \
|
|||||||
| `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения |
|
| `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения |
|
||||||
| `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию |
|
| `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию |
|
||||||
| `hy2xs-orchestrator repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** |
|
| `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 перед публикацией логов |
|
| `hy2xs-orchestrator redact-config` | Отредактировать секреты в env/yaml перед публикацией логов |
|
||||||
|
|
||||||
`repair` без `--allow-partial-state` работает только поверх полностью успешной
|
`repair` без `--allow-partial-state` работает только поверх полностью успешной
|
||||||
@@ -766,6 +766,22 @@ hy2xs-orchestrator diagnostics collect \
|
|||||||
--config /etc/hy2xs/hy2xs.env
|
--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`);
|
1. проверяет контракт `versions.env` (`verify_versions_contract`);
|
||||||
2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`);
|
2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`);
|
||||||
3. прогоняет контрактные тесты панели (спрайт иконок, словари локализации, коды ошибок, атрибуция);
|
3. прогоняет dependency-free контракты панели (спрайт иконок, совпадение словарей, коды ошибок, атрибуция и frontend/Go-контракты);
|
||||||
4. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
|
4. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
|
||||||
5. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
|
5. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
|
||||||
6. собирает orchestrator, frontend и backend, проставляя версию админки из контракта;
|
6. собирает standalone-бинарник orchestrator;
|
||||||
7. прогоняет `go vet` и `go test` для HY2XS admin;
|
7. устанавливает frontend lock-граф, runtime-компилирует каждое сообщение RU/EN реальным `vue-i18n`, затем проверяет типы и собирает frontend и backend, проставляя версию админки из контракта;
|
||||||
8. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
|
8. прогоняет `go vet` и `go test` для HY2XS admin;
|
||||||
9. формирует архив и прогоняет acceptance‑проверки.
|
9. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
|
||||||
|
10. формирует архив и прогоняет acceptance‑проверки.
|
||||||
|
|
||||||
Любой сбой на шагах 1–8 останавливает сборку до создания пакета.
|
Любой сбой на шагах 1–9 останавливает сборку до создания пакета.
|
||||||
|
|
||||||
Тесты и типы (шаги 2, 3 и 7) — такой же обязательный гейт, как проверка
|
Тесты и типы (шаги 2, 3, 7 и 8) — такой же обязательный гейт, как проверка
|
||||||
зависимостей: переменной, которая их отключает, не существует. Готовый пакет
|
зависимостей: переменной, которая их отключает, не существует. Готовый пакет
|
||||||
объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это
|
объявляет об этом полем `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`
|
`bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts`
|
||||||
запускаются напрямую и tarball не создают.
|
запускаются напрямую и tarball не создают.
|
||||||
|
|
||||||
|
Runtime-проверка словарей требует установленного frontend lock-графа:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/frontend
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
bun test test/i18n-runtime.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
Переменные, управляющие выбором версии Hysteria:
|
Переменные, управляющие выбором версии Hysteria:
|
||||||
|
|
||||||
| Переменная | По умолчанию | Назначение |
|
| Переменная | По умолчанию | Назначение |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
func Hysteria2Auth(c *gin.Context) {
|
func Hysteria2Auth(c *gin.Context) {
|
||||||
var req dto.Hysteria2AuthDto
|
var req dto.Hysteria2AuthDto
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := strictBindJSON(c, &req); err != nil {
|
||||||
vo.Hysteria2AuthBadRequest(c)
|
vo.Hysteria2AuthBadRequest(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -46,27 +46,6 @@ func resolveID(c *gin.Context) (int64, error) {
|
|||||||
return parsed, nil
|
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) {
|
func PagePeer(c *gin.Context) {
|
||||||
peerPageDto, err := validateField(c, dto.PeerPageDto{})
|
peerPageDto, err := validateField(c, dto.PeerPageDto{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -210,6 +189,10 @@ func ImportPeer(c *gin.Context) {
|
|||||||
vo.Fail("the file is too big", c)
|
vo.Fail("the file is too big", c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err = validateStrictJSONEncoding(content); err != nil {
|
||||||
|
vo.Fail("content contains invalid UTF-8 or UTF-16", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var peerExports []bo.PeerExport
|
var peerExports []bo.PeerExport
|
||||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||||
|
|||||||
@@ -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()
|
t.Helper()
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("не удалось собрать форму: %v", err)
|
t.Fatalf("не удалось собрать форму: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := part.Write([]byte(content)); err != nil {
|
if _, err := part.Write(content); err != nil {
|
||||||
t.Fatalf("не удалось записать файл в форму: %v", err)
|
t.Fatalf("не удалось записать файл в форму: %v", err)
|
||||||
}
|
}
|
||||||
if err := writer.Close(); err != nil {
|
if err := writer.Close(); err != nil {
|
||||||
@@ -49,6 +49,24 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
|||||||
return result
|
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 читает ПЕРВЫЙ документ и останавливается. Файл с
|
// Регрессия: json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл с
|
||||||
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
|
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
|
||||||
// половина файла молча не применялась.
|
// половина файла молча не применялась.
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
|
"hy2xs-admin/credential"
|
||||||
"hy2xs-admin/model/constant"
|
"hy2xs-admin/model/constant"
|
||||||
"hy2xs-admin/model/dto"
|
"hy2xs-admin/model/dto"
|
||||||
"hy2xs-admin/model/vo"
|
"hy2xs-admin/model/vo"
|
||||||
@@ -34,6 +34,7 @@ func init() {
|
|||||||
|
|
||||||
mustRegister("peerName", validatePeerName)
|
mustRegister("peerName", validatePeerName)
|
||||||
mustRegister("credentialStr", validateCredentialStr)
|
mustRegister("credentialStr", validateCredentialStr)
|
||||||
|
mustRegister("adminPassword", validateAdminPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustRegister(tag string, fn validator.Func) {
|
func mustRegister(tag string, fn validator.Func) {
|
||||||
@@ -60,21 +61,38 @@ func validatePeerName(f validator.FieldLevel) bool {
|
|||||||
return service.IsValidPeerName(f.Field().String())
|
return service.IsValidPeerName(f.Field().String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// credentialStrPattern — набор символов логина и пароля администратора.
|
// validateCredentialStr — единственное правило имени администратора.
|
||||||
//
|
//
|
||||||
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
|
// Набор символов и длина берутся из service по той же причине, что и у имени
|
||||||
// последствия неэкранированного дефиса в исходной записи `_+-=`. Это сделано
|
// пира: собственная копия правила в слое контроллеров уже расходилась с
|
||||||
// намеренно: имя администратора приходит из HY2XS_ADMIN_USER в hy2xs.env,
|
// остальным продуктом. Здесь она вдобавок была единственным местом, знавшим
|
||||||
// оркестратор набор символов не ограничивает, и сужение правила означало бы,
|
// набор, — оркестратор писал в hy2xs.env любое имя, а панель проверяла своё
|
||||||
// что установка с логином вроде `admin.ops` перестаёт пускать оператора в
|
// третье правило.
|
||||||
// панель. Сужать этот набор можно только вместе с проверкой имени на стороне
|
//
|
||||||
// оркестратора, и это отдельная работа, а не побочный эффект правки формы
|
// Пустое значение НЕ впускается. Раньше здесь стояло `field == "" || …`, и это
|
||||||
// пира.
|
// была ловушка: у поля-указателя `required` считает непустым сам факт
|
||||||
var credentialStrPattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+,\-./:;<=]{6,32}$`)
|
// ненулевого указателя, поэтому `{"username":""}` доходило сюда и объявлялось
|
||||||
|
// корректным. Пустой логин отсекается нормализацией DTO, которая превращает
|
||||||
|
// его в «не задано», и получает отказ `required` — то есть ту причину, которая
|
||||||
|
// у него на самом деле.
|
||||||
func validateCredentialStr(f validator.FieldLevel) bool {
|
func validateCredentialStr(f validator.FieldLevel) bool {
|
||||||
field := f.Field().String()
|
return credential.IsValidAdminUsername(f.Field().String())
|
||||||
return field == "" || credentialStrPattern.MatchString(field)
|
}
|
||||||
|
|
||||||
|
// validateAdminPassword — единственное правило пароля администратора.
|
||||||
|
//
|
||||||
|
// Здесь стояли `min=6,max=64` прямо в теге, и это была та же конструкция, от
|
||||||
|
// которой уже отказались на логине: границы жили РЯДОМ с правилом, а не внутри
|
||||||
|
// него. У пароля она вдобавок была неполна. Тег `max=64` считает символы
|
||||||
|
// Unicode, а bcrypt считает байты, поэтому пароль из 64 кириллических букв —
|
||||||
|
// 128 байт — проходил проверку целиком и падал только в хешировании, уже как
|
||||||
|
// системная ошибка на штатной смене пароля.
|
||||||
|
//
|
||||||
|
// Границу в байтах тегом не выразить: у валидатора нет правила длины в байтах.
|
||||||
|
// Поэтому всё правило целиком переехало в contract-функцию, и теперь у формы
|
||||||
|
// входа, формы смены пароля, bootstrap-установки и `reset-admin` она одна.
|
||||||
|
func validateAdminPassword(f validator.FieldLevel) bool {
|
||||||
|
return credential.IsValidAdminPassword(f.Field().String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
|
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
|
||||||
@@ -91,7 +109,7 @@ func validateField[T interface{}](c *gin.Context, field T) (T, error) {
|
|||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
bindErr = c.ShouldBindQuery(&field)
|
bindErr = c.ShouldBindQuery(&field)
|
||||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||||
bindErr = c.ShouldBindJSON(&field)
|
bindErr = strictBindJSON(c, &field)
|
||||||
}
|
}
|
||||||
if bindErr != nil {
|
if bindErr != nil {
|
||||||
vo.FailValidation(
|
vo.FailValidation(
|
||||||
@@ -202,8 +220,38 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
|
|||||||
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
|
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
|
||||||
)
|
)
|
||||||
case "credentialStr":
|
case "credentialStr":
|
||||||
|
// Причина называет набор и границы ровно так же, как это делает
|
||||||
|
// `peerName`. Прежнее «содержит недопустимые символы» не отвечало на
|
||||||
|
// вопрос оператора: набор символов логина ему негде посмотреть, а
|
||||||
|
// отказ по длине этой фразой описывался как отказ по символам.
|
||||||
described.Code = constant.ErrCodeCredentialStr
|
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:
|
default:
|
||||||
described.Code = constant.ErrCodeRuleUnknown
|
described.Code = constant.ErrCodeRuleUnknown
|
||||||
described.Params = map[string]string{"rule": fieldErr.Tag()}
|
described.Params = map[string]string{"rule": fieldErr.Tag()}
|
||||||
|
|||||||
@@ -4,40 +4,63 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"hy2xs-admin/credential"
|
||||||
"hy2xs-admin/service"
|
"hy2xs-admin/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Набор символов логина и пароля закреплён ФАКТИЧЕСКИМ множеством.
|
// Правила валидатора обязаны спрашивать КОНТРАКТ, а не собственную копию.
|
||||||
//
|
//
|
||||||
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
// Прежние версии этих проверок обращались к регекспу внутри слоя контроллеров и
|
||||||
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
// потому не увидели бы расхождения между копией и правилом — а именно так этот
|
||||||
// Новая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
// класс дефектов и появлялся: набор символов логина существовал в четырёх
|
||||||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, оркестратор его
|
// местах, и все четыре разошлись. Сам контракт проверяется в пакете credential;
|
||||||
// набор символов не ограничивает, и сужение правила означало бы, что установка
|
// здесь проверяется ПРОВОДКА: тег валидатора и функция контракта отвечают
|
||||||
// с логином вроде `admin.ops` перестаёт пускать оператора в панель.
|
// одинаково на одних и тех же значениях.
|
||||||
//
|
func TestCredentialStrTagDelegatesToContract(t *testing.T) {
|
||||||
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
for _, candidate := range []string{
|
||||||
// классе символов уронит его, а не вход администратора на живом сервере.
|
"hy2xsadmin",
|
||||||
func TestCredentialCharsetIsUnchanged(t *testing.T) {
|
"admin.ops",
|
||||||
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
"admin+1",
|
||||||
|
"admi",
|
||||||
for _, symbol := range strings.Split(historical, "") {
|
"админ12",
|
||||||
candidate := "admin" + symbol
|
"admin пробел",
|
||||||
if !credentialStrPattern.MatchString(candidate) {
|
strings.Repeat("a", credential.AdminUsernameMaxLength+1),
|
||||||
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
} {
|
||||||
|
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), // длиннее тридцати двух
|
// Границы пароля стояли прямо в теге (`min=6,max=64`), и тег НЕ МОГ выразить
|
||||||
"admin пробел", // пробел
|
// предел bcrypt в байтах — правила длины в байтах у go-playground/validator
|
||||||
"админ1", // кириллица
|
// нет. Поэтому пароль из 64 кириллических букв проходил валидацию формы и
|
||||||
"admin\n1", // перевод строки
|
// падал в хешировании, возвращаясь оператору системной ошибкой. Проверка
|
||||||
"admin'1", // апостроф вне набора
|
// закрепляет, что правило теперь одно и что оно то же самое, которым
|
||||||
|
// пользуются 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) {
|
expected := credential.IsValidAdminPassword(candidate)
|
||||||
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
|
if got := validate.Var(candidate, "adminPassword") == nil; got != expected {
|
||||||
|
t.Errorf(
|
||||||
|
"тег adminPassword на значении из %d символов / %d байт дал %v, контракт — %v",
|
||||||
|
len([]rune(candidate)), len(candidate), got, expected,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("пароль с краевым пробелом отклонён: пробел объявлен частью значения")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hy2xs-admin/credential"
|
||||||
|
"hy2xs-admin/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bootstrap-путь администратора обязан подчиняться ОБЩЕМУ контракту пароля.
|
||||||
|
//
|
||||||
|
// Здесь было два расхождения, и оба ломали установку молча.
|
||||||
|
//
|
||||||
|
// Первое: `strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Продукт
|
||||||
|
// объявляет пробел по краям частью пароля и не снимает его ни на форме входа,
|
||||||
|
// ни при смене, ни в оркестраторе — а здесь снимал. Учётная запись создавалась
|
||||||
|
// с паролем, отличным от того, который оператор записал в hy2xs.env и которым
|
||||||
|
// он потом пытался войти.
|
||||||
|
//
|
||||||
|
// Второе: контракт не проверялся вовсе, поэтому непригодный для bcrypt пароль
|
||||||
|
// (64 кириллические буквы = 128 байт) доходил до GenerateFromPassword, и старт
|
||||||
|
// службы падал с «bcrypt: password length exceeds 72 bytes» — сообщением, по
|
||||||
|
// которому нельзя понять ни что чинить, ни где.
|
||||||
|
|
||||||
|
func startAdminBootstrap(t *testing.T, password string) error {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
t.Setenv("HY2XS_ADMIN_USER", "hy2xsadmin")
|
||||||
|
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", password)
|
||||||
|
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
|
||||||
|
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
|
||||||
|
|
||||||
|
err := InitSqlAt(filepath.Join(t.TempDir(), "hy2xs-admin-test.db"))
|
||||||
|
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом.
|
||||||
|
func TestBootstrapAdminKeepsSurroundingWhitespaceInPassword(t *testing.T) {
|
||||||
|
const password = "bootstrap-admin-password "
|
||||||
|
|
||||||
|
if err := startAdminBootstrap(t, password); err != nil {
|
||||||
|
t.Fatalf("запуск сервиса не удался: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("учётная запись администратора не создана: %v", err)
|
||||||
|
}
|
||||||
|
if admin.PasswordHash == nil {
|
||||||
|
t.Fatal("у созданной учётной записи нет хеша пароля")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !util.VerifyPassword(password, *admin.PasswordHash) {
|
||||||
|
t.Error("вход исходным значением невозможен: пароль обрезан при создании учётной записи")
|
||||||
|
}
|
||||||
|
if util.VerifyPassword(strings.TrimSpace(password), *admin.PasswordHash) {
|
||||||
|
t.Error("вход обрезанным значением возможен: пробел по краям снят вопреки контракту")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пароль, не проходящий контракт, роняет старт с ПРИЧИНОЙ, а не с ошибкой
|
||||||
|
// bcrypt, и учётной записи после этого не остаётся.
|
||||||
|
func TestBootstrapAdminRefusesPasswordOutsideTheContract(t *testing.T) {
|
||||||
|
// 64 символа — в границе; 128 байт — за пределом bcrypt.
|
||||||
|
password := strings.Repeat("я", credential.AdminPasswordMaxLength)
|
||||||
|
if credential.IsValidAdminPassword(password) {
|
||||||
|
t.Fatal("подготовка: контракт принимает значение, проверяется не то")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := startAdminBootstrap(t, password)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("старт с непригодным паролем завершился успешно")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "HY2XS_ADMIN_INITIAL_PASSWORD") {
|
||||||
|
t.Errorf("отказ не называет переменную, которую надо чинить: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "hy2xs.env") {
|
||||||
|
t.Errorf("отказ не называет файл, в котором чинится причина: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пароль ровно в предел bcrypt (72 байта) — законное значение, и установка с
|
||||||
|
// ним обязана проходить целиком.
|
||||||
|
func TestBootstrapAdminAcceptsPasswordAtBcryptByteLimit(t *testing.T) {
|
||||||
|
password := strings.Repeat("я", 36)
|
||||||
|
if len(password) != credential.AdminPasswordMaxBytes {
|
||||||
|
t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := startAdminBootstrap(t, password); err != nil {
|
||||||
|
t.Fatalf("установка с паролем в 72 байта не удалась: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("учётная запись администратора не создана: %v", err)
|
||||||
|
}
|
||||||
|
if !util.VerifyPassword(password, *admin.PasswordHash) {
|
||||||
|
t.Error("пароль в 72 байта не проходит проверку собственным хешем")
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-1
@@ -3,11 +3,13 @@ package dao
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
"gorm.io/gorm/schema"
|
"gorm.io/gorm/schema"
|
||||||
|
"hy2xs-admin/credential"
|
||||||
"hy2xs-admin/model/constant"
|
"hy2xs-admin/model/constant"
|
||||||
"hy2xs-admin/model/entity"
|
"hy2xs-admin/model/entity"
|
||||||
"hy2xs-admin/util"
|
"hy2xs-admin/util"
|
||||||
@@ -132,7 +134,14 @@ func ensureSecureBootstrapAdmin() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
|
// Пароль читается КАК ЕСТЬ.
|
||||||
|
//
|
||||||
|
// Здесь стоял strings.TrimSpace, и он противоречил контракту продукта:
|
||||||
|
// пробел по краям объявлен частью пароля и не снимается ни на форме входа,
|
||||||
|
// ни при смене пароля, ни в оркестраторе. То есть bootstrap-путь
|
||||||
|
// гарантированно создавал учётную запись НЕ с тем паролем, который оператор
|
||||||
|
// записал в hy2xs.env, а войти в неё он пытался бы исходным значением.
|
||||||
|
adminPassword := os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")
|
||||||
if adminPassword == "" {
|
if adminPassword == "" {
|
||||||
return errors.New(
|
return errors.New(
|
||||||
"HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" +
|
"HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" +
|
||||||
@@ -144,6 +153,28 @@ func ensureSecureBootstrapAdmin() error {
|
|||||||
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Контракт проверяется ЗДЕСЬ, а не только при хешировании.
|
||||||
|
//
|
||||||
|
// Отказ bcrypt на непригодном пароле выглядел бы как «bcrypt: password
|
||||||
|
// length exceeds 72 bytes» в journal — сообщение, по которому оператор не
|
||||||
|
// поймёт ни что чинить, ни где. Причина же чинится одной строкой в
|
||||||
|
// hy2xs.env, и назвать её обязан тот, кто знает про этот файл.
|
||||||
|
if !credential.IsValidAdminPassword(adminPassword) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели: "+
|
||||||
|
"от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и 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)
|
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
|
||||||
status := int64(1)
|
status := int64(1)
|
||||||
tokenVersion := int64(1)
|
tokenVersion := int64(1)
|
||||||
@@ -709,6 +740,7 @@ func tableExists(tableName string) bool {
|
|||||||
}
|
}
|
||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ
|
// ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ
|
||||||
// придумывает его сам.
|
// придумывает его сам.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -19,5 +19,11 @@ Frontend собирается production builder'ом из [`tools/build`](../..
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm install --frozen-lockfile
|
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()`.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"qrcode.vue": "3.4.1",
|
"qrcode.vue": "3.4.1",
|
||||||
"vue": "^3.5.42",
|
"vue": "^3.5.42",
|
||||||
"vue-echarts": "^8.1.0",
|
"vue-echarts": "^8.1.0",
|
||||||
"vue-i18n": "^9.14.5",
|
"vue-i18n": "^11.4.10",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -65,17 +65,6 @@
|
|||||||
"vue-eslint-parser": "10.4.1",
|
"vue-eslint-parser": "10.4.1",
|
||||||
"vue-tsc": "^3.3.11"
|
"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": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+45
-38
@@ -4,15 +4,6 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
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:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -54,8 +45,8 @@ importers:
|
|||||||
specifier: ^8.1.0
|
specifier: ^8.1.0
|
||||||
version: 8.1.0(echarts@6.1.0)(vue@3.5.42(typescript@5.9.3))
|
version: 8.1.0(echarts@6.1.0)(vue@3.5.42(typescript@5.9.3))
|
||||||
vue-i18n:
|
vue-i18n:
|
||||||
specifier: ^9.14.5
|
specifier: ^11.4.10
|
||||||
version: 9.14.5(vue@3.5.42(typescript@5.9.3))
|
version: 11.4.10(vue@3.5.42(typescript@5.9.3))
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.6.4
|
specifier: ^4.6.4
|
||||||
version: 4.6.4(vue@3.5.42(typescript@5.9.3))
|
version: 4.6.4(vue@3.5.42(typescript@5.9.3))
|
||||||
@@ -492,17 +483,21 @@ packages:
|
|||||||
'@iconify/utils@3.1.4':
|
'@iconify/utils@3.1.4':
|
||||||
resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
|
resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
|
||||||
|
|
||||||
'@intlify/core-base@9.14.5':
|
'@intlify/core-base@11.4.10':
|
||||||
resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
|
resolution: {integrity: sha512-+yJ74JRWVJokdgG9zYNMyTSzeNV3O9T4vVxk8PvLFHmI+R/BYA//cITh7vhRK37hWLZ4/kTcKcUz1dlWOpypIg==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
'@intlify/message-compiler@9.14.5':
|
'@intlify/devtools-types@11.4.10':
|
||||||
resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
|
resolution: {integrity: sha512-xZxzZsAuu6/0zoLRVQWdpXWe5Kjl0LnWpjlQA3r9u9FbLYMhapqt7IwkgQyn0Tm2GUNAqhj9eZiUmYOrB024BQ==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
'@intlify/shared@9.14.5':
|
'@intlify/message-compiler@11.4.10':
|
||||||
resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
|
resolution: {integrity: sha512-oUB/scz2EJENXDiUJ7JjZffOrH8UIZ1BuZeHvonbi5fWLavLt04aivuk2OIByOZA0tsci1bkeeQRmwhb5M8Imw==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
|
'@intlify/shared@11.4.10':
|
||||||
|
resolution: {integrity: sha512-FeImVdPeoSHTm3NBFFZHv0eRP9gQ3F4lj2puDBX5Kw7iiM1uJW6JTf39ian0K/17pbXCI3ef5i9RVsRrALqI6Q==}
|
||||||
|
engines: {node: '>= 22'}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
@@ -1525,7 +1520,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
picomatch: '>=2.3.2'
|
picomatch: ^3 || ^4
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
@@ -1789,8 +1784,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
|
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/lodash-es': '*'
|
'@types/lodash-es': '*'
|
||||||
lodash: '>=4.18.1'
|
lodash: '*'
|
||||||
lodash-es: '>=4.18.1'
|
lodash-es: '*'
|
||||||
|
|
||||||
lodash.truncate@4.4.2:
|
lodash.truncate@4.4.2:
|
||||||
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
|
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
|
||||||
@@ -1957,6 +1952,10 @@ packages:
|
|||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
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:
|
picomatch@4.0.7:
|
||||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2406,7 +2405,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
|
resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
browserslist: 4.28.7
|
browserslist: '>= 4.21.0'
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
@@ -2483,10 +2482,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||||
|
|
||||||
vue-i18n@9.14.5:
|
vue-i18n@11.4.10:
|
||||||
resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
|
resolution: {integrity: sha512-Lp+BjOxqzOY87DS6Z8KrQrpiTr9IN/Lt4kZEilwyXG2Wrx+AcU6IVsAW92HNXtVcn1HFFPV6ty41p9e/qDpyvg==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 22'}
|
||||||
deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html
|
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.0.0
|
vue: ^3.0.0
|
||||||
|
|
||||||
@@ -2782,17 +2780,23 @@ snapshots:
|
|||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
import-meta-resolve: 4.2.0
|
import-meta-resolve: 4.2.0
|
||||||
|
|
||||||
'@intlify/core-base@9.14.5':
|
'@intlify/core-base@11.4.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@intlify/message-compiler': 9.14.5
|
'@intlify/devtools-types': 11.4.10
|
||||||
'@intlify/shared': 9.14.5
|
'@intlify/message-compiler': 11.4.10
|
||||||
|
'@intlify/shared': 11.4.10
|
||||||
|
|
||||||
'@intlify/message-compiler@9.14.5':
|
'@intlify/devtools-types@11.4.10':
|
||||||
dependencies:
|
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
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
'@intlify/shared@9.14.5': {}
|
'@intlify/shared@11.4.10': {}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4113,7 +4117,7 @@ snapshots:
|
|||||||
micromatch@4.0.8:
|
micromatch@4.0.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
picomatch: 4.0.7
|
picomatch: 2.3.2
|
||||||
|
|
||||||
mime-db@1.52.0: {}
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
@@ -4245,6 +4249,8 @@ snapshots:
|
|||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
picomatch@2.3.2: {}
|
||||||
|
|
||||||
picomatch@4.0.7: {}
|
picomatch@4.0.7: {}
|
||||||
|
|
||||||
pinia@2.3.1(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)):
|
pinia@2.3.1(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)):
|
||||||
@@ -4798,10 +4804,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@intlify/core-base': 9.14.5
|
'@intlify/core-base': 11.4.10
|
||||||
'@intlify/shared': 9.14.5
|
'@intlify/devtools-types': 11.4.10
|
||||||
|
'@intlify/shared': 11.4.10
|
||||||
'@vue/devtools-api': 6.6.4
|
'@vue/devtools-api': 6.6.4
|
||||||
vue: 3.5.42(typescript@5.9.3)
|
vue: 3.5.42(typescript@5.9.3)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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[],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}}$`
|
||||||
|
);
|
||||||
@@ -21,8 +21,13 @@ export default {
|
|||||||
password: "Password",
|
password: "Password",
|
||||||
login: "Login",
|
login: "Login",
|
||||||
capsLockOn: "Caps lock is On",
|
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: {
|
dashboard: {
|
||||||
stale: "Dashboard data is stale. Retrying automatically...",
|
stale: "Dashboard data is stale. Retrying automatically...",
|
||||||
@@ -55,7 +60,8 @@ export default {
|
|||||||
trafficApiUnreachable: "Unreachable",
|
trafficApiUnreachable: "Unreachable",
|
||||||
security: {
|
security: {
|
||||||
hysteriaStopped: "Hysteria2 is stopped",
|
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",
|
trafficApiUnavailable: "Traffic API is unavailable",
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
@@ -161,7 +167,9 @@ export default {
|
|||||||
gt: "“{field}”: must be greater than {gt}",
|
gt: "“{field}”: must be greater than {gt}",
|
||||||
peer_name:
|
peer_name:
|
||||||
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
|
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
|
||||||
credential_format: "“{field}”: 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",
|
rule_violated: "“{field}”: value is not acceptable",
|
||||||
validation_failed: "Validation failed",
|
validation_failed: "Validation failed",
|
||||||
body_invalid:
|
body_invalid:
|
||||||
@@ -210,7 +218,7 @@ export default {
|
|||||||
// The character set is stated as service.IsValidPeerName accepts it. The
|
// The character set is stated as service.IsValidPeerName accepts it. The
|
||||||
// previous wording described the rule that was in force before EX-03.
|
// previous wording described the rule that was in force before EX-03.
|
||||||
nameHint:
|
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",
|
remarkPlaceholder: "laptop",
|
||||||
remarkHint: "Optional operator note. It is never shown to the client.",
|
remarkHint: "Optional operator note. It is never shown to the client.",
|
||||||
secretPlaceholder: "leave empty to generate one",
|
secretPlaceholder: "leave empty to generate one",
|
||||||
|
|||||||
@@ -19,8 +19,24 @@ export default {
|
|||||||
password: "Пароль",
|
password: "Пароль",
|
||||||
login: "Войти",
|
login: "Войти",
|
||||||
capsLockOn: "Caps Lock включён",
|
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: {
|
dashboard: {
|
||||||
stale:
|
stale:
|
||||||
@@ -167,7 +183,13 @@ export default {
|
|||||||
gt: "«{field}»: значение должно быть больше {gt}",
|
gt: "«{field}»: значение должно быть больше {gt}",
|
||||||
peer_name:
|
peer_name:
|
||||||
"«{field}»: от {min} до {max} символов из набора {charset}. Пробелы, кириллица и знаки / : ; . недопустимы",
|
"«{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}»: значение не подходит",
|
rule_violated: "«{field}»: значение не подходит",
|
||||||
validation_failed: "Проверка данных не пройдена",
|
validation_failed: "Проверка данных не пройдена",
|
||||||
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
|
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
|
||||||
@@ -218,7 +240,7 @@ export default {
|
|||||||
// действовавшего до EX-03: подсказка обещала более узкий набор, чем
|
// действовавшего до EX-03: подсказка обещала более узкий набор, чем
|
||||||
// сервер принимает, и оператор не имел причин пробовать остальные.
|
// сервер принимает, и оператор не имел причин пробовать остальные.
|
||||||
nameHint:
|
nameHint:
|
||||||
"Идентификатор пира: от 6 до 32 символов — латиница, цифры и знаки !@#$%^&*()_+-=. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
|
"Идентификатор пира: от {min} до {max} символов — латиница, цифры и знаки {punctuation}. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
|
||||||
remarkPlaceholder: "ноутбук",
|
remarkPlaceholder: "ноутбук",
|
||||||
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
|
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
|
||||||
secretPlaceholder: "оставьте пустым — сгенерируем",
|
secretPlaceholder: "оставьте пустым — сгенерируем",
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export const ERR_CODE = {
|
|||||||
ruleViolated: "rule_violated",
|
ruleViolated: "rule_violated",
|
||||||
peerName: "peer_name",
|
peerName: "peer_name",
|
||||||
credentialFormat: "credential_format",
|
credentialFormat: "credential_format",
|
||||||
|
// Правило пароля администратора целиком: длина в символах, длина в байтах
|
||||||
|
// (предел bcrypt) и запрет управляющих символов. Отдельный код нужен потому,
|
||||||
|
// что min_length/max_length не умеют назвать отказ по БАЙТАМ, а он бывает у
|
||||||
|
// пароля, укладывающегося в границу символов.
|
||||||
|
adminPasswordFormat: "admin_password_format",
|
||||||
peerNameTaken: "peer_name_taken",
|
peerNameTaken: "peer_name_taken",
|
||||||
peerNameReserved: "peer_name_reserved",
|
peerNameReserved: "peer_name_reserved",
|
||||||
peerBootstrapLocked: "peer_bootstrap_identity_locked",
|
peerBootstrapLocked: "peer_bootstrap_identity_locked",
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ import { useI18n } from "vue-i18n";
|
|||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { adminChangePasswordApi } from "@/api/admin";
|
import { adminChangePasswordApi } from "@/api/admin";
|
||||||
import { useAdminStore } from "@/store/modules/admin";
|
import { useAdminStore } from "@/store/modules/admin";
|
||||||
|
import {
|
||||||
|
ADMIN_PASSWORD_MAX_BYTES,
|
||||||
|
ADMIN_PASSWORD_MAX_LENGTH,
|
||||||
|
ADMIN_PASSWORD_MIN_LENGTH,
|
||||||
|
adminPasswordFormRule,
|
||||||
|
} from "@/constants/credentials";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -46,7 +52,33 @@ const form = reactive({
|
|||||||
newPassword: "",
|
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 = {
|
const rules: FormRules = {
|
||||||
oldPassword: [
|
oldPassword: [
|
||||||
{
|
{
|
||||||
@@ -54,11 +86,7 @@ const rules: FormRules = {
|
|||||||
message: t("common.required"),
|
message: t("common.required"),
|
||||||
trigger: ["change", "blur"],
|
trigger: ["change", "blur"],
|
||||||
},
|
},
|
||||||
{
|
{ ...passwordRule },
|
||||||
pattern: passwordPattern,
|
|
||||||
message: t("common.invalid"),
|
|
||||||
trigger: ["change", "blur"],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
newPassword: [
|
newPassword: [
|
||||||
{
|
{
|
||||||
@@ -66,11 +94,7 @@ const rules: FormRules = {
|
|||||||
message: t("common.required"),
|
message: t("common.required"),
|
||||||
trigger: ["change", "blur"],
|
trigger: ["change", "blur"],
|
||||||
},
|
},
|
||||||
{
|
{ ...passwordRule },
|
||||||
pattern: passwordPattern,
|
|
||||||
message: t("common.invalid"),
|
|
||||||
trigger: ["change", "blur"],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,9 @@
|
|||||||
<el-col :xs="24" :sm="12" :lg="6"
|
<el-col :xs="24" :sm="12" :lg="6"
|
||||||
><el-card shadow="never"
|
><el-card shadow="never"
|
||||||
>{{ $t("dashboard.onlinePeers") }}:
|
>{{ $t("dashboard.onlinePeers") }}:
|
||||||
{{ summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?" }}</el-card
|
{{
|
||||||
|
summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?"
|
||||||
|
}}</el-card
|
||||||
></el-col
|
></el-col
|
||||||
>
|
>
|
||||||
<el-col :xs="24" :sm="12" :lg="6"
|
<el-col :xs="24" :sm="12" :lg="6"
|
||||||
@@ -128,9 +130,9 @@
|
|||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
<span>{{ $t("dashboard.trafficChart") }}</span>
|
<span>{{ $t("dashboard.trafficChart") }}</span>
|
||||||
<el-radio-group v-model="range" size="small" @change="loadDashboard">
|
<el-radio-group v-model="range" size="small" @change="loadDashboard">
|
||||||
<el-radio-button label="1h">1h</el-radio-button>
|
<el-radio-button value="1h">1h</el-radio-button>
|
||||||
<el-radio-button label="24h">24h</el-radio-button>
|
<el-radio-button value="24h">24h</el-radio-button>
|
||||||
<el-radio-button label="7d">7d</el-radio-button>
|
<el-radio-button value="7d">7d</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ import { useAdminStore } from "@/store/modules/admin";
|
|||||||
// Зависимость API
|
// Зависимость API
|
||||||
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
|
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
|
||||||
import { AdminLoginDto } from "@/api/admin/types";
|
import { AdminLoginDto } from "@/api/admin/types";
|
||||||
|
import {
|
||||||
|
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 adminStore = useAdminStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -114,6 +124,21 @@ const loginForm = ref<AdminLoginDto>({
|
|||||||
pass: "",
|
pass: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Правила формы входа берутся из общего контракта, а не пишутся здесь.
|
||||||
|
*
|
||||||
|
* У пароля проверяется ТОЛЬКО длина. Прежнее правило требовало ещё и набор
|
||||||
|
* символов, из-за чего форма входа отказывалась отправлять пароль, который
|
||||||
|
* сервер принимает: набор пароля сервер не ограничивает нигде. Проверка,
|
||||||
|
* которая умеет только запереть оператора и ничего не защищает, — не проверка.
|
||||||
|
*
|
||||||
|
* Правило пароля — собственное, а не встроенные `min`/`max` Element Plus.
|
||||||
|
* Element Plus делегирует правила формы библиотеке `async-validator`, а она
|
||||||
|
* сравнивает `min`/`max` строки с `String.prototype.length`, то есть считает
|
||||||
|
* единицы UTF-16, тогда как сервер считает code points. Пароль из трёх эмодзи
|
||||||
|
* имел `value.length === 6` и проходил минимум формы, а сервер видел три руны и
|
||||||
|
* отказывал. См. isValidAdminPassword.
|
||||||
|
*/
|
||||||
const loginRules = {
|
const loginRules = {
|
||||||
username: [
|
username: [
|
||||||
{
|
{
|
||||||
@@ -122,8 +147,12 @@ const loginRules = {
|
|||||||
trigger: ["change", "blur"],
|
trigger: ["change", "blur"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
|
pattern: ADMIN_USERNAME_PATTERN,
|
||||||
message: t("login.usernameFormatIncorrect"),
|
message: t("credentials.usernameFormat", {
|
||||||
|
min: ADMIN_USERNAME_MIN_LENGTH,
|
||||||
|
max: ADMIN_USERNAME_MAX_LENGTH,
|
||||||
|
charset: ADMIN_USERNAME_CHARSET,
|
||||||
|
}),
|
||||||
trigger: ["change", "blur"],
|
trigger: ["change", "blur"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -133,11 +162,13 @@ const loginRules = {
|
|||||||
message: t("common.required"),
|
message: t("common.required"),
|
||||||
trigger: ["change", "blur"],
|
trigger: ["change", "blur"],
|
||||||
},
|
},
|
||||||
{
|
adminPasswordFormRule(
|
||||||
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
|
t("credentials.passwordFormat", {
|
||||||
message: t("login.passwordFormatIncorrect"),
|
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||||
trigger: ["change", "blur"],
|
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 {
|
.el-form-item {
|
||||||
background: var(--subMenuBg);
|
background: var(--subMenuBg);
|
||||||
border: 1px solid rgb(255 255 255 / 12%);
|
border: 1px solid rgb(255 255 255 / 12%);
|
||||||
border-radius: 5px;
|
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 {
|
.el-input {
|
||||||
|
|||||||
@@ -247,7 +247,15 @@
|
|||||||
v-model="dataForm.name"
|
v-model="dataForm.name"
|
||||||
:placeholder="$t('peer.form.namePlaceholder')"
|
: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>
|
||||||
<el-form-item :label="$t('peer.remark')" :error="serverErrors.remark">
|
<el-form-item :label="$t('peer.remark')" :error="serverErrors.remark">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -445,6 +453,13 @@ import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
|
|||||||
import { ERR_CODE, isApiError } from "@/utils/api-error";
|
import { ERR_CODE, isApiError } from "@/utils/api-error";
|
||||||
import { describeApiError, fieldErrorMap } from "@/utils/api-message";
|
import { describeApiError, fieldErrorMap } from "@/utils/api-message";
|
||||||
import { saveResponseAsFile } from "@/utils/download";
|
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_MIN_LENGTH = 6;
|
||||||
const SECRET_MAX_LENGTH = 128;
|
const SECRET_MAX_LENGTH = 128;
|
||||||
|
|
||||||
@@ -600,9 +611,9 @@ const rules = {
|
|||||||
pattern: PEER_NAME_PATTERN,
|
pattern: PEER_NAME_PATTERN,
|
||||||
message: t("error.code.peer_name", {
|
message: t("error.code.peer_name", {
|
||||||
field: t("error.field.name"),
|
field: t("error.field.name"),
|
||||||
min: 6,
|
min: PEER_NAME_MIN_LENGTH,
|
||||||
max: 32,
|
max: PEER_NAME_MAX_LENGTH,
|
||||||
charset: "a-z A-Z 0-9 !@#$%^&*()_+-=",
|
charset: PEER_NAME_CHARSET,
|
||||||
}),
|
}),
|
||||||
trigger: ["change", "blur"],
|
trigger: ["change", "blur"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -43,15 +43,24 @@ const (
|
|||||||
// валидатора у них один. Оператору это разные фразы: «не меньше 1
|
// валидатора у них один. Оператору это разные фразы: «не меньше 1
|
||||||
// устройства» и «не короче 6 символов», — и панель обязана уметь их
|
// устройства» и «не короче 6 символов», — и панель обязана уметь их
|
||||||
// различить, не заводя у себя таблицу «какое поле какого рода».
|
// различить, не заводя у себя таблицу «какое поле какого рода».
|
||||||
ErrCodeMin string = "min"
|
ErrCodeMin string = "min"
|
||||||
ErrCodeMax string = "max"
|
ErrCodeMax string = "max"
|
||||||
ErrCodeMinLength string = "min_length"
|
ErrCodeMinLength string = "min_length"
|
||||||
ErrCodeMaxLength string = "max_length"
|
ErrCodeMaxLength string = "max_length"
|
||||||
ErrCodeLen string = "len"
|
ErrCodeLen string = "len"
|
||||||
ErrCodeOneOf string = "oneof"
|
ErrCodeOneOf string = "oneof"
|
||||||
ErrCodeGreaterThan string = "gt"
|
ErrCodeGreaterThan string = "gt"
|
||||||
ErrCodePeerName string = "peer_name"
|
ErrCodePeerName string = "peer_name"
|
||||||
ErrCodeCredentialStr string = "credential_format"
|
ErrCodeCredentialStr string = "credential_format"
|
||||||
|
// ErrCodeAdminPassword — пароль администратора не удовлетворяет контракту.
|
||||||
|
//
|
||||||
|
// Отдельный код, а не пара min_length/max_length, потому что правило одно и
|
||||||
|
// проверяет три вещи сразу: длину в символах, длину в байтах (предел
|
||||||
|
// bcrypt) и отсутствие управляющих символов. Пока границы стояли двумя
|
||||||
|
// тегами, отказ по байтам было нечем назвать — его просто не существовало,
|
||||||
|
// и пароль из 64 кириллических букв доходил до хеширования, где становился
|
||||||
|
// системной ошибкой.
|
||||||
|
ErrCodeAdminPassword string = "admin_password_format"
|
||||||
ErrCodeRuleUnknown string = "rule_violated"
|
ErrCodeRuleUnknown string = "rule_violated"
|
||||||
|
|
||||||
// Доменные коды: правило соблюдено, но операция всё равно невозможна.
|
// Доменные коды: правило соблюдено, но операция всё равно невозможна.
|
||||||
|
|||||||
@@ -1,6 +1,37 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
|
// Правило пароля здесь и в LoginDto обязано быть ОДНИМ И ТЕМ ЖЕ.
|
||||||
|
//
|
||||||
|
// Пока границы расходились, панель запирала оператора снаружи после операции,
|
||||||
|
// которую сама же и предлагала: форма смены пароля принимала 64 символа, а
|
||||||
|
// форма входа — 32, и назначенный длинный пароль становился паролем, с которым
|
||||||
|
// невозможно войти.
|
||||||
|
//
|
||||||
|
// Числа из тегов ушли внутрь правила `adminPassword`. Записанные тегом
|
||||||
|
// `min=6,max=64` они, кроме дублирования, были ещё и неполны: тег считает
|
||||||
|
// символы Unicode, а bcrypt — байты, поэтому новый пароль из 64 кириллических
|
||||||
|
// букв проходил проверку формы, доходил до util.HashPassword и возвращался
|
||||||
|
// оператору системной ошибкой. Отказ по байтам тегом валидатора выразить
|
||||||
|
// нечем — правила длины в байтах у него нет.
|
||||||
|
//
|
||||||
|
// Набора символов здесь нет и не было: пароль принадлежит оператору целиком.
|
||||||
|
|
||||||
type AdminChangePasswordDto struct {
|
type AdminChangePasswordDto struct {
|
||||||
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,min=6,max=64"`
|
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,adminPassword"`
|
||||||
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,min=6,max=64"`
|
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,adminPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize: пароль НЕ триммится ни в одном из полей.
|
||||||
|
//
|
||||||
|
// Пустое поле обязано выглядеть незаполненным и получить отказ `required`: у
|
||||||
|
// поля-указателя `required` считает значение заданным по самому факту
|
||||||
|
// ненулевого указателя, поэтому `{"oldPassword":""}` иначе получило бы отказ по
|
||||||
|
// длине вместо «поле обязательно».
|
||||||
|
//
|
||||||
|
// Пробел по краям при этом остаётся частью значения. Форма входа ведёт себя так
|
||||||
|
// же (LoginDto.Normalize), и иначе быть не может: пароль, назначенный здесь,
|
||||||
|
// вводится там.
|
||||||
|
func (d *AdminChangePasswordDto) Normalize() {
|
||||||
|
emptyToNil(&d.OldPassword)
|
||||||
|
emptyToNil(&d.NewPassword)
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-2
@@ -1,6 +1,56 @@
|
|||||||
package dto
|
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 {
|
type LoginDto struct {
|
||||||
Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"`
|
Username *string `json:"username" form:"username" validate:"required,credentialStr"`
|
||||||
Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=64"`
|
Pass *string `json:"pass" form:"pass" validate:"required,adminPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize: незаполненное поле обязано выглядеть незаполненным.
|
||||||
|
//
|
||||||
|
// У поля-указателя `required` считает значение заданным по ненулевому
|
||||||
|
// указателю, поэтому `{"username":"","pass":""}` проходило `required` целиком и
|
||||||
|
// получало отказ по набору символов и по длине — то есть оператор, отправивший
|
||||||
|
// пустую форму, читал про недопустимые символы вместо «поле обязательно».
|
||||||
|
//
|
||||||
|
// Логин при этом ещё и триммится: скопированное из bootstrap-admin.secret
|
||||||
|
// значение регулярно приезжает с пробелом или переводом строки на конце, и
|
||||||
|
// раньше это давало «неверный логин или пароль» — отказ, который невозможно
|
||||||
|
// связать с причиной.
|
||||||
|
//
|
||||||
|
// Пароль НЕ триммится. Набор его символов не ограничен, пробел по краям —
|
||||||
|
// законная часть значения, и молчаливое его удаление означало бы, что панель
|
||||||
|
// отправляет не то, что ввёл оператор.
|
||||||
|
func (d *LoginDto) Normalize() {
|
||||||
|
blankToNil(&d.Username)
|
||||||
|
emptyToNil(&d.Pass)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,24 @@ func trimValue(field *string) {
|
|||||||
*field = strings.TrimSpace(*field)
|
*field = strings.TrimSpace(*field)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emptyToNil: пустая строка означает «не задано», и ничего больше.
|
||||||
|
//
|
||||||
|
// Отличается от blankToNil тем, что НЕ трогает пробелы. Применяется к полям,
|
||||||
|
// содержимое которых принадлежит оператору целиком, — к паролю: набор его
|
||||||
|
// символов не ограничен, пробел по краям является частью значения, и
|
||||||
|
// молчаливое его удаление означало бы, что на сервер уезжает не то, что было
|
||||||
|
// введено. Пустое поле при этом обязано получить отказ `required`, а не отказ
|
||||||
|
// по длине: у поля-указателя `required` считает значение заданным по самому
|
||||||
|
// факту ненулевого указателя.
|
||||||
|
func emptyToNil(field **string) {
|
||||||
|
if *field == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if **field == "" {
|
||||||
|
*field = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// zeroToNil: ноль у необязательного числового поля означает «не задано».
|
// zeroToNil: ноль у необязательного числового поля означает «не задано».
|
||||||
//
|
//
|
||||||
// Применяется ТОЛЬКО там, где ноль не является осмысленным значением:
|
// Применяется ТОЛЬКО там, где ноль не является осмысленным значением:
|
||||||
|
|||||||
@@ -119,4 +119,56 @@ func TestNormalizableIsImplemented(t *testing.T) {
|
|||||||
var _ Normalizable = (*PeerPageDto)(nil)
|
var _ Normalizable = (*PeerPageDto)(nil)
|
||||||
var _ Normalizable = (*BaseDto)(nil)
|
var _ Normalizable = (*BaseDto)(nil)
|
||||||
var _ Normalizable = (*LogDto)(nil)
|
var _ Normalizable = (*LogDto)(nil)
|
||||||
|
var _ Normalizable = (*LoginDto)(nil)
|
||||||
|
var _ Normalizable = (*AdminChangePasswordDto)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пароль НЕ триммится ни на одной из двух форм.
|
||||||
|
//
|
||||||
|
// Набор его символов не ограничен, пробел по краям — часть значения, и
|
||||||
|
// молчаливое его удаление означало бы, что на сервер уезжает не то, что ввёл
|
||||||
|
// оператор. Логин при этом триммится: скопированное из bootstrap-admin.secret
|
||||||
|
// значение регулярно приезжает с пробелом или переводом строки на конце.
|
||||||
|
func TestPasswordIsNeverTrimmedByNormalization(t *testing.T) {
|
||||||
|
login := LoginDto{Username: strPtr(" hy2xsadmin\n"), Pass: strPtr(" пароль ")}
|
||||||
|
login.Normalize()
|
||||||
|
|
||||||
|
if login.Username == nil || *login.Username != "hy2xsadmin" {
|
||||||
|
t.Fatalf("логин не приведён к каноничному виду: %v", login.Username)
|
||||||
|
}
|
||||||
|
if login.Pass == nil || *login.Pass != " пароль " {
|
||||||
|
t.Fatalf("пароль изменён нормализацией: %q", *login.Pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
change := AdminChangePasswordDto{
|
||||||
|
OldPassword: strPtr("abcde "),
|
||||||
|
NewPassword: strPtr(" abcdef"),
|
||||||
|
}
|
||||||
|
change.Normalize()
|
||||||
|
|
||||||
|
if change.OldPassword == nil || *change.OldPassword != "abcde " {
|
||||||
|
t.Fatalf("старый пароль изменён нормализацией: %v", change.OldPassword)
|
||||||
|
}
|
||||||
|
if change.NewPassword == nil || *change.NewPassword != " abcdef" {
|
||||||
|
t.Fatalf("новый пароль изменён нормализацией: %v", change.NewPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой пароль обязан выглядеть НЕЗАДАННЫМ.
|
||||||
|
//
|
||||||
|
// У поля-указателя `required` считает значение заданным по самому факту
|
||||||
|
// ненулевого указателя, поэтому без этого шага оператор, отправивший пустую
|
||||||
|
// форму, получал отказ по длине вместо «поле обязательно».
|
||||||
|
func TestEmptyPasswordBecomesAbsent(t *testing.T) {
|
||||||
|
login := LoginDto{Username: strPtr("hy2xsadmin"), Pass: strPtr("")}
|
||||||
|
login.Normalize()
|
||||||
|
if login.Pass != nil {
|
||||||
|
t.Fatalf("пустой пароль остался заданным: %q", *login.Pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
change := AdminChangePasswordDto{OldPassword: strPtr(""), NewPassword: strPtr("")}
|
||||||
|
change.Normalize()
|
||||||
|
if change.OldPassword != nil || change.NewPassword != nil {
|
||||||
|
t.Fatal("пустые пароли формы смены остались заданными")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
"hy2xs-admin/dao"
|
"hy2xs-admin/dao"
|
||||||
"hy2xs-admin/model/bo"
|
"hy2xs-admin/model/bo"
|
||||||
"hy2xs-admin/model/constant"
|
"hy2xs-admin/model/constant"
|
||||||
@@ -58,6 +59,23 @@ func Login(username string, plainPassword string) (string, bool, error) {
|
|||||||
if tokenErr != nil {
|
if tokenErr != nil {
|
||||||
return "", false, tokenErr
|
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, который ставит установка.
|
// флаг force_password_change, который ставит установка.
|
||||||
|
|||||||
+31
-2
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"hy2xs-admin/credential"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Функции SHA224String здесь больше нет, как и ветки сравнения паролей по ней.
|
// Функции SHA224String здесь больше нет, как и ветки сравнения паролей по ней.
|
||||||
@@ -32,9 +33,37 @@ import (
|
|||||||
// осталась запасным путём проверки пароля со слабым алгоритмом — в обработчике
|
// осталась запасным путём проверки пароля со слабым алгоритмом — в обработчике
|
||||||
// логина, доступном без аутентификации.
|
// логина, доступном без аутентификации.
|
||||||
|
|
||||||
|
// HashPassword проверяет пароль ОБЩИМ контрактом и хеширует его.
|
||||||
|
//
|
||||||
|
// Здесь стояла собственная проверка:
|
||||||
|
//
|
||||||
|
// if len(strings.TrimSpace(password)) < 6 { … }
|
||||||
|
//
|
||||||
|
// и она была третьей копией правила о пароле — вдобавок расходящейся с двумя
|
||||||
|
// остальными сразу по двум признакам.
|
||||||
|
//
|
||||||
|
// Первое: `TrimSpace`. Контракт продукта прямо объявляет пробел по краям частью
|
||||||
|
// пароля и не снимает его нигде — ни в нормализации DTO, ни при чтении
|
||||||
|
// bootstrap-окружения. Пароль `"abcde "` считался корректным формой входа,
|
||||||
|
// формой смены и оркестратором, а здесь превращался в `"abcde"` и получал
|
||||||
|
// отказ. То есть значение, принятое всеми дверями продукта, невозможно было
|
||||||
|
// захешировать.
|
||||||
|
//
|
||||||
|
// Второе: у проверки не было верхней границы вовсе, поэтому предел bcrypt в 72
|
||||||
|
// БАЙТА всплывал ошибкой хеширования уже после того, как все проверки пройдены.
|
||||||
|
//
|
||||||
|
// Теперь правило одно на весь продукт. Возможность позвать его отсюда — прямая
|
||||||
|
// причина, по которой контракт живёт в пакете `credential`, а не в `service`:
|
||||||
|
// `service` импортирует `util`, и обратный импорт был бы циклическим.
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
if len(strings.TrimSpace(password)) < 6 {
|
if !credential.IsValidAdminPassword(password) {
|
||||||
return "", errors.New("password too short")
|
return "", fmt.Errorf(
|
||||||
|
"пароль администратора не удовлетворяет контракту: от %d до %d символов, "+
|
||||||
|
"не более %d байт в UTF-8, без управляющих символов и U+FEFF",
|
||||||
|
credential.AdminPasswordMinLength,
|
||||||
|
credential.AdminPasswordMaxLength,
|
||||||
|
credential.AdminPasswordMaxBytes,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"hy2xs-admin/credential"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Здесь был TestSHA224String, который печатал хеш в stdout и ничего не
|
// Здесь был TestSHA224String, который печатал хеш в stdout и ничего не
|
||||||
@@ -41,12 +43,81 @@ func TestHashPasswordIsSalted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHashPasswordRejectsShortPassword(t *testing.T) {
|
// HashPassword проверяет ОБЩИЙ контракт, а не собственную копию правила.
|
||||||
if _, err := HashPassword("12345"); err == nil {
|
//
|
||||||
t.Fatal("слишком короткий пароль принят")
|
// Здесь стояло `len(strings.TrimSpace(password)) < 6`, и это была третья копия
|
||||||
|
// правила о пароле, расходящаяся с двумя остальными. Тест закреплял её обе
|
||||||
|
// ошибки: он требовал ОТКАЗА на шести пробелах — то есть на значении, которое
|
||||||
|
// контракт продукта принимает, — и не проверял верхнюю границу вовсе.
|
||||||
|
//
|
||||||
|
// Шесть пробелов принимаются намеренно. Пробел объявлен частью пароля, набор
|
||||||
|
// символов не ограничен, и «слабый пароль» здесь не тема: значение назначает
|
||||||
|
// оператор, а хеширование не является местом, где оценивают стойкость.
|
||||||
|
func TestHashPasswordFollowsTheCredentialContract(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
password string
|
||||||
|
}{
|
||||||
|
{"ровно минимум", strings.Repeat("a", credential.AdminPasswordMinLength)},
|
||||||
|
{"на символ короче минимума", strings.Repeat("a", credential.AdminPasswordMinLength-1)},
|
||||||
|
{"шесть пробелов", strings.Repeat(" ", credential.AdminPasswordMinLength)},
|
||||||
|
{"краевой пробел", "abcde "},
|
||||||
|
{"ровно максимум в ASCII", strings.Repeat("a", credential.AdminPasswordMaxLength)},
|
||||||
|
{"на символ длиннее максимума", strings.Repeat("a", credential.AdminPasswordMaxLength+1)},
|
||||||
|
{"ровно предел bcrypt", strings.Repeat("я", 36)},
|
||||||
|
{"на букву длиннее предела bcrypt", strings.Repeat("я", 37)},
|
||||||
|
{"эмодзи ровно в предел", strings.Repeat("😀", 18)},
|
||||||
|
{"эмодзи за пределом", strings.Repeat("😀", 19)},
|
||||||
|
{"64 кириллические буквы", strings.Repeat("я", credential.AdminPasswordMaxLength)},
|
||||||
|
{"перевод строки", "abcde\n"},
|
||||||
|
{"пусто", ""},
|
||||||
}
|
}
|
||||||
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.
|
||||||
@@ -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-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-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 |
|
||||||
|
|
||||||
## Разборы кода между прогонами
|
## Разборы кода между прогонами
|
||||||
|
|
||||||
|
|||||||
@@ -1117,6 +1117,18 @@ B входит со своим устаревшим 0 -> lastOnline снова 0
|
|||||||
Если переменной нет, а создавать учётную запись нужно, админка **отказывает в
|
Если переменной нет, а создавать учётную запись нужно, админка **отказывает в
|
||||||
старте** с сообщением, называющим причину и способ починки.
|
старте** с сообщением, называющим причину и способ починки.
|
||||||
|
|
||||||
|
То же и при значении вне контракта пароля: `HY2XS_ADMIN_INITIAL_PASSWORD`
|
||||||
|
проверяется против того же правила, что и форма входа
|
||||||
|
(`apps/credential/admin.go`), и непригодное значение роняет старт с внятным
|
||||||
|
текстом, а не доходит до `bcrypt.GenerateFromPassword`, чтобы вернуться оттуда
|
||||||
|
строкой `password length exceeds 72 bytes`. Учётная запись при этом не
|
||||||
|
создаётся: установка иначе завершилась бы успешно, а войти было бы нельзя.
|
||||||
|
|
||||||
|
Пароль читается **как есть**: пробелы по краям объявлены его частью и не
|
||||||
|
снимаются ни здесь, ни при хешировании, ни на форме входа. Раньше bootstrap
|
||||||
|
делал `strings.TrimSpace`, и учётная запись создавалась не с тем паролем,
|
||||||
|
который оператор записал в `hy2xs.env`.
|
||||||
|
|
||||||
Раньше она в этом случае придумывала пароль сама и печатала его двумя
|
Раньше она в этом случае придумывала пароль сама и печатала его двумя
|
||||||
`logrus.Warnf` — открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, то есть в
|
`logrus.Warnf` — открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, то есть в
|
||||||
файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо
|
файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо
|
||||||
|
|||||||
@@ -85,6 +85,12 @@
|
|||||||
сборку — vue-i18n молча отдаёт сам ключ, и оператор видит `error.code.min_length`
|
сборку — vue-i18n молча отдаёт сам ключ, и оператор видит `error.code.min_length`
|
||||||
вместо фразы.
|
вместо фразы.
|
||||||
|
|
||||||
|
Совпадения ключей недостаточно: строки компилируются как Vue I18n message
|
||||||
|
format только при переводе. Специальные символы (`@`, `$`, `{}`, `|`) нельзя
|
||||||
|
вставлять в текст как произвольные данные. Динамический набор знаков передаётся
|
||||||
|
через named interpolation, а builder переводит каждую leaf-строку RU/EN и
|
||||||
|
считает ошибкой как исключение, так и compiler diagnostics в `console.error`.
|
||||||
|
|
||||||
**Состояние сессии** сообщается кодами `unauthorized`, `session_expired`,
|
**Состояние сессии** сообщается кодами `unauthorized`, `session_expired`,
|
||||||
`token_invalid`, `account_disabled` при `code = 50401`. Панель по ним
|
`token_invalid`, `account_disabled` при `code = 50401`. Панель по ним
|
||||||
показывает диалог и возвращает на форму входа.
|
показывает диалог и возвращает на форму входа.
|
||||||
@@ -132,6 +138,11 @@
|
|||||||
продукт: в таблицу пиров ведут две двери, и они не имеют права требовать
|
продукт: в таблицу пиров ведут две двери, и они не имеют права требовать
|
||||||
разного.
|
разного.
|
||||||
|
|
||||||
|
Frontend-зеркало границ, regex и человекочитаемого набора находится в одном
|
||||||
|
модуле `constants/peer.ts` и сверяется с Go-контрактом тестом. Пунктуация
|
||||||
|
`!@#$%^&*()_+-=` попадает в подсказку как значение `{punctuation}`, а не как
|
||||||
|
часть синтаксиса message format.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Таблицы журнала
|
## 4. Таблицы журнала
|
||||||
@@ -259,3 +270,195 @@ control plane показывал всех пиров офлайн. Теперь
|
|||||||
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
|
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
|
||||||
ни config API, ни таблица `config`, ни настройки панели его не содержат и не
|
ни 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
@@ -136,7 +136,7 @@ Bun обновляется отдельно от остальных: оркес
|
|||||||
полного прохода `bun test → tsc → compile → приёмка на Debian`, а не строки в
|
полного прохода `bun test → tsc → compile → приёмка на Debian`, а не строки в
|
||||||
общем патче.
|
общем патче.
|
||||||
|
|
||||||
### Проверка типов frontend — обязательный шаг релиза
|
### Runtime-компиляция локализации и типы frontend — обязательные шаги релиза
|
||||||
|
|
||||||
```text
|
```text
|
||||||
pnpm run typecheck → vue-tsc --noEmit → ОБЯЗАН пройти
|
pnpm run typecheck → vue-tsc --noEmit → ОБЯЗАН пройти
|
||||||
@@ -144,9 +144,13 @@ pnpm run build:prod → vite build
|
|||||||
pnpm run verify → typecheck, затем build
|
pnpm run verify → typecheck, затем build
|
||||||
```
|
```
|
||||||
|
|
||||||
`bundle_ui()` запускает `typecheck` **до** сборки bundle: собирать production
|
После `pnpm install --frozen-lockfile` функция `bundle_ui()` сначала запускает
|
||||||
bundle из кода, который не проходит проверку типов, незачем. Порядок и сам факт
|
`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
|
До v1 этой гарантии не было. `build:prod` означал `vite build && vue-tsc
|
||||||
--noEmit`, но `vue-tsc` был версии `0.35.0` (2022 год) и шаблоны Vue
|
--noEmit`, но `vue-tsc` был версии `0.35.0` (2022 год) и шаблоны Vue
|
||||||
@@ -222,7 +226,9 @@ bundle из кода, который не проходит проверку ти
|
|||||||
|
|
||||||
Уязвимость обычно приходит не из прямой зависимости, а из чужого поддерева, и
|
Уязвимость обычно приходит не из прямой зависимости, а из чужого поддерева, и
|
||||||
поднимать ради неё весь frontend-граф не нужно. Такие случаи закрываются
|
поднимать ради неё весь frontend-граф не нужно. Такие случаи закрываются
|
||||||
списком `pnpm.overrides` в `apps/frontend/package.json`:
|
списком `overrides` в `apps/frontend/pnpm-workspace.yaml`. Настройки pnpm не
|
||||||
|
хранятся в поле `pnpm` package manifest: современные версии его игнорируют, и
|
||||||
|
правило осталось бы только случайно замороженным в старом lock-файле.
|
||||||
|
|
||||||
| override | зачем |
|
| override | зачем |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -253,14 +259,15 @@ bundle из кода, который не проходит проверку ти
|
|||||||
| Шаг сборки | Что запускается |
|
| Шаг сборки | Что запускается |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` |
|
| `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 ./...` |
|
| `run_admin_tests` | `go vet ./...`, `go test ./...` |
|
||||||
|
|
||||||
Готовый пакет объявляет об этом полем `tests_gate=true` в
|
Готовый пакет объявляет об этом полем `tests_gate=true` в
|
||||||
`metadata/package.env` — так же, как `dependency_security_gate` и
|
`metadata/package.env` — так же, как `dependency_security_gate` и
|
||||||
`hysteria_compat_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.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` относится к пиру установщика.
|
Аналогичное сообщение про `HY2XS_ADMIN_CON_PASS` относится к пиру установщика.
|
||||||
Его секрет продублирован в `bootstrap-admin.secret`, откуда его читает проверка
|
Его секрет продублирован в `bootstrap-admin.secret`, откуда его читает проверка
|
||||||
machine-auth, поэтому придуманный секрет разошёлся бы с файлом и первая же
|
machine-auth, поэтому придуманный секрет разошёлся бы с файлом и первая же
|
||||||
@@ -550,13 +601,43 @@ machine-auth, поэтому придуманный секрет разошёл
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl stop hy2xs-admin
|
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
|
"$HY2XS_INSTALL_DIR/hy2xs-admin" reset-admin
|
||||||
systemctl start hy2xs-admin
|
systemctl start hy2xs-admin
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime env подключается намеренно: из него берутся пути к базе и журналу
|
Пути к базе и журналу (`HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`) берутся из runtime env
|
||||||
(`HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`) — те же, с которыми работает юнит.
|
намеренно: это те же значения, с которыми работает юнит.
|
||||||
|
|
||||||
|
**Почему не `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 …)` повторно как код не исполняется — он
|
||||||
|
становится значением переменной, и это принципиальная разница.
|
||||||
|
|
||||||
Команда печатает новые логин и пароль в консоль и требует смены пароля при
|
Команда печатает новые логин и пароль в консоль и требует смены пароля при
|
||||||
первом входе. Работает поверх существующей установки; на машине без базы она
|
первом входе. Работает поверх существующей установки; на машине без базы она
|
||||||
|
|||||||
@@ -74,18 +74,19 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env
|
|||||||
быть отменён: ни неудачной записью состояния в `/var/lib/hy2xs`, ни отказом
|
быть отменён: ни неудачной записью состояния в `/var/lib/hy2xs`, ни отказом
|
||||||
одной из своих стадий. Поэтому в журнале нужно читать две разные вещи:
|
одной из своих стадий. Поэтому в журнале нужно читать две разные вещи:
|
||||||
|
|
||||||
| Строка в журнале | Что она означает |
|
| Строка в журнале | Что она означает |
|
||||||
| --- | --- |
|
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `failed to persist failure state, continuing with the mandatory rollback` | маркер не обновился (обычно заполненный диск), но восстановление выполнено; после освобождения места запустить `doctor` |
|
| `failed to persist failure state, continuing with the mandatory rollback` | маркер не обновился (обычно заполненный диск), но восстановление выполнено; после освобождения места запустить `doctor` |
|
||||||
| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены |
|
| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены |
|
||||||
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
|
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
|
||||||
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
|
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
|
||||||
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
|
| `systemctl reset-failed <unit> exited with …, but the cleanup postcondition is satisfied` | юнит уже выгружен или не находится в `failed`; это допустимый идемпотентный cleanup, ручное восстановление не требуется |
|
||||||
| `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)` | guard взведён; с этого момента операция обязана снять его до фиксации успеха |
|
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
|
||||||
| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания |
|
| `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)` | guard взведён; с этого момента операция обязана снять его до фиксации успеха |
|
||||||
| `automatic firewall rollback has already fired` | guard успел сработать; сервер работает на **прежнем** firewall, операция обязана завершиться отказом |
|
| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания |
|
||||||
| `firewall rollback guard <unit> is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd |
|
| `automatic firewall rollback has already fired` | guard успел сработать; сервер работает на **прежнем** firewall, операция обязана завершиться отказом |
|
||||||
| `unable to verify firewall rollback guard state; systemd query failed` | состояние guard'а недоказуемо; операция не начата, чинить нужно systemd, а не ждать |
|
| `firewall rollback guard <unit> is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd |
|
||||||
|
| `unable to verify firewall rollback guard state; systemd query failed` | состояние guard'а недоказуемо; операция не начата, чинить нужно systemd, а не ждать |
|
||||||
|
|
||||||
Отдельно про сработавший guard. Окно 45 секунд намеренно короче худшего случая
|
Отдельно про сработавший guard. Окно 45 секунд намеренно короче худшего случая
|
||||||
smoke и не обязано его покрывать: доказательством служит не время, а маркер
|
smoke и не обязано его покрывать: доказательством служит не время, а маркер
|
||||||
@@ -336,6 +337,7 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/
|
|||||||
```
|
```
|
||||||
|
|
||||||
Инварианты:
|
Инварианты:
|
||||||
|
|
||||||
- команда не выводит исходные секреты в stdout;
|
- команда не выводит исходные секреты в stdout;
|
||||||
- требуется выбрать ровно один режим: `--in-place` или `--out <path>`;
|
- требуется выбрать ровно один режим: `--in-place` или `--out <path>`;
|
||||||
- `--format auto` пытается определить формат по имени файла, при неоднозначности используйте `--format env|yaml`;
|
- `--format auto` пытается определить формат по имени файла, при неоднозначности используйте `--format env|yaml`;
|
||||||
@@ -344,14 +346,18 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/
|
|||||||
редакцию, а результат остаётся валидным YAML;
|
редакцию, а результат остаётся валидным YAML;
|
||||||
- в env-файлах секрет вырезается и из URL-значения, даже если имя ключа
|
- в env-файлах секрет вырезается и из URL-значения, даже если имя ключа
|
||||||
несекретное — например, `HY2_AUTH_URL` в `post-install.env`.
|
несекретное — например, `HY2_AUTH_URL` в `post-install.env`.
|
||||||
|
- `HY2XS_FORCE_PASSWORD_CHANGE=true|false` сохраняется как диагностический
|
||||||
|
boolean; это точное исключение, остальные ключи с `PASSWORD` редактируются.
|
||||||
|
|
||||||
Та же редакция применяется к diagnostics-бандлу
|
Та же редакция применяется к diagnostics-бандлу
|
||||||
(`hy2xs-orchestrator diagnostics collect`), который собирается автоматически при
|
(`hy2xs-orchestrator diagnostics collect`), который собирается автоматически при
|
||||||
неудачной установке или реконфигурации. Бандл предназначен для передачи наружу,
|
неудачной установке или реконфигурации. Бандл предназначен для передачи наружу,
|
||||||
поэтому попадающие в него `hy2xs.env`, `post-install.env` и `config.yaml`
|
поэтому попадающие в него `hy2xs.env`, `post-install.env` и `config.yaml`
|
||||||
редактируются перед упаковкой.
|
редактируются перед упаковкой. Готовый архив находится в
|
||||||
|
`/var/lib/hy2xs/diagnostics`, принадлежит `root:root` и имеет режим `0600`.
|
||||||
|
Сам каталог имеет режим `0700`; не ослабляйте его права и не переносите bundle
|
||||||
|
в `HY2XS_LOG_DIR`, доступный сервисному пользователю.
|
||||||
|
|
||||||
При отказе **до** начала применения изменений (`fatal_pre_apply`) бандл не
|
При отказе **до** начала применения изменений (`fatal_pre_apply`) бандл не
|
||||||
собирается: его сбор сам создал бы каталоги в `/var/log/hy2xs` на сервере,
|
собирается: его сбор сам создал бы каталог в `/var/lib/hy2xs/diagnostics` на сервере,
|
||||||
который мы обещали не трогать.
|
который мы обещали не трогать.
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
## Технологический стек оркестратора
|
## Технологический стек оркестратора
|
||||||
|
|
||||||
Оркестратор фиксируется как:
|
Оркестратор фиксируется как:
|
||||||
|
|
||||||
- **Bun + TypeScript** по исходникам
|
- **Bun + TypeScript** по исходникам
|
||||||
- локальная сборка builder layer'ом
|
- локальная сборка builder layer'ом
|
||||||
- поставка на target в виде **готового install-артефакта**
|
- поставка на target в виде **готового install-артефакта**
|
||||||
|
|
||||||
Это означает:
|
Это означает:
|
||||||
|
|
||||||
- на target нет `npm`, `pnpm`, `yarn` или `bun install`
|
- на target нет `npm`, `pnpm`, `yarn` или `bun install`
|
||||||
- на target нет transpile/build step
|
- на target нет transpile/build step
|
||||||
- shell на target допустим только как thin wrapper entrypoint
|
- shell на target допустим только как thin wrapper entrypoint
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
## Главная роль оркестратора
|
## Главная роль оркестратора
|
||||||
|
|
||||||
Оркестратор работает **только на target machine** и умеет:
|
Оркестратор работает **только на target machine** и умеет:
|
||||||
|
|
||||||
- выполнить read-only проверку чистоты хоста (`preflight-install`)
|
- выполнить read-only проверку чистоты хоста (`preflight-install`)
|
||||||
- выполнить первичную установку (`install`)
|
- выполнить первичную установку (`install`)
|
||||||
- выполнить явную реконфигурацию (`reconfigure --dry-run|--apply`)
|
- выполнить явную реконфигурацию (`reconfigure --dry-run|--apply`)
|
||||||
@@ -42,6 +45,7 @@
|
|||||||
## Предусловия
|
## Предусловия
|
||||||
|
|
||||||
Оркестратор рассчитан только на:
|
Оркестратор рассчитан только на:
|
||||||
|
|
||||||
- чистый Debian 13
|
- чистый Debian 13
|
||||||
- root/sudo install context
|
- root/sudo install context
|
||||||
- один сервер
|
- один сервер
|
||||||
@@ -127,10 +131,10 @@ clean-host. Ко второму вызову на диске лежал собс
|
|||||||
Guard умеет останавливать только то, что через него проходит. Поэтому
|
Guard умеет останавливать только то, что через него проходит. Поэтому
|
||||||
универсального раннера в `lib/process.ts` нет — есть два явных набора:
|
универсального раннера в `lib/process.ts` нет — есть два явных набора:
|
||||||
|
|
||||||
| Набор | Guard | Назначение |
|
| Набор | Guard | Назначение |
|
||||||
| --- | --- | --- |
|
| -------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------- |
|
||||||
| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` |
|
| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` |
|
||||||
| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост |
|
| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост |
|
||||||
|
|
||||||
`*Secret`-варианты не печатают команду в текст ошибки: их аргументы несут
|
`*Secret`-варианты не печатают команду в текст ошибки: их аргументы несут
|
||||||
machine token или пароль пира, а сообщение уходит в логи и диагностику.
|
machine token или пароль пира, а сообщение уходит в логи и диагностику.
|
||||||
@@ -254,7 +258,7 @@ preflight-ошибка со словом `nftables` приводила к отк
|
|||||||
него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом
|
него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом
|
||||||
случае system rollback не выполняется, `install-state.json` не пишется,
|
случае system rollback не выполняется, `install-state.json` не пишется,
|
||||||
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
|
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
|
||||||
`/var/log/hy2xs`).
|
`/var/lib/hy2xs/diagnostics`).
|
||||||
- `systemctl stop/disable` выполняется **только если текущая операция сама
|
- `systemctl stop/disable` выполняется **только если текущая операция сама
|
||||||
развернула эти unit-файлы**.
|
развернула эти unit-файлы**.
|
||||||
|
|
||||||
@@ -277,10 +281,10 @@ preflight-ошибка со словом `nftables` приводила к отк
|
|||||||
|
|
||||||
Второй инвариант — **стадии отката независимы**:
|
Второй инвариант — **стадии отката независимы**:
|
||||||
|
|
||||||
| Команда | Стадии |
|
| Команда | Стадии |
|
||||||
| --- | --- |
|
| ------------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
| `install` | firewall → stop services → disable services → reset failed services |
|
| `install` | firewall → stop services → disable services → reset failed `hysteria-server` → reset failed `hy2xs-admin` |
|
||||||
| `reconfigure` | firewall → restore configuration |
|
| `reconfigure` | firewall → restore configuration |
|
||||||
|
|
||||||
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
|
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
|
||||||
упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все
|
упасть сама. Пока они стояли цепочкой `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` в каждой
|
тому, что действовало раньше. Пока непрерывность держалась на `|| true` в каждой
|
||||||
команде, стадия физически не могла сообщить, что восстановление не выполнилось:
|
команде, стадия физически не могла сообщить, что восстановление не выполнилось:
|
||||||
@@ -368,7 +380,11 @@ reconfigure B → создание копии упало, ошибка скры
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"opId": "2026-08-30T10-00-00.000Z",
|
"opId": "2026-08-30T10-00-00.000Z",
|
||||||
"entries": [
|
"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 }
|
{ "path": "/etc/nftables.d/hy2xs.nft", "present": false, "stored": null }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -423,11 +439,11 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
|||||||
|
|
||||||
Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY`:
|
Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY`:
|
||||||
|
|
||||||
| Значение | Поведение |
|
| Значение | Поведение |
|
||||||
| --- | --- |
|
| ----------------------- | ------------------------------------------------ |
|
||||||
| `strict` (по умолчанию) | расхождение останавливает операцию |
|
| `strict` (по умолчанию) | расхождение останавливает операцию |
|
||||||
| `warn` | печатается предупреждение, операция продолжается |
|
| `warn` | печатается предупреждение, операция продолжается |
|
||||||
| `off` | сравнение не выполняется |
|
| `off` | сравнение не выполняется |
|
||||||
|
|
||||||
Ослабление предназначено для топологий вне baseline (NAT, floating IP, anycast).
|
Ослабление предназначено для топологий вне baseline (NAT, floating IP, anycast).
|
||||||
Отсутствие A-записи остаётся фатальным при любом значении: имя без A-записи не
|
Отсутствие A-записи остаётся фатальным при любом значении: имя без A-записи не
|
||||||
@@ -436,6 +452,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
|||||||
## Что приходит на target
|
## Что приходит на target
|
||||||
|
|
||||||
На target должен попадать уже готовый package, содержащий:
|
На target должен попадать уже готовый package, содержащий:
|
||||||
|
|
||||||
- thin install entrypoint
|
- thin install entrypoint
|
||||||
- compiled orchestrator artifact
|
- compiled orchestrator artifact
|
||||||
- bundled HY2XS admin
|
- bundled HY2XS admin
|
||||||
@@ -447,6 +464,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
|||||||
## Логическая модульность
|
## Логическая модульность
|
||||||
|
|
||||||
Даже если на target приезжает один собранный артефакт, внутри исходников оркестратор должен быть разложен по шагам:
|
Даже если на target приезжает один собранный артефакт, внутри исходников оркестратор должен быть разложен по шагам:
|
||||||
|
|
||||||
- preflight
|
- preflight
|
||||||
- deps
|
- deps
|
||||||
- filesystem
|
- filesystem
|
||||||
@@ -474,17 +492,20 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
|||||||
## Модель поставки
|
## Модель поставки
|
||||||
|
|
||||||
Рекомендуемая baseline-модель:
|
Рекомендуемая baseline-модель:
|
||||||
|
|
||||||
- исходники оркестратора хранятся в `orchestrator/`
|
- исходники оркестратора хранятся в `orchestrator/`
|
||||||
- builder выполняет локальную сборку через Bun
|
- builder выполняет локальную сборку через Bun
|
||||||
- в install package кладётся готовый артефакт, который запускается thin wrapper'ом
|
- в install package кладётся готовый артефакт, который запускается thin wrapper'ом
|
||||||
|
|
||||||
Например:
|
Например:
|
||||||
|
|
||||||
- `package/install.sh` — проверка контекста и вызов оркестратора
|
- `package/install.sh` — проверка контекста и вызов оркестратора
|
||||||
- `package/orchestrator/hy2xs-orchestrator` — собранный артефакт
|
- `package/orchestrator/hy2xs-orchestrator` — собранный артефакт
|
||||||
|
|
||||||
## Логирование и коды возврата
|
## Логирование и коды возврата
|
||||||
|
|
||||||
Оркестратор должен:
|
Оркестратор должен:
|
||||||
|
|
||||||
- печатать понятные step-based сообщения
|
- печатать понятные step-based сообщения
|
||||||
- завершаться ненулевым кодом при ошибке
|
- завершаться ненулевым кодом при ошибке
|
||||||
- не скрывать первичный источник падения
|
- не скрывать первичный источник падения
|
||||||
@@ -500,6 +521,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
|||||||
## CLI baseline
|
## CLI baseline
|
||||||
|
|
||||||
Команды:
|
Команды:
|
||||||
|
|
||||||
- `preflight-install --package-dir <path> [--config <source-env>]`
|
- `preflight-install --package-dir <path> [--config <source-env>]`
|
||||||
- `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`
|
- `reconfigure --package-dir <path> --config /etc/hy2xs/hy2xs.env --dry-run`
|
||||||
@@ -513,6 +535,7 @@ PHASE 0 ничего не меняет.
|
|||||||
`--allow-partial-state` допустим только для `repair`.
|
`--allow-partial-state` допустим только для `repair`.
|
||||||
|
|
||||||
Инварианты:
|
Инварианты:
|
||||||
|
|
||||||
- только IPv4 bind/listen;
|
- только IPv4 bind/listen;
|
||||||
- TLS modes: `acme | file | self_signed_dev`;
|
- TLS modes: `acme | file | self_signed_dev`;
|
||||||
- `trafficStats.secret` отдельный от `JWT_SECRET`;
|
- `trafficStats.secret` отдельный от `JWT_SECRET`;
|
||||||
@@ -542,11 +565,179 @@ production-профилем, а не ищет подстроки. Проверя
|
|||||||
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
|
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
|
||||||
уходит в логи и в diagnostics-бандл.
|
уходит в логи и в diagnostics-бандл.
|
||||||
|
|
||||||
|
## Формат env-файлов: у него два читателя
|
||||||
|
|
||||||
|
`/etc/hy2xs/hy2xs.env` разбирает не только оркестратор. Файл объявлен
|
||||||
|
`EnvironmentFile=` в юните `hy2xs-admin`, то есть его читает **systemd**, и
|
||||||
|
формат обязан совпадать у обоих. Пока значения писались интерполяцией
|
||||||
|
(`` `HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}` ``), а читались
|
||||||
|
построчным `split("=")` с `trim()`, форматом это не являлось: совпадение
|
||||||
|
поведения держалось на том, что в значениях не встречалось ни пробелов по краям,
|
||||||
|
ни кавычек, ни обратных слешей. Продукт при этом обещает оператору, что набор
|
||||||
|
символов пароля не ограничен, а краевой пробел — часть значения.
|
||||||
|
|
||||||
|
Запись и разбор живут в `orchestrator/src/lib/envFile.ts` и повторяют конечный
|
||||||
|
автомат `parse_env_file_internal` из systemd (`src/basic/env-file.c`).
|
||||||
|
Существенны четыре его свойства:
|
||||||
|
|
||||||
|
1. у **незакавыченного** значения срезаются пробелы в конце, `\` уводит в
|
||||||
|
escape, а `\<перевод строки>` склеивает строки;
|
||||||
|
2. в **одинарных** кавычках всё literal до закрывающей кавычки — escape там
|
||||||
|
нет (отличие от `sh`);
|
||||||
|
3. в **двойных** кавычках `\` уводит в escape, и обратный слеш снимается только
|
||||||
|
перед `"`, `\`, `` ` `` и `$` (`SHELL_NEED_ESCAPE`); перед любым другим
|
||||||
|
символом он СОХРАНЯЕТСЯ;
|
||||||
|
4. подстановки переменных в env-файле нет вовсе: `$` внутри значения — обычный
|
||||||
|
символ.
|
||||||
|
|
||||||
|
Из (3) и (4) следует кодирование, которое переживает любое издание systemd:
|
||||||
|
двойные кавычки и экранирование **только** `\` и `"`. Оба входят в
|
||||||
|
`SHELL_NEED_ESCAPE` и разворачиваются одинаково в действующей редакции и в тех,
|
||||||
|
где escape в двойных кавычках снимался безусловно.
|
||||||
|
|
||||||
|
Кавычки ставятся только там, где они нужны: обычные значения (порты, пути,
|
||||||
|
домены, `50 mbps`, base64url-секреты) остаются побайтово прежними, поэтому
|
||||||
|
релизные гейты и инструкции оператора вида `grep '^HY2XS_UI_PORT=8080$'`
|
||||||
|
продолжают работать. Тем же кодировщиком пишется `bootstrap-admin.secret`.
|
||||||
|
|
||||||
|
Расхождений с systemd ровно два, оба намеренные и оба **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
|
`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*(.*)` подставляло маркер в
|
Это не косметика. Построчное правило `auth:\s*(.*)` подставляло маркер в
|
||||||
заголовок mapping'а и оставляло нетронутым вложенный
|
заголовок mapping'а и оставляло нетронутым вложенный
|
||||||
`auth.http.url` с `access_token=<секрет>`, то есть бандл уносил machine token
|
`auth.http.url` с `access_token=<секрет>`, то есть бандл уносил machine token
|
||||||
@@ -554,6 +745,7 @@ production-профилем, а не ищет подстроки. Проверя
|
|||||||
дерево.
|
дерево.
|
||||||
|
|
||||||
Редактируются:
|
Редактируются:
|
||||||
|
|
||||||
- поля с секретоподобным именем (`password`, `secret`, `token`, `apiKey`,
|
- поля с секретоподобным именем (`password`, `secret`, `token`, `apiKey`,
|
||||||
`privateKey`, `authorization`, `cookie`, `bearer`, `signature`, …);
|
`privateKey`, `authorization`, `cookie`, `bearer`, `signature`, …);
|
||||||
- карты, где секретны все значения (`auth.userpass`, `acme.dns.config`);
|
- карты, где секретны все значения (`auth.userpass`, `acme.dns.config`);
|
||||||
|
|||||||
@@ -140,10 +140,105 @@ anycast. Отсутствие A-записи фатально при любом
|
|||||||
Важно:
|
Важно:
|
||||||
- `HY2XS_ADMIN_INITIAL_PASSWORD` используется только для первичного bootstrap seed;
|
- `HY2XS_ADMIN_INITIAL_PASSWORD` используется только для первичного bootstrap seed;
|
||||||
- `HY2XS_ADMIN_CON_PASS` — отдельная runtime-сущность для Hysteria auth/smoke;
|
- `HY2XS_ADMIN_CON_PASS` — отдельная runtime-сущность для Hysteria auth/smoke;
|
||||||
- bootstrap secret хранится в явном формате `KEY=VALUE` (`ADMIN_USER`, `ADMIN_INITIAL_PASSWORD`, `ADMIN_CON_PASS`), права `0600`;
|
- bootstrap secret хранится в том же формате `KEY=VALUE` (`ADMIN_USER`, `ADMIN_INITIAL_PASSWORD`, `ADMIN_CON_PASS`), права `0600`; значения с пробелами по краям, кавычками или обратными слешами записываются в двойных кавычках — читать файл следует парсером формата, а не `cut -d= -f2-`;
|
||||||
- `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован);
|
- `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован);
|
||||||
- после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`.
|
- после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`.
|
||||||
|
|
||||||
|
В санитизированной диагностике корректное значение `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 контракт
|
### Immutable-bootstrap контракт
|
||||||
|
|
||||||
- `/etc/hy2xs/bootstrap-admin.secret` создаётся оркестратором только при первичной установке.
|
- `/etc/hy2xs/bootstrap-admin.secret` создаётся оркестратором только при первичной установке.
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ cd apps && go vet ./... && go test ./...
|
|||||||
# Проверка типов и сборка frontend
|
# Проверка типов и сборка frontend
|
||||||
cd apps/frontend && pnpm install --frozen-lockfile && pnpm run verify
|
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
|
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 (ничего не меняет)
|
# Сверка среды разработки с versions.env (ничего не меняет)
|
||||||
./tools/dev/doctor.sh
|
./tools/dev/doctor.sh
|
||||||
|
|
||||||
@@ -36,9 +39,12 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
|||||||
|
|
||||||
## Почему контракты панели проверяет Bun, а не vitest
|
## Почему контракты панели проверяет Bun, а не vitest
|
||||||
|
|
||||||
Проверяемые модули (`SvgIcon/symbol.ts`, `constants/branding.ts`, словари
|
Dependency-free модули (`SvgIcon/symbol.ts`, frontend constants и словари
|
||||||
локализации) намеренно чистые: ни Vite, ни DOM в них нет, поэтому их можно
|
локализации) намеренно чистые: ни Vite, ни DOM в них нет. Отдельный
|
||||||
выполнить вне браузера уже закреплённым в `versions.env` Bun.
|
`i18n-runtime.test.ts` запускается после `pnpm install`, потому что обязан
|
||||||
|
использовать именно `vue-i18n` из frozen lock-графа. Он обходит каждую строку
|
||||||
|
обоих словарей и считает отказом и исключение, и compiler diagnostics в
|
||||||
|
`console.error`.
|
||||||
|
|
||||||
Vitest с jsdom не вычисляет `currentColor` и визуальной корректности всё равно
|
Vitest с jsdom не вычисляет `currentColor` и визуальной корректности всё равно
|
||||||
не доказал бы, зато привёл бы в граф `pnpm audit` — а его порог считается по
|
не доказал бы, зато привёл бы в граф `pnpm audit` — а его порог считается по
|
||||||
|
|||||||
@@ -89,6 +89,51 @@
|
|||||||
|
|
||||||
Отдельно — round-trip `parse(render(config)) == config`. Этот тест ловит класс ошибок «в рендер runtime-конфига попал литерал вместо значения из конфигурации».
|
Отдельно — round-trip `parse(render(config)) == config`. Этот тест ловит класс ошибок «в рендер runtime-конфига попал литерал вместо значения из конфигурации».
|
||||||
|
|
||||||
|
### A4a. Формат env-файла совпадает с systemd
|
||||||
|
|
||||||
|
`orchestrator/test/env-file.test.ts`. У `hy2xs.env` два читателя, и один из них
|
||||||
|
не наш: файл объявлен `EnvironmentFile=` в юните `hy2xs-admin`. Поэтому
|
||||||
|
проверяется не «работает на наших данных», а совпадение с правилами systemd
|
||||||
|
(`src/basic/env-file.c`) на тех значениях, где прежний разбор расходился:
|
||||||
|
|
||||||
|
| Вход | Ожидание |
|
||||||
|
| --- | --- |
|
||||||
|
| `KEY= value ` | `value` — незакавыченное значение теряет краевые пробелы, как и у systemd |
|
||||||
|
| `KEY=" value "` | ` value ` — в кавычках сохраняются |
|
||||||
|
| `KEY="a\"b"` | `a"b` |
|
||||||
|
| `KEY="a\\b"` | `a\b` |
|
||||||
|
| `KEY="a\nb"` | `a\nb` — `n` не входит в `SHELL_NEED_ESCAPE`, слеш сохраняется |
|
||||||
|
| `KEY="$HOME"` | `$HOME` — подстановок в env-файле нет |
|
||||||
|
| `KEY='a\b'` | `a\b` — в одинарных кавычках escape нет вовсе |
|
||||||
|
| строка без `=` | отказ (намеренное расхождение: systemd её отбрасывает молча) |
|
||||||
|
| незакрытая кавычка на 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`):
|
Рендер конфига (`orchestrator/test/render-config.test.ts`):
|
||||||
|
|
||||||
- Gecko рендерит **только** gecko-подблок;
|
- Gecko рендерит **только** gecko-подблок;
|
||||||
@@ -361,6 +406,15 @@
|
|||||||
хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где
|
хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где
|
||||||
редактировались env и YAML, а `journal-admin.log` копировался как есть.
|
редактировались 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)
|
## A7. Machine token в журналах (unit)
|
||||||
|
|
||||||
`apps/middleware/log_test.go` — запрос
|
`apps/middleware/log_test.go` — запрос
|
||||||
@@ -508,7 +562,88 @@ wildcard-маршрутом фронтенда или дублирующая р
|
|||||||
|
|
||||||
- `HashPassword` выдаёт bcrypt и солит: два хеша одного пароля различаются;
|
- `HashPassword` выдаёт bcrypt и солит: два хеша одного пароля различаются;
|
||||||
- вход по несолёному SHA-224 (формат предыдущего поколения) **невозможен**;
|
- вход по несолёному SHA-224 (формат предыдущего поколения) **невозможен**;
|
||||||
- любая не-bcrypt строка в поле хеша отклоняется.
|
- любая не-bcrypt строка в поле хеша отклоняется;
|
||||||
|
- `HashPassword` следует ОБЩЕМУ контракту, а не собственной проверке. Раньше
|
||||||
|
здесь стояло `len(strings.TrimSpace(password)) < 6` — третья копия правила, и
|
||||||
|
она расходилась с двумя остальными: значение `abcde ` считалось корректным
|
||||||
|
всеми дверями продукта и не могло быть захешировано, а верхней границы у
|
||||||
|
копии не было вовсе;
|
||||||
|
- **всё, что контракт принял, обязано хешироваться.** Проверяются граничные
|
||||||
|
значения (36 x `я` = 72 байта, 18 x `😀` = 72 байта): именно здесь
|
||||||
|
расхождение с bcrypt и проявлялось.
|
||||||
|
|
||||||
|
## A9c1. Контракт учётных данных администратора (unit)
|
||||||
|
|
||||||
|
Контракт объявлен один раз в `apps/credential/admin.go` — в leaf-пакете, потому
|
||||||
|
что его зовут и `util.HashPassword`, и слой данных при создании первой учётной
|
||||||
|
записи, а `service` импортирует `util`.
|
||||||
|
|
||||||
|
`apps/credential/admin_test.go` — сам контракт:
|
||||||
|
|
||||||
|
- набор символов логина закреплён ФАКТИЧЕСКИМ множеством: сужение уронит тест, а
|
||||||
|
не вход администратора на живом сервере;
|
||||||
|
- границы пароля проверяются таблицей, и она включает случаи, где границы в
|
||||||
|
символах и в байтах расходятся:
|
||||||
|
|
||||||
|
| значение | символов | байт | результат |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 64 x `a` | 64 | 64 | принят |
|
||||||
|
| 36 x `я` | 36 | 72 | принят (граница bcrypt) |
|
||||||
|
| 37 x `я` | 37 | 74 | **отвергнут** |
|
||||||
|
| 18 x `😀` | 18 | 72 | принят (граница bcrypt) |
|
||||||
|
| 19 x `😀` | 19 | 76 | **отвергнут** |
|
||||||
|
| 64 x `я` | 64 | 128 | **отвергнут** |
|
||||||
|
|
||||||
|
последняя строка — исходный дефект: прежний тест требовал ПРИЁМА этого
|
||||||
|
значения, то есть закреплял как ожидаемое ровно то, на чём продукт ломался;
|
||||||
|
- пробел по краям — часть пароля, шесть пробелов являются корректным паролем;
|
||||||
|
- управляющие символы 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 символов
|
`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` |
|
| `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 ./...` |
|
| `run_admin_tests` | `go vet ./...`, `go test ./...` |
|
||||||
|
|
||||||
Приёмка проверяет:
|
Приёмка проверяет:
|
||||||
@@ -791,9 +927,13 @@ build tooling исполняется на build-машине и порождае
|
|||||||
- отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs
|
- отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs
|
||||||
(место для истории — `CHANGELOG.md`);
|
(место для истории — `CHANGELOG.md`);
|
||||||
- `metadata/package.env` содержит `tests_gate=true`;
|
- `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. Приёмка проверяет код, а не упоминания
|
## A12. Приёмка проверяет код, а не упоминания
|
||||||
|
|
||||||
@@ -815,4 +955,3 @@ Hysteria. Вторая не была замечена только потому,
|
|||||||
Отсутствие legacy-маршрута доказывает не `grep` по исходникам, а
|
Отсутствие legacy-маршрута доказывает не `grep` по исходникам, а
|
||||||
`TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера —
|
`TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера —
|
||||||
и существование этого теста само проверяется контрактом.
|
и существование этого теста само проверяется контрактом.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
## B. Target install tests
|
## B. Target install tests
|
||||||
|
|
||||||
### На чистом Debian 13 проверяем
|
### На чистом Debian 13 проверяем
|
||||||
|
|
||||||
1. пакет запускается без ручной сборки на сервере
|
1. пакет запускается без ручной сборки на сервере
|
||||||
2. Hysteria2 скачивается с official upstream
|
2. Hysteria2 скачивается с official upstream
|
||||||
3. bundled HY2XS admin раскладывается локально из пакета
|
3. bundled HY2XS admin раскладывается локально из пакета
|
||||||
@@ -52,6 +53,69 @@
|
|||||||
32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd
|
32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd
|
||||||
33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON
|
33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON
|
||||||
34. страница конфигурации показывает фактические значения `/etc/hysteria/config.yaml`, перечисляет секции вне production-профиля и не содержит паролей и токенов
|
34. страница конфигурации показывает фактические значения `/etc/hysteria/config.yaml`, перечисляет секции вне production-профиля и не содержит паролей и токенов
|
||||||
|
35. **оператор входит в панель**: `POST /api/auth/login` с bootstrap-учётными данными из `/etc/hy2xs/bootstrap-admin.secret` отвечает `code: 20000` и непустым `accessToken`. Заведомо неверные учётные данные (со СЛУЧАЙНЫМ паролем, а не с литералом) дают HTTP 200 с конвертом отказа: `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 конфига
|
## C1. Семантический smoke конфига
|
||||||
|
|
||||||
@@ -179,4 +243,3 @@ idle timeout проходил семантическую проверку. То
|
|||||||
|
|
||||||
Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами:
|
Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами:
|
||||||
граница определяется значением, а не именем ключа.
|
граница определяется значением, а не именем ключа.
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +1,130 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
import type { CommonOptions } from "../types/context";
|
import type { CommonOptions } from "../types/context";
|
||||||
|
import { InvalidUtf8Error, readText, writeTextAtomic } from "../lib/fs";
|
||||||
import { info, setOperationContext } from "../lib/log";
|
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 { redactEnv, redactLogText, redactYaml } from "../lib/redaction";
|
||||||
|
import {
|
||||||
|
cleanupDiagnosticsWorkspace,
|
||||||
|
createDiagnosticsWorkspace,
|
||||||
|
discardDiagnosticsArchive,
|
||||||
|
finalizeDiagnosticsArchive
|
||||||
|
} from "../lib/diagnosticsStorage";
|
||||||
|
|
||||||
function shellEscapeSingleQuotes(value: string): string {
|
const unavailable = (reason: string): string =>
|
||||||
return value.replaceAll("'", "'\\''");
|
`[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> {
|
export async function diagnosticsCollect(_options: CommonOptions): Promise<void> {
|
||||||
const opId = `diag-${Date.now().toString(36)}`;
|
const workspace = await createDiagnosticsWorkspace();
|
||||||
setOperationContext(opId);
|
setOperationContext(workspace.operationId);
|
||||||
|
let archiveComplete = false;
|
||||||
const outDir = `/var/log/hy2xs/diagnostics/${opId}`;
|
let collectionFailed = false;
|
||||||
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`}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const envRaw = await Bun.file(`${outDir}/hy2xs.env`).text();
|
await collectCommand(workspace.outDir, "systemd-hysteria.txt", ["systemctl", "status", "hysteria-server"]);
|
||||||
await Bun.write(`${outDir}/hy2xs.env`, redactEnv(envRaw));
|
await collectCommand(workspace.outDir, "systemd-admin.txt", ["systemctl", "status", "hy2xs-admin"]);
|
||||||
} catch {
|
await collectCommand(workspace.outDir, "journal-hysteria.log", ["journalctl", "-u", "hysteria-server", "-n", "300", "--no-pager"]);
|
||||||
// noop
|
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 {
|
await collectFile(workspace.outDir, "os-release.txt", "/etc/os-release", redactLogText);
|
||||||
const postInstallRaw = await Bun.file(`${outDir}/post-install.env`).text();
|
await collectFile(workspace.outDir, "post-install.env", "/etc/hysteria/post-install.env", redactEnv);
|
||||||
await Bun.write(`${outDir}/post-install.env`, redactEnv(postInstallRaw));
|
await collectFile(workspace.outDir, "hy2xs.env", "/etc/hy2xs/hy2xs.env", redactEnv);
|
||||||
} catch {
|
await collectFile(workspace.outDir, "hysteria-config.yaml", "/etc/hysteria/config.yaml", redactYaml);
|
||||||
// noop
|
await collectFile(workspace.outDir, "install-state.json", "/var/lib/hy2xs/install-state.json", redactLogText);
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// Archive path уже создан через open("wx") внутри доверенного root-only
|
||||||
const cfgRaw = await Bun.file(`${outDir}/hysteria-config.yaml`).text();
|
// namespace. argv-форма не запускает shell и сохраняет границы аргументов.
|
||||||
await Bun.write(`${outDir}/hysteria-config.yaml`, redactYaml(cfgRaw));
|
await runMutatingArgv(["tar", "-czf", workspace.archive, "-C", workspace.outDir, "."]);
|
||||||
} catch {
|
await finalizeDiagnosticsArchive(workspace.archive);
|
||||||
// noop
|
archiveComplete = true;
|
||||||
}
|
} catch (error) {
|
||||||
|
collectionFailed = true;
|
||||||
// Журналы и вывод systemctl — такой же артефакт, покидающий сервер, как env
|
throw error;
|
||||||
// и YAML. Раньше редактировались только последние два, а journal-admin.log
|
} finally {
|
||||||
// копировался как есть — и уносил machine token, который админка логировала
|
const cleanupErrors: unknown[] = [];
|
||||||
// в составе RequestURI.
|
|
||||||
for (const logFile of [
|
|
||||||
"journal-hysteria.log",
|
|
||||||
"journal-admin.log",
|
|
||||||
"systemd-hysteria.txt",
|
|
||||||
"systemd-admin.txt"
|
|
||||||
]) {
|
|
||||||
try {
|
try {
|
||||||
const raw = await Bun.file(`${outDir}/${logFile}`).text();
|
await cleanupDiagnosticsWorkspace(workspace);
|
||||||
await Bun.write(`${outDir}/${logFile}`, redactLogText(raw));
|
} catch (error) {
|
||||||
} catch {
|
cleanupErrors.push(error);
|
||||||
// noop
|
}
|
||||||
|
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: ${workspace.archive}`);
|
||||||
|
|
||||||
info(`diagnostics bundle collected: ${archive}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import type { InstallContext, InstallOptions } from "../types/context";
|
import type { InstallContext, InstallOptions } from "../types/context";
|
||||||
import { fileExists, readText, writeTextAtomic } from "../lib/fs";
|
import { fileExists, readText, writeTextAtomic } from "../lib/fs";
|
||||||
import { runMutatingVisible } from "../lib/process";
|
import { runMutatingVisible } from "../lib/process";
|
||||||
|
import { resetFailedUnit } from "../lib/systemd";
|
||||||
import { info, setOperationContext, step, stepDone } from "../lib/log";
|
import { info, setOperationContext, step, stepDone } from "../lib/log";
|
||||||
import { readPackageValue } from "../lib/packageMeta";
|
import { readPackageValue } from "../lib/packageMeta";
|
||||||
import { REPAIR_HINT, buildInstallStateRecord } from "../lib/installState";
|
import { REPAIR_HINT, buildInstallStateRecord } from "../lib/installState";
|
||||||
import { persistInstallState } from "../lib/installStateWriter";
|
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 { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
|
||||||
import {
|
import {
|
||||||
ORCHESTRATOR_INSTALL_DIR,
|
ORCHESTRATOR_INSTALL_DIR,
|
||||||
ORCHESTRATOR_INSTALL_PATH,
|
ORCHESTRATOR_INSTALL_PATH,
|
||||||
ORCHESTRATOR_SYMLINK_PATH,
|
ORCHESTRATOR_SYMLINK_PATH,
|
||||||
RUNTIME_PACKAGE_DIR
|
RUNTIME_PACKAGE_DIR,
|
||||||
} from "../config/profile";
|
} from "../config/profile";
|
||||||
import { preflight } from "../steps/preflight";
|
import { preflight } from "../steps/preflight";
|
||||||
import { bootstrapRuntime } from "../steps/bootstrap";
|
import { bootstrapRuntime } from "../steps/bootstrap";
|
||||||
@@ -27,7 +32,7 @@ import {
|
|||||||
cleanupFirewallRollback,
|
cleanupFirewallRollback,
|
||||||
disarmFirewallRollback,
|
disarmFirewallRollback,
|
||||||
operationKeyFor,
|
operationKeyFor,
|
||||||
rollbackFirewallNow
|
rollbackFirewallNow,
|
||||||
} from "../steps/firewall";
|
} from "../steps/firewall";
|
||||||
import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
|
import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
|
||||||
import { smoke } from "../steps/smoke";
|
import { smoke } from "../steps/smoke";
|
||||||
@@ -125,7 +130,7 @@ function newOwnership(): OperationOwnership {
|
|||||||
firewallTouched: false,
|
firewallTouched: false,
|
||||||
postInstallTouched: false,
|
postInstallTouched: false,
|
||||||
bootstrapSecretTouched: false,
|
bootstrapSecretTouched: false,
|
||||||
servicesStarted: false
|
servicesStarted: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,14 +151,14 @@ function installOwnedPaths(context: InstallContext): string[] {
|
|||||||
ORCHESTRATOR_INSTALL_DIR,
|
ORCHESTRATOR_INSTALL_DIR,
|
||||||
ORCHESTRATOR_INSTALL_PATH,
|
ORCHESTRATOR_INSTALL_PATH,
|
||||||
ORCHESTRATOR_SYMLINK_PATH,
|
ORCHESTRATOR_SYMLINK_PATH,
|
||||||
RUNTIME_PACKAGE_DIR
|
RUNTIME_PACKAGE_DIR,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeInstallState(
|
async function writeInstallState(
|
||||||
context: InstallContext,
|
context: InstallContext,
|
||||||
phase: InstallPhase,
|
phase: InstallPhase,
|
||||||
lastError: string
|
lastError: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const record = buildInstallStateRecord({
|
const record = buildInstallStateRecord({
|
||||||
productVersion: context.packageVersion,
|
productVersion: context.packageVersion,
|
||||||
@@ -168,7 +173,7 @@ async function writeInstallState(
|
|||||||
installed: phase === "installed",
|
installed: phase === "installed",
|
||||||
ownedPaths: installOwnedPaths(context),
|
ownedPaths: installOwnedPaths(context),
|
||||||
lastError,
|
lastError,
|
||||||
repairHint: phase === "installed" ? undefined : REPAIR_HINT
|
repairHint: phase === "installed" ? undefined : REPAIR_HINT,
|
||||||
});
|
});
|
||||||
|
|
||||||
await persistInstallState(record);
|
await persistInstallState(record);
|
||||||
@@ -178,7 +183,7 @@ async function advanceInstallState(
|
|||||||
context: InstallContext,
|
context: InstallContext,
|
||||||
ownership: OperationOwnership,
|
ownership: OperationOwnership,
|
||||||
phase: InstallPhase,
|
phase: InstallPhase,
|
||||||
lastError = ""
|
lastError = "",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Флаг взводится ПЕРЕД записью, а не после неё: см. комментарий к
|
// Флаг взводится ПЕРЕД записью, а не после неё: см. комментарий к
|
||||||
// stateTouched. Частично выполненная запись маркера — это уже изменение
|
// stateTouched. Частично выполненная запись маркера — это уже изменение
|
||||||
@@ -211,7 +216,7 @@ async function advanceInstallState(
|
|||||||
export function classifyFailure(
|
export function classifyFailure(
|
||||||
ownership: OperationOwnership,
|
ownership: OperationOwnership,
|
||||||
phase: InstallPhase,
|
phase: InstallPhase,
|
||||||
error?: unknown
|
error?: unknown,
|
||||||
): FailureKind {
|
): FailureKind {
|
||||||
if (error instanceof FirewallGuardFiredError) {
|
if (error instanceof FirewallGuardFiredError) {
|
||||||
return "firewall_guard_fired";
|
return "firewall_guard_fired";
|
||||||
@@ -256,10 +261,12 @@ export function classifyFailure(
|
|||||||
async function rollbackFailedInstall(
|
async function rollbackFailedInstall(
|
||||||
context: InstallContext,
|
context: InstallContext,
|
||||||
ownership: OperationOwnership,
|
ownership: OperationOwnership,
|
||||||
failureKind: FailureKind
|
failureKind: FailureKind,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (failureKind === "fatal_pre_apply") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +282,7 @@ async function rollbackFailedInstall(
|
|||||||
name: "firewall",
|
name: "firewall",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
await rollbackFirewallNow(context);
|
await rollbackFirewallNow(context);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,23 +302,31 @@ async function rollbackFailedInstall(
|
|||||||
name: "stop services",
|
name: "stop services",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin`;
|
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin`;
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "disable services",
|
name: "disable services",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin`;
|
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin`;
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "reset failed services",
|
name: "reset failed hysteria-server",
|
||||||
run: async () => {
|
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 {
|
} 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);
|
await runRollbackStages(stages);
|
||||||
@@ -319,11 +334,15 @@ async function rollbackFailedInstall(
|
|||||||
|
|
||||||
export async function install(options: InstallOptions): Promise<void> {
|
export async function install(options: InstallOptions): Promise<void> {
|
||||||
setOperationContext(`install-${Date.now().toString(36)}`);
|
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) {
|
if (options.sourceConfigPath && !hasSourceConfig) {
|
||||||
throw new Error(`config source not found: ${options.sourceConfigPath}`);
|
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 sourceConfigRaw = await readText(sourceConfigPath);
|
||||||
const config = parseRuntimeEnv(sourceConfigRaw);
|
const config = parseRuntimeEnv(sourceConfigRaw);
|
||||||
|
|
||||||
@@ -331,18 +350,48 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
mode: "install",
|
mode: "install",
|
||||||
options,
|
options,
|
||||||
config,
|
config,
|
||||||
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
|
packageVersion: await readPackageValue(
|
||||||
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
|
options.packageDir,
|
||||||
|
"package.version",
|
||||||
|
"unknown",
|
||||||
|
),
|
||||||
|
packageBuildId: await readPackageValue(
|
||||||
|
options.packageDir,
|
||||||
|
"package.build_id",
|
||||||
|
"unknown",
|
||||||
|
),
|
||||||
installDate: new Date().toISOString(),
|
installDate: new Date().toISOString(),
|
||||||
hysteriaVersion: "unknown",
|
hysteriaVersion: "unknown",
|
||||||
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown"),
|
hysteriaResolution: await readPackageValue(
|
||||||
hysteriaTargetVersion: await readPackageValue(options.packageDir, "hysteria.version", ""),
|
options.packageDir,
|
||||||
hysteriaArtifactUrl: await readPackageValue(options.packageDir, "hysteria.url", ""),
|
"hysteria.resolution",
|
||||||
hysteriaArtifactSha256: await readPackageValue(options.packageDir, "hysteria.sha256", "")
|
"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) {
|
if (
|
||||||
throw new Error("missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256");
|
!context.hysteriaTargetVersion ||
|
||||||
|
!context.hysteriaArtifactUrl ||
|
||||||
|
!context.hysteriaArtifactSha256
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownership = newOwnership();
|
const ownership = newOwnership();
|
||||||
@@ -356,7 +405,10 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
// Всё, что дальше, уже создаёт наши собственные пути, и повторная проверка
|
// Всё, что дальше, уже создаёт наши собственные пути, и повторная проверка
|
||||||
// опознала бы их как чужую установку.
|
// опознала бы их как чужую установку.
|
||||||
step("preflight");
|
step("preflight");
|
||||||
await preflight(context, { requireCapabilities: false, checkCleanHost: true });
|
await preflight(context, {
|
||||||
|
requireCapabilities: false,
|
||||||
|
checkCleanHost: true,
|
||||||
|
});
|
||||||
stepDone("preflight");
|
stepDone("preflight");
|
||||||
|
|
||||||
await advanceInstallState(context, ownership, "preflight_ok");
|
await advanceInstallState(context, ownership, "preflight_ok");
|
||||||
@@ -372,7 +424,10 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
await installDeps(context);
|
await installDeps(context);
|
||||||
stepDone("system dependencies");
|
stepDone("system dependencies");
|
||||||
step("preflight capabilities");
|
step("preflight capabilities");
|
||||||
await preflight(context, { requireCapabilities: true, checkCleanHost: false });
|
await preflight(context, {
|
||||||
|
requireCapabilities: true,
|
||||||
|
checkCleanHost: false,
|
||||||
|
});
|
||||||
stepDone("preflight capabilities");
|
stepDone("preflight capabilities");
|
||||||
await advanceInstallState(context, ownership, "deps_ok");
|
await advanceInstallState(context, ownership, "deps_ok");
|
||||||
phase = "deps_ok";
|
phase = "deps_ok";
|
||||||
@@ -387,7 +442,7 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), {
|
await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
owner: "root",
|
owner: "root",
|
||||||
group: "root"
|
group: "root",
|
||||||
});
|
});
|
||||||
stepDone("write runtime env");
|
stepDone("write runtime env");
|
||||||
await advanceInstallState(context, ownership, "runtime_env_written");
|
await advanceInstallState(context, ownership, "runtime_env_written");
|
||||||
@@ -468,8 +523,13 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await cleanupFirewallRollback(context);
|
await cleanupFirewallRollback(context);
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
const cleanupMessage =
|
||||||
info(`firewall rollback data cleanup failed after a successful install: ${cleanupMessage}`);
|
cleanupError instanceof Error
|
||||||
|
? cleanupError.message
|
||||||
|
: String(cleanupError);
|
||||||
|
info(
|
||||||
|
`firewall rollback data cleanup failed after a successful install: ${cleanupMessage}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
@@ -498,7 +558,12 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
: "failed";
|
: "failed";
|
||||||
|
|
||||||
await persistFailureState(() =>
|
await persistFailureState(() =>
|
||||||
advanceInstallState(context, ownership, failurePhase, `${failureKind}: ${message}`)
|
advanceInstallState(
|
||||||
|
context,
|
||||||
|
ownership,
|
||||||
|
failurePhase,
|
||||||
|
`${failureKind}: ${message}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Диагностика — best effort, откат — обязателен.
|
// Диагностика — best effort, откат — обязателен.
|
||||||
@@ -512,8 +577,12 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
await diagnosticsCollect(options);
|
await diagnosticsCollect(options);
|
||||||
} catch (diagnosticsError) {
|
} catch (diagnosticsError) {
|
||||||
const diagnosticsMessage =
|
const diagnosticsMessage =
|
||||||
diagnosticsError instanceof Error ? diagnosticsError.message : String(diagnosticsError);
|
diagnosticsError instanceof Error
|
||||||
info(`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`);
|
? diagnosticsError.message
|
||||||
|
: String(diagnosticsError);
|
||||||
|
info(
|
||||||
|
`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await rollbackFailedInstall(context, ownership, failureKind);
|
await rollbackFailedInstall(context, ownership, failureKind);
|
||||||
|
|||||||
+126
-59
@@ -1,9 +1,12 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import type { FirewallMode, PublicEndpointPolicy, RuntimeConfig, TlsMode } from "../types/context";
|
import type { FirewallMode, PublicEndpointPolicy, RuntimeConfig, TlsMode } from "../types/context";
|
||||||
|
import { assertEnvTransportable, parseEnvFile, renderEnvFile } from "../lib/envFile";
|
||||||
import {
|
import {
|
||||||
GECKO_DEFAULT_MAX_PACKET_SIZE,
|
GECKO_DEFAULT_MAX_PACKET_SIZE,
|
||||||
GECKO_DEFAULT_MIN_PACKET_SIZE,
|
GECKO_DEFAULT_MIN_PACKET_SIZE,
|
||||||
HY2XS_CONFIG_SCHEMA_VERSION,
|
HY2XS_CONFIG_SCHEMA_VERSION,
|
||||||
|
assertValidAdminPassword,
|
||||||
|
assertValidAdminUsername,
|
||||||
normalizeHysteriaObfsType,
|
normalizeHysteriaObfsType,
|
||||||
validateGeckoPacketSizes
|
validateGeckoPacketSizes
|
||||||
} from "./profile";
|
} from "./profile";
|
||||||
@@ -21,22 +24,17 @@ function valueOrGenerate(value: string | undefined): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разбор env-файла делегирован lib/envFile.
|
||||||
|
*
|
||||||
|
* Здесь стоял построчный разбор с `value.trim()`, и он молча срезал пробелы по
|
||||||
|
* краям КАЖДОГО значения. Для пароля администратора это прямое нарушение
|
||||||
|
* контракта — продукт объявляет краевой пробел частью пароля, — а для
|
||||||
|
* значений с кавычками разбор вдобавок расходился с systemd, который читает
|
||||||
|
* этот же файл как EnvironmentFile.
|
||||||
|
*/
|
||||||
function parseEnv(content: string): EnvMap {
|
function parseEnv(content: string): EnvMap {
|
||||||
const result: EnvMap = {};
|
return parseEnvFile(content);
|
||||||
for (const rawLine of content.split(/\r?\n/)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line || line.startsWith("#")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const separator = line.indexOf("=");
|
|
||||||
if (separator < 1) {
|
|
||||||
throw new Error(`invalid env line: ${rawLine}`);
|
|
||||||
}
|
|
||||||
const key = line.slice(0, separator).trim();
|
|
||||||
const value = line.slice(separator + 1).trim();
|
|
||||||
result[key] = value;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePort(name: string, raw: string, fallback: number): number {
|
function parsePort(name: string, raw: string, fallback: number): number {
|
||||||
@@ -223,6 +221,11 @@ function normalizeSafeAbsolutePath(name: string, value: string, options?: { disa
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseRuntimeEnv(content: string): RuntimeConfig {
|
export function parseRuntimeEnv(content: string): RuntimeConfig {
|
||||||
|
// Проверяется ИСХОДНЫЙ текст до разбора. Иначе запрещённый символ в
|
||||||
|
// комментарии, неизвестном ключе или перед первым ключом мог бы исчезнуть
|
||||||
|
// при нормализации, и preflight объявил бы повреждённый EnvironmentFile
|
||||||
|
// корректным. Нормализованные значения проверяются повторно ниже.
|
||||||
|
assertEnvTransportable("runtime EnvironmentFile", content);
|
||||||
const env = parseEnv(content);
|
const env = parseEnv(content);
|
||||||
|
|
||||||
const uiPort = parsePort("HY2XS_UI_PORT", env.HY2XS_UI_PORT, 8080);
|
const uiPort = parsePort("HY2XS_UI_PORT", env.HY2XS_UI_PORT, 8080);
|
||||||
@@ -250,8 +253,23 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
|
|||||||
uiBindHost,
|
uiBindHost,
|
||||||
uiPublicAccess: parseBool("HY2XS_UI_PUBLIC_ACCESS", env.HY2XS_UI_PUBLIC_ACCESS, false),
|
uiPublicAccess: parseBool("HY2XS_UI_PUBLIC_ACCESS", env.HY2XS_UI_PUBLIC_ACCESS, false),
|
||||||
uiPort,
|
uiPort,
|
||||||
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"),
|
// Умолчание — `hy2xsadmin`, и оно совпадает с package/config/hy2xs.env и с
|
||||||
adminInitialPassword: valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD),
|
// запасным значением в 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)),
|
adminConPass: requireValue("HY2XS_ADMIN_CON_PASS", valueOrGenerate(env.HY2XS_ADMIN_CON_PASS)),
|
||||||
forcePasswordChange: parseBool("HY2XS_FORCE_PASSWORD_CHANGE", env.HY2XS_FORCE_PASSWORD_CHANGE, false),
|
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),
|
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);
|
validateRuntimeConfig(config);
|
||||||
|
// Проверка транспорта идёт ЗДЕСЬ, а не при записи файла.
|
||||||
|
//
|
||||||
|
// Раньше она жила только внутри renderRuntimeEnv, то есть срабатывала на шаге
|
||||||
|
// «write runtime env» — уже ПОСЛЕ bootstrap оркестратора, установки пакетов и
|
||||||
|
// раскладки файловой системы. Детерминированно известная ошибка конфигурации
|
||||||
|
// роняла операцию, оставив за собой изменённый хост, а read-only
|
||||||
|
// `preflight-install` про неё говорил PASS: он вызывает parseRuntimeEnv и не
|
||||||
|
// рендерит ничего.
|
||||||
|
//
|
||||||
|
// Это противоречит контракту PHASE 0: всё, что про конфигурацию известно
|
||||||
|
// детерминированно, обязано быть отвергнуто ДО первой необратимой мутации.
|
||||||
|
validateRuntimeEnvTransport(config);
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,47 +409,84 @@ export function validateRuntimeConfig(config: RuntimeConfig): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderRuntimeEnv(config: RuntimeConfig): string {
|
/**
|
||||||
const lines = [
|
* Пары `KEY=VALUE`, которые уезжают в /etc/hy2xs/hy2xs.env.
|
||||||
"# HY2XS runtime config (editable)",
|
*
|
||||||
`HY2XS_CONFIG_SCHEMA_VERSION=${config.configSchemaVersion}`,
|
* Вынесены из renderRuntimeEnv, потому что у списка ДВА потребителя: запись
|
||||||
`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}`,
|
export function runtimeEnvEntries(config: RuntimeConfig): [string, string][] {
|
||||||
`HY2XS_PUBLIC_PORT=${config.publicPort}`,
|
return [
|
||||||
`HY2XS_SSH_PORT=${config.sshPort}`,
|
["HY2XS_CONFIG_SCHEMA_VERSION", String(config.configSchemaVersion)],
|
||||||
`HY2XS_FIREWALL_MODE=${config.firewallMode}`,
|
["HY2XS_IPV6_ENABLED", String(config.ipv6Enabled)],
|
||||||
`HY2XS_FIREWALL_STAGED_APPLY=${config.firewallStagedApply}`,
|
["HY2XS_DOMAIN", config.domain],
|
||||||
`HY2XS_UI_BIND_HOST=${config.uiBindHost}`,
|
["HY2XS_DNS_AAAA_POLICY", config.dnsAaaaPolicy],
|
||||||
`HY2XS_UI_PUBLIC_ACCESS=${config.uiPublicAccess}`,
|
["HY2XS_PUBLIC_ENDPOINT_POLICY", config.publicEndpointPolicy],
|
||||||
`HY2XS_UI_PORT=${config.uiPort}`,
|
["HY2XS_PUBLIC_HOST", config.publicHost],
|
||||||
`HY2XS_ADMIN_USER=${config.adminUser}`,
|
["HY2XS_PUBLIC_PORT", String(config.publicPort)],
|
||||||
`HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`,
|
["HY2XS_SSH_PORT", String(config.sshPort)],
|
||||||
`HY2XS_ADMIN_CON_PASS=${config.adminConPass}`,
|
["HY2XS_FIREWALL_MODE", config.firewallMode],
|
||||||
`HY2XS_FORCE_PASSWORD_CHANGE=${config.forcePasswordChange}`,
|
["HY2XS_FIREWALL_STAGED_APPLY", String(config.firewallStagedApply)],
|
||||||
`HY2XS_ALLOW_SELF_SIGNED_DEV=${config.allowSelfSignedDev}`,
|
["HY2XS_UI_BIND_HOST", config.uiBindHost],
|
||||||
`HY2XS_TLS_MODE=${config.tlsMode}`,
|
["HY2XS_UI_PUBLIC_ACCESS", String(config.uiPublicAccess)],
|
||||||
`HY2XS_ACME_TYPE=${config.acmeType}`,
|
["HY2XS_UI_PORT", String(config.uiPort)],
|
||||||
`HY2XS_ACME_EMAIL=${config.acmeEmail}`,
|
["HY2XS_ADMIN_USER", config.adminUser],
|
||||||
`HY2XS_TLS_CERT_PATH=${config.tlsCertPath}`,
|
["HY2XS_ADMIN_INITIAL_PASSWORD", config.adminInitialPassword],
|
||||||
`HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`,
|
["HY2XS_ADMIN_CON_PASS", config.adminConPass],
|
||||||
`HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`,
|
["HY2XS_FORCE_PASSWORD_CHANGE", String(config.forcePasswordChange)],
|
||||||
`HY2XS_HYSTERIA_PORT=${config.hysteriaPort}`,
|
["HY2XS_ALLOW_SELF_SIGNED_DEV", String(config.allowSelfSignedDev)],
|
||||||
`HY2XS_HYSTERIA_AUTH_MODE=${config.hysteriaAuthMode}`,
|
["HY2XS_TLS_MODE", config.tlsMode],
|
||||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=${config.hysteriaTrafficStatsHost}`,
|
["HY2XS_ACME_TYPE", config.acmeType],
|
||||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`,
|
["HY2XS_ACME_EMAIL", config.acmeEmail],
|
||||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${config.hysteriaTrafficStatsSecret}`,
|
["HY2XS_TLS_CERT_PATH", config.tlsCertPath],
|
||||||
`HY2XS_HYSTERIA_OBFS_TYPE=${config.hysteriaObfsType}`,
|
["HY2XS_TLS_KEY_PATH", config.tlsKeyPath],
|
||||||
`HY2XS_HYSTERIA_OBFS_PASSWORD=${config.hysteriaObfsPassword}`,
|
["HY2XS_HYSTERIA_BIND_HOST", config.hysteriaBindHost],
|
||||||
`HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`,
|
["HY2XS_HYSTERIA_PORT", String(config.hysteriaPort)],
|
||||||
`HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`,
|
["HY2XS_HYSTERIA_AUTH_MODE", config.hysteriaAuthMode],
|
||||||
`HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=${config.hysteriaIgnoreClientBandwidth}`,
|
["HY2XS_HYSTERIA_TRAFFIC_STATS_HOST", config.hysteriaTrafficStatsHost],
|
||||||
`HY2XS_HYSTERIA_CONFIG_PATH=${config.hysteriaConfigPath}`,
|
["HY2XS_HYSTERIA_TRAFFIC_STATS_PORT", String(config.hysteriaTrafficStatsPort)],
|
||||||
`HY2XS_INSTALL_DIR=${config.installDir}`,
|
["HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", config.hysteriaTrafficStatsSecret],
|
||||||
`HY2XS_DATA_DIR=${config.dataDir}`,
|
["HY2XS_HYSTERIA_OBFS_TYPE", config.hysteriaObfsType],
|
||||||
`HY2XS_LOG_DIR=${config.logDir}`
|
["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))}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HysteriaObfsType, RuntimeConfig } from "../types/context";
|
import type { HysteriaObfsType, RuntimeConfig } from "../types/context";
|
||||||
|
import { isEnvTransportable } from "../lib/envFile";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HY2XS production profile: единственное место, где определены значения
|
* HY2XS production profile: единственное место, где определены значения
|
||||||
@@ -41,6 +42,151 @@ export const HY2XS_TARGET_ARCH = "amd64";
|
|||||||
export const ADMIN_API_BASE = "/api";
|
export const ADMIN_API_BASE = "/api";
|
||||||
export const HYSTERIA_MACHINE_AUTH_PATH = "/internal/hysteria/auth";
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Где оркестратор живёт на установленном хосте.
|
* Где оркестратор живёт на установленном хосте.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -27,8 +27,37 @@ export async function dirExists(path: string): Promise<boolean> {
|
|||||||
return st?.isDirectory() ?? false;
|
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> {
|
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> {
|
export async function writeText(path: string, data: string, mode?: number): Promise<void> {
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
import { readText } from "./fs";
|
import { InvalidUtf8Error, readText } from "./fs";
|
||||||
import { runReadOnly } from "./process";
|
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> {
|
export async function readPackageValue(packageDir: string, file: string, fallback: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return (await readText(`${packageDir}/metadata/${file}`)).trim();
|
return (await readText(`${packageDir}/metadata/${file}`)).trim();
|
||||||
} catch {
|
} catch (error) {
|
||||||
return fallback;
|
// Только отсутствие необязательного файла означает fallback. Ошибки прав,
|
||||||
|
// ввода-вывода, неверного типа и повреждённый UTF-8 описывают битый либо
|
||||||
|
// недоступный пакет и не имеют права маскироваться запасным значением.
|
||||||
|
if (isMissingPackageMetadataError(error)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertMutationAllowed } from "./guard";
|
import { assertMutationAllowed } from "./guard";
|
||||||
|
import { decodeUtf8Lossy } from "./fs";
|
||||||
import { info } from "./log";
|
import { info } from "./log";
|
||||||
|
|
||||||
function shellQuote(value: unknown): string {
|
function shellQuote(value: unknown): string {
|
||||||
@@ -190,8 +191,77 @@ export type MutationResult = {
|
|||||||
exitCode: number;
|
exitCode: number;
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Мутация, которая ВОЗВРАЩАЕТ код возврата вместо броска.
|
* Мутация, которая ВОЗВРАЩАЕТ код возврата вместо броска.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -12,6 +12,17 @@
|
|||||||
|
|
||||||
export const REDACTED = "<redacted>";
|
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 и
|
* Маркеры имён полей. Список покрывает известные секреты HY2XS/Hysteria и
|
||||||
* типовые имена, под которыми секрет может появиться в будущем upstream-поле.
|
* типовые имена, под которыми секрет может появиться в будущем upstream-поле.
|
||||||
@@ -36,11 +47,14 @@ export const SECRET_KEY_MARKERS: readonly string[] = [
|
|||||||
"authorization",
|
"authorization",
|
||||||
"cookie",
|
"cookie",
|
||||||
"bearer",
|
"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 {
|
export function isSecretKey(key: string): boolean {
|
||||||
const lowered = key.toLowerCase();
|
const lowered = key.toLowerCase();
|
||||||
@@ -92,7 +106,9 @@ function redactValueDeep(value: unknown): unknown {
|
|||||||
}
|
}
|
||||||
if (value && typeof value === "object") {
|
if (value && typeof value === "object") {
|
||||||
const out: Record<string, unknown> = {};
|
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);
|
out[key] = redactValueDeep(child);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -138,8 +154,15 @@ function redactNode(value: unknown, path: readonly string[]): unknown {
|
|||||||
|
|
||||||
export function redactKeyValueSecrets(content: string): string {
|
export function redactKeyValueSecrets(content: string): string {
|
||||||
return content.replace(
|
return content.replace(
|
||||||
/^([A-Z0-9_]*(PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|TOKEN|CREDENTIAL|APIKEY|AUTHORIZATION|COOKIE|BEARER|SIGNATURE)[A-Z0-9_]*=).*$/gm,
|
/^([A-Z0-9_]+)=(.*?)(\r?)$/gm,
|
||||||
`$1${REDACTED}`
|
(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,
|
LOG_SECRET_ASSIGNMENT_PATTERN,
|
||||||
(match, key: string, separator: string, rawValue: string) => {
|
(match, key: string, separator: string, rawValue: string) => {
|
||||||
const [value, trailing] = splitTrailingPunctuation(rawValue);
|
const [value, trailing] = splitTrailingPunctuation(rawValue);
|
||||||
if (value === REDACTED || value.toLowerCase() === ENCODED_REDACTED.toLowerCase()) {
|
if (
|
||||||
|
value === REDACTED ||
|
||||||
|
value.toLowerCase() === ENCODED_REDACTED.toLowerCase()
|
||||||
|
) {
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
const rendered = separator === ":" ? ": " : "=";
|
const rendered = separator === ":" ? ": " : "=";
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { RuntimeContext } from "../types/context";
|
import type { RuntimeContext } from "../types/context";
|
||||||
import { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs";
|
import { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs";
|
||||||
|
import { renderEnvFile } from "../lib/envFile";
|
||||||
import { runMutatingVisible } from "../lib/process";
|
import { runMutatingVisible } from "../lib/process";
|
||||||
import {
|
import {
|
||||||
BBR_PROFILE,
|
BBR_PROFILE,
|
||||||
@@ -56,14 +57,23 @@ export async function writePostInstallEnv(context: RuntimeContext): Promise<void
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пишет /etc/hy2xs/bootstrap-admin.secret.
|
||||||
|
*
|
||||||
|
* Тот же формат и тот же кодировщик, что и у hy2xs.env, и по той же причине: в
|
||||||
|
* файле лежит пароль администратора, набор символов которого продукт объявил
|
||||||
|
* неограниченным. Пока строки собирались интерполяцией, пароль с пробелом по
|
||||||
|
* краям, кавычкой или обратным слешем нельзя было прочитать обратно — а читает
|
||||||
|
* этот файл smoke, чтобы ДОКАЗАТЬ, что в панель можно войти.
|
||||||
|
*/
|
||||||
export async function writeBootstrapAdminSecret(context: RuntimeContext): Promise<void> {
|
export async function writeBootstrapAdminSecret(context: RuntimeContext): Promise<void> {
|
||||||
await writeTextAtomic(
|
await writeTextAtomic(
|
||||||
context.config.bootstrapAdminSecretPath,
|
context.config.bootstrapAdminSecretPath,
|
||||||
[
|
renderEnvFile([
|
||||||
`ADMIN_USER=${context.config.adminUser}`,
|
["ADMIN_USER", context.config.adminUser],
|
||||||
`ADMIN_INITIAL_PASSWORD=${context.config.adminInitialPassword}`,
|
["ADMIN_INITIAL_PASSWORD", context.config.adminInitialPassword],
|
||||||
`ADMIN_CON_PASS=${context.config.adminConPass}`
|
["ADMIN_CON_PASS", context.config.adminConPass]
|
||||||
].join("\n") + "\n",
|
]),
|
||||||
{
|
{
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
owner: "root",
|
owner: "root",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { InstallContext } from "../types/context";
|
import type { InstallContext } from "../types/context";
|
||||||
import { runMutating } from "../lib/process";
|
import { runMutating } from "../lib/process";
|
||||||
|
import { ensureDiagnosticsStorageRoot } from "../lib/diagnosticsStorage";
|
||||||
|
|
||||||
async function userExists(user: string): Promise<boolean> {
|
async function userExists(user: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -58,5 +59,6 @@ export async function prepareFilesystem(context: InstallContext): Promise<void>
|
|||||||
await ensureDir(context.config.installDir, "0755", "root:root");
|
await ensureDir(context.config.installDir, "0755", "root:root");
|
||||||
await ensureDir("/usr/local/lib/hy2xs", "0755", "root:root");
|
await ensureDir("/usr/local/lib/hy2xs", "0755", "root:root");
|
||||||
await ensureDir("/etc/nftables.d", "0755", "root:root");
|
await ensureDir("/etc/nftables.d", "0755", "root:root");
|
||||||
|
await ensureDiagnosticsStorageRoot();
|
||||||
await runMutating`chmod -R go-w ${context.config.installDir}`;
|
await runMutating`chmod -R go-w ${context.config.installDir}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+409
-32
@@ -1,8 +1,18 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import type { RuntimeContext } from "../types/context";
|
import type { RuntimeContext } from "../types/context";
|
||||||
import { info } from "../lib/log";
|
import { info } from "../lib/log";
|
||||||
import { readText } from "../lib/fs";
|
import { readText } from "../lib/fs";
|
||||||
import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process";
|
import { parseEnvFile } from "../lib/envFile";
|
||||||
import { HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile";
|
import {
|
||||||
|
runReadOnly,
|
||||||
|
runReadOnlySecret,
|
||||||
|
runMutatingVisible,
|
||||||
|
} from "../lib/process";
|
||||||
|
import {
|
||||||
|
ADMIN_LOGIN_PATH,
|
||||||
|
HYSTERIA_MACHINE_AUTH_PATH,
|
||||||
|
hysteriaMachineAuthUrl,
|
||||||
|
} from "../config/profile";
|
||||||
import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
|
import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
|
||||||
import { assertEffectiveFirewallIsOurs } from "./firewall";
|
import { assertEffectiveFirewallIsOurs } from "./firewall";
|
||||||
|
|
||||||
@@ -80,7 +90,8 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
|||||||
1000,
|
1000,
|
||||||
async () => runReadOnlySecret`systemctl is-active hysteria-server || true`,
|
async () => runReadOnlySecret`systemctl is-active hysteria-server || true`,
|
||||||
(state) => state.trim() === "active",
|
(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(
|
await retry(
|
||||||
"systemd hy2xs-admin active",
|
"systemd hy2xs-admin active",
|
||||||
@@ -88,7 +99,8 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
|||||||
1000,
|
1000,
|
||||||
async () => runReadOnlySecret`systemctl is-active hy2xs-admin || true`,
|
async () => runReadOnlySecret`systemctl is-active hy2xs-admin || true`,
|
||||||
(state) => state.trim() === "active",
|
(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) {
|
if (context.options.skipSmoke) {
|
||||||
@@ -101,24 +113,38 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
|||||||
15,
|
15,
|
||||||
1000,
|
1000,
|
||||||
async () => runReadOnlySecret`ss -H -ltn`,
|
async () => runReadOnlySecret`ss -H -ltn`,
|
||||||
(lines) => hasTcpListener(lines, context.config.uiBindHost, context.config.uiPort),
|
(lines) =>
|
||||||
(lines, error) => new Error(`ui listener not ready on ${context.config.uiBindHost}:${context.config.uiPort}: ${lines ?? String(error)}`),
|
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(
|
await retry(
|
||||||
"hysteria udp listener readiness",
|
"hysteria udp listener readiness",
|
||||||
15,
|
15,
|
||||||
1000,
|
1000,
|
||||||
async () => runReadOnlySecret`ss -H -lun`,
|
async () => runReadOnlySecret`ss -H -lun`,
|
||||||
(lines) => hasUdpListener(lines, context.config.hysteriaBindHost, context.config.hysteriaPort),
|
(lines) =>
|
||||||
(lines, error) => new Error(`hysteria udp listener not ready on 0.0.0.0:${context.config.hysteriaPort}: ${lines ?? String(error)}`),
|
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(
|
await retry(
|
||||||
"admin healthz readiness",
|
"admin healthz readiness",
|
||||||
15,
|
15,
|
||||||
1000,
|
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) => /"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 раннерами.
|
// Всё, что ниже, — НАБЛЮДЕНИЕ, и оно выполняется read-only раннерами.
|
||||||
@@ -129,7 +155,9 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
|||||||
// не меняет диагностируемую систему» невозможно было включить guard'ом — он
|
// не меняет диагностируемую систему» невозможно было включить 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/hysteria/config.yaml`;
|
||||||
await runReadOnly`test -s /etc/hy2xs/hy2xs.env`;
|
await runReadOnly`test -s /etc/hy2xs/hy2xs.env`;
|
||||||
await runReadOnly`test -s /etc/hysteria/post-install.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") {
|
if (context.config.uiBindHost === "127.0.0.1") {
|
||||||
const tcp = await runReadOnlySecret`ss -H -ltn`;
|
const tcp = await runReadOnlySecret`ss -H -ltn`;
|
||||||
if (hasTcpListener(tcp, "0.0.0.0", context.config.uiPort)) {
|
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} '`;
|
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 machineAuthUrlNoToken = `http://127.0.0.1:${context.config.uiPort}${HYSTERIA_MACHINE_AUTH_PATH}`;
|
||||||
const machineAuthUrl = hysteriaMachineAuthUrl(
|
const machineAuthUrl = hysteriaMachineAuthUrl(
|
||||||
context.config.uiPort,
|
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") {
|
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(
|
const invalidAuthResponse = await retry(
|
||||||
"auth invalid credentials",
|
"auth invalid credentials",
|
||||||
5,
|
5,
|
||||||
1000,
|
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) => /"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) {
|
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)) {
|
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") {
|
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") {
|
if (context.mode === "install") {
|
||||||
const adminConPass = (await runReadOnlySecret`grep '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-`).trim();
|
const adminConPass =
|
||||||
|
(await readBootstrapAdminSecret(context)).ADMIN_CON_PASS ?? "";
|
||||||
if (!adminConPass) {
|
if (!adminConPass) {
|
||||||
throw new Error("admin connection password is empty in bootstrap secret file");
|
throw new Error(
|
||||||
|
"admin connection password is empty in bootstrap secret file",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await retry(
|
await retry(
|
||||||
"auth valid credentials",
|
"auth valid credentials",
|
||||||
10,
|
10,
|
||||||
1000,
|
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) => /"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(
|
await retry(
|
||||||
"trafficStats valid secret",
|
"trafficStats valid secret",
|
||||||
10,
|
10,
|
||||||
1000,
|
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) => /^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)) {
|
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` только разбирает файл и правил не применяет — это проверка
|
// `nft -c` только разбирает файл и правил не применяет — это проверка
|
||||||
@@ -233,23 +292,341 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
|||||||
// синтаксически валидный — ruleset, проверка проходила зелёной, и операция
|
// синтаксически валидный — ruleset, проверка проходила зелёной, и операция
|
||||||
// объявляла успешной установку, работающую на firewall, который она же
|
// объявляла успешной установку, работающую на 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);
|
await assertEffectiveFirewallIsOurs(context);
|
||||||
|
|
||||||
// Семантическая проверка установленного конфига: разбираем YAML и сверяем
|
// Семантическая проверка установленного конфига: разбираем YAML и сверяем
|
||||||
// с production-профилем, а не ищем подстроки.
|
// с production-профилем, а не ищем подстроки.
|
||||||
info("verifying effective Hysteria config against HY2XS production profile");
|
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);
|
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
|
* Установленный бинарник обязан совпадать с версией, замороженной в metadata
|
||||||
* пакета. На reconfigure metadata может относиться к другому пакету, поэтому
|
* пакета. На reconfigure metadata может относиться к другому пакету, поэтому
|
||||||
* расхождение там — предупреждение, а не отказ.
|
* расхождение там — предупреждение, а не отказ.
|
||||||
*/
|
*/
|
||||||
async function assertEffectiveHysteriaVersion(context: RuntimeContext): Promise<void> {
|
async function assertEffectiveHysteriaVersion(
|
||||||
|
context: RuntimeContext,
|
||||||
|
): Promise<void> {
|
||||||
const packagedVersion = context.hysteriaVersion.trim();
|
const packagedVersion = context.hysteriaVersion.trim();
|
||||||
if (!packagedVersion || packagedVersion === "unknown") {
|
if (!packagedVersion || packagedVersion === "unknown") {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
redactLogText,
|
redactLogText,
|
||||||
redactYaml,
|
redactYaml,
|
||||||
redactYamlFallback,
|
redactYamlFallback,
|
||||||
sanitizeUrlValue
|
sanitizeUrlValue,
|
||||||
} from "../src/lib/redaction";
|
} from "../src/lib/redaction";
|
||||||
|
|
||||||
const MACHINE_TOKEN = "hy2xs-machine-token-must-not-leak";
|
const MACHINE_TOKEN = "hy2xs-machine-token-must-not-leak";
|
||||||
@@ -58,7 +58,10 @@ describe("редакция серверного конфига", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("результат остаётся валидным YAML", () => {
|
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");
|
expect(parsed.listen).toBe("0.0.0.0:443");
|
||||||
const auth = parsed.auth as Record<string, unknown>;
|
const auth = parsed.auth as Record<string, unknown>;
|
||||||
const http = auth.http as Record<string, unknown>;
|
const http = auth.http as Record<string, unknown>;
|
||||||
@@ -69,7 +72,10 @@ describe("редакция серверного конфига", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("несекретные поля сохраняются: диагностика должна оставаться полезной", () => {
|
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>;
|
const acme = parsed.acme as Record<string, unknown>;
|
||||||
expect(acme.email).toBe("admin@example.com");
|
expect(acme.email).toBe("admin@example.com");
|
||||||
expect(acme.ca).toBe("letsencrypt");
|
expect(acme.ca).toBe("letsencrypt");
|
||||||
@@ -92,7 +98,10 @@ describe("редакция серверного конфига", () => {
|
|||||||
test("вложенное дерево под секретным ключом вырезается целиком", () => {
|
test("вложенное дерево под секретным ключом вырезается целиком", () => {
|
||||||
const yaml = `acme:\n dns:\n name: cloudflare\n config:\n api_token: leak-me\n zone: example.com\n`;
|
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 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>;
|
const cfg = dns.config as Record<string, unknown>;
|
||||||
expect(cfg.api_token).toBe(REDACTED);
|
expect(cfg.api_token).toBe(REDACTED);
|
||||||
// acme.dns.config — карта, где секретны все значения, а не только ключ.
|
// acme.dns.config — карта, где секретны все значения, а не только ключ.
|
||||||
@@ -158,7 +167,7 @@ describe("редакция env-артефактов", () => {
|
|||||||
"HY2_AUTH_MODE=http",
|
"HY2_AUTH_MODE=http",
|
||||||
`HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}`,
|
`HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}`,
|
||||||
`HY2_OBFS_PASSWORD=${OBFS_PASSWORD}`,
|
`HY2_OBFS_PASSWORD=${OBFS_PASSWORD}`,
|
||||||
"HY2_PORT=443"
|
"HY2_PORT=443",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
// Регрессия: HY2_AUTH_URL не подходит ни под один key-маркер, но несёт
|
// Регрессия: HY2_AUTH_URL не подходит ни под один key-маркер, но несёт
|
||||||
@@ -166,7 +175,9 @@ describe("редакция env-артефактов", () => {
|
|||||||
test("секрет внутри URL-значения вырезается, даже если имя ключа несекретное", () => {
|
test("секрет внутри URL-значения вырезается, даже если имя ключа несекретное", () => {
|
||||||
const redacted = redactEnv(POST_INSTALL);
|
const redacted = redactEnv(POST_INSTALL);
|
||||||
expect(redacted).not.toContain(MACHINE_TOKEN);
|
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("ключи-секреты вырезаются по имени", () => {
|
test("ключи-секреты вырезаются по имени", () => {
|
||||||
@@ -180,13 +191,40 @@ describe("редакция env-артефактов", () => {
|
|||||||
expect(redacted).toContain("HY2_PORT=443");
|
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-конфиг теряет все секреты", () => {
|
test("runtime-конфиг теряет все секреты", () => {
|
||||||
const runtime = [
|
const runtime = [
|
||||||
"HY2XS_CONFIG_SCHEMA_VERSION=2",
|
"HY2XS_CONFIG_SCHEMA_VERSION=2",
|
||||||
|
"HY2XS_FORCE_PASSWORD_CHANGE=false",
|
||||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${MACHINE_TOKEN}`,
|
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${MACHINE_TOKEN}`,
|
||||||
`HY2XS_HYSTERIA_OBFS_PASSWORD=${OBFS_PASSWORD}`,
|
`HY2XS_HYSTERIA_OBFS_PASSWORD=${OBFS_PASSWORD}`,
|
||||||
"HY2XS_ADMIN_CON_PASS=connection-password",
|
"HY2XS_ADMIN_CON_PASS=connection-password",
|
||||||
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password"
|
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const redacted = redactEnv(runtime);
|
const redacted = redactEnv(runtime);
|
||||||
@@ -195,16 +233,21 @@ describe("редакция env-артефактов", () => {
|
|||||||
expect(redacted).not.toContain("connection-password");
|
expect(redacted).not.toContain("connection-password");
|
||||||
expect(redacted).not.toContain("initial-password");
|
expect(redacted).not.toContain("initial-password");
|
||||||
expect(redacted).toContain("HY2XS_CONFIG_SCHEMA_VERSION=2");
|
expect(redacted).toContain("HY2XS_CONFIG_SCHEMA_VERSION=2");
|
||||||
|
expect(redacted).toContain("HY2XS_FORCE_PASSWORD_CHANGE=false");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("санитизация URL", () => {
|
describe("санитизация URL", () => {
|
||||||
test("креденшелы в 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-параметры вырезаются, остальные сохраняются", () => {
|
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).not.toContain("abc");
|
||||||
expect(out).toContain("page=2");
|
expect(out).toContain("page=2");
|
||||||
});
|
});
|
||||||
@@ -245,7 +288,7 @@ describe("редакция журналов в diagnostics-бандле", () =>
|
|||||||
for (const line of [
|
for (const line of [
|
||||||
`starting with access_token=${MACHINE_TOKEN}`,
|
`starting with access_token=${MACHINE_TOKEN}`,
|
||||||
`trafficStats secret=${MACHINE_TOKEN} rejected`,
|
`trafficStats secret=${MACHINE_TOKEN} rejected`,
|
||||||
`obfs password: ${OBFS_PASSWORD}`
|
`obfs password: ${OBFS_PASSWORD}`,
|
||||||
]) {
|
]) {
|
||||||
const out = redactLogText(line);
|
const out = redactLogText(line);
|
||||||
expect(out).not.toContain(MACHINE_TOKEN);
|
expect(out).not.toContain(MACHINE_TOKEN);
|
||||||
@@ -288,14 +331,22 @@ describe("маркеры секретных имён", () => {
|
|||||||
"cookie",
|
"cookie",
|
||||||
"bearer",
|
"bearer",
|
||||||
"signature",
|
"signature",
|
||||||
"passphrase"
|
"passphrase",
|
||||||
]) {
|
]) {
|
||||||
expect(isSecretKey(key)).toBe(true);
|
expect(isSecretKey(key)).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("не задевают обычные поля", () => {
|
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);
|
expect(isSecretKey(key)).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
HYSTERIA_HASHES_ASSET,
|
HYSTERIA_HASHES_ASSET,
|
||||||
HYSTERIA_LINUX_AMD64_ASSET,
|
HYSTERIA_LINUX_AMD64_ASSET,
|
||||||
selectLatestStableRelease,
|
selectLatestStableRelease,
|
||||||
type GithubRelease
|
type GithubRelease,
|
||||||
} from "../src/build/hysteriaRelease";
|
} from "../src/build/hysteriaRelease";
|
||||||
|
|
||||||
function assetUrl(tag: string, name: string): string {
|
function assetUrl(tag: string, name: string): string {
|
||||||
@@ -21,13 +21,13 @@ function release(tag: string): GithubRelease {
|
|||||||
assets: [
|
assets: [
|
||||||
{
|
{
|
||||||
name: HYSTERIA_LINUX_AMD64_ASSET,
|
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,
|
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"],
|
["commands", "install.ts"],
|
||||||
["steps", "hysteria.ts"],
|
["steps", "hysteria.ts"],
|
||||||
["lib", "packageMeta.ts"],
|
["lib", "packageMeta.ts"],
|
||||||
["commands", "reconfigure.ts"]
|
["commands", "reconfigure.ts"],
|
||||||
];
|
];
|
||||||
|
|
||||||
test("install-time код не импортирует build-time резолвер", () => {
|
test("install-time код не импортирует build-time резолвер", () => {
|
||||||
@@ -83,13 +83,19 @@ describe("install-time никогда не резолвит latest", () => {
|
|||||||
|
|
||||||
test("установка берёт версию, url и sha256 только из metadata пакета", () => {
|
test("установка берёт версию, url и sha256 только из metadata пакета", () => {
|
||||||
const install = srcFile("commands", "install.ts");
|
const install = srcFile("commands", "install.ts");
|
||||||
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.version"');
|
for (const key of ["hysteria.version", "hysteria.url", "hysteria.sha256"]) {
|
||||||
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.url"');
|
expect(install).toMatch(
|
||||||
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.sha256"');
|
new RegExp(
|
||||||
|
`readPackageValue\\(\\s*options\\.packageDir,\\s*"${key.replace(".", "\\.")}"`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("отсутствие любой части lock-метаданных прерывает установку", () => {
|
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("установленный бинарник сверяется с закреплённой версией", () => {
|
test("установленный бинарник сверяется с закреплённой версией", () => {
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ import { persistFailureState, runRollbackStages } from "../src/lib/rollback";
|
|||||||
// Пути считаются от файла теста, а не от cwd: `bun test` запускается и из корня
|
// Пути считаются от файла теста, а не от cwd: `bun test` запускается и из корня
|
||||||
// репозитория (сборка), и из orchestrator/ (разработчик).
|
// репозитория (сборка), и из orchestrator/ (разработчик).
|
||||||
function source(relativeToSrc: string): string {
|
function source(relativeToSrc: string): string {
|
||||||
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
|
return readFileSync(
|
||||||
|
join(import.meta.dir, "..", "src", relativeToSrc),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("стадии отката независимы", () => {
|
describe("стадии отката независимы", () => {
|
||||||
@@ -33,7 +36,7 @@ describe("стадии отката независимы", () => {
|
|||||||
const executed: string[] = [];
|
const executed: string[] = [];
|
||||||
const failures = await runRollbackStages([
|
const failures = await runRollbackStages([
|
||||||
{ name: "firewall", run: async () => void executed.push("firewall") },
|
{ 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"]);
|
expect(executed).toEqual(["firewall", "services"]);
|
||||||
@@ -49,10 +52,13 @@ describe("стадии отката независимы", () => {
|
|||||||
run: async () => {
|
run: async () => {
|
||||||
executed.push("firewall");
|
executed.push("firewall");
|
||||||
throw new Error("nft: command failed");
|
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"]);
|
expect(executed).toEqual(["firewall", "restore", "stop"]);
|
||||||
@@ -67,15 +73,15 @@ describe("стадии отката независимы", () => {
|
|||||||
name: "firewall",
|
name: "firewall",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
throw new Error("first");
|
throw new Error("first");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{ name: "healthy", run: async () => undefined },
|
{ name: "healthy", run: async () => undefined },
|
||||||
{
|
{
|
||||||
name: "restore configuration",
|
name: "restore configuration",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
throw new Error("second");
|
throw new Error("second");
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(failures).toHaveLength(2);
|
expect(failures).toHaveLength(2);
|
||||||
@@ -91,14 +97,14 @@ describe("стадии отката независимы", () => {
|
|||||||
name: "a",
|
name: "a",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
throw new Error("boom");
|
throw new Error("boom");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "b",
|
name: "b",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
throw new Error("boom");
|
throw new Error("boom");
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
expect(failures).toHaveLength(2);
|
expect(failures).toHaveLength(2);
|
||||||
});
|
});
|
||||||
@@ -109,8 +115,8 @@ describe("стадии отката независимы", () => {
|
|||||||
name: "weird",
|
name: "weird",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
throw "строковая ошибка";
|
throw "строковая ошибка";
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
expect(failures[0]).toContain("строковая ошибка");
|
expect(failures[0]).toContain("строковая ошибка");
|
||||||
});
|
});
|
||||||
@@ -135,7 +141,7 @@ describe("состояние отказа пишется best effort", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
persistFailureState(async () => {
|
persistFailureState(async () => {
|
||||||
throw new Error("ENOSPC: no space left on device");
|
throw new Error("ENOSPC: no space left on device");
|
||||||
})
|
}),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -173,7 +179,10 @@ describe("install: откат обязателен после операцион
|
|||||||
// Прямых await-вызовов отката в теле rollbackFailedInstall быть не должно:
|
// Прямых await-вызовов отката в теле rollbackFailedInstall быть не должно:
|
||||||
// именно они и образовывали отменяемую цепочку.
|
// именно они и образовывали отменяемую цепочку.
|
||||||
const start = installSource.indexOf("async function 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)");
|
expect(body).toContain("await rollbackFirewallNow(context)");
|
||||||
// Вызов существует только внутри стадии.
|
// Вызов существует только внутри стадии.
|
||||||
const firewallAt = body.indexOf("await rollbackFirewallNow(context)");
|
const firewallAt = body.indexOf("await rollbackFirewallNow(context)");
|
||||||
@@ -182,14 +191,19 @@ describe("install: откат обязателен после операцион
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("остановка сервисов остаётся отдельными стадиями", () => {
|
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}"`);
|
expect(installSource).toContain(`name: "${stage}"`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("чужие сервисы по-прежнему не трогаются", () => {
|
test("чужие сервисы по-прежнему не трогаются", () => {
|
||||||
expect(installSource).toContain(
|
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, в какую бы строку его ни перенёс
|
// выполняться ВНУТРИ persistFailureState, в какую бы строку его ни перенёс
|
||||||
// редактор.
|
// редактор.
|
||||||
const wrapped = reconfigureSource.match(
|
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();
|
expect(wrapped).not.toBeNull();
|
||||||
});
|
});
|
||||||
@@ -222,8 +236,12 @@ describe("reconfigure: откат обязателен после операци
|
|||||||
|
|
||||||
// Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком.
|
// Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком.
|
||||||
test("порядок сохранён: сначала firewall, затем конфиги", () => {
|
test("порядок сохранён: сначала firewall, затем конфиги", () => {
|
||||||
const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
|
const firewall = reconfigureSource.indexOf(
|
||||||
const restore = reconfigureSource.indexOf("await rollbackCurrentState(context)");
|
"await rollbackFirewallNow(context)",
|
||||||
|
);
|
||||||
|
const restore = reconfigureSource.indexOf(
|
||||||
|
"await rollbackCurrentState(context)",
|
||||||
|
);
|
||||||
const stages = reconfigureSource.indexOf("await runRollbackStages(stages)");
|
const stages = reconfigureSource.indexOf("await runRollbackStages(stages)");
|
||||||
expect(firewall).toBeGreaterThan(-1);
|
expect(firewall).toBeGreaterThan(-1);
|
||||||
expect(firewall).toBeLessThan(restore);
|
expect(firewall).toBeLessThan(restore);
|
||||||
@@ -242,14 +260,19 @@ describe("reconfigure: откат обязателен после операци
|
|||||||
const start = reconfigureSource.indexOf("function restoreStages(");
|
const start = reconfigureSource.indexOf("function restoreStages(");
|
||||||
const body = reconfigureSource.slice(
|
const body = reconfigureSource.slice(
|
||||||
start,
|
start,
|
||||||
reconfigureSource.indexOf("async function rollbackCurrentState")
|
reconfigureSource.indexOf("async function rollbackCurrentState"),
|
||||||
);
|
);
|
||||||
const muted = body
|
const muted = body
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter((line) => line.includes("runMutatingVisible`"))
|
.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("восстановление разбито на независимые стадии", () => {
|
test("восстановление разбито на независимые стадии", () => {
|
||||||
@@ -260,7 +283,7 @@ describe("reconfigure: откат обязателен после операци
|
|||||||
"restore nftables files",
|
"restore nftables files",
|
||||||
"apply restored ruleset",
|
"apply restored ruleset",
|
||||||
"reload systemd units",
|
"reload systemd units",
|
||||||
"restart services"
|
"restart services",
|
||||||
]) {
|
]) {
|
||||||
expect(reconfigureSource).toContain(`"${stage}"`);
|
expect(reconfigureSource).toContain(`"${stage}"`);
|
||||||
}
|
}
|
||||||
@@ -269,7 +292,9 @@ describe("reconfigure: откат обязателен после операци
|
|||||||
// Неполное восстановление обязано доходить до итоговой сводки как отказ.
|
// Неполное восстановление обязано доходить до итоговой сводки как отказ.
|
||||||
test("частичное восстановление не выдаётся за успешное", () => {
|
test("частичное восстановление не выдаётся за успешное", () => {
|
||||||
expect(reconfigureSource).toContain("restoreFailures.length > 0");
|
expect(reconfigureSource).toContain("restoreFailures.length > 0");
|
||||||
expect(reconfigureSource).toContain("восстановление состояния выполнено не полностью");
|
expect(reconfigureSource).toContain(
|
||||||
|
"восстановление состояния выполнено не полностью",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,12 +307,18 @@ describe("install: команды отката не глушат собстве
|
|||||||
// них не выполнялось никогда.
|
// них не выполнялось никогда.
|
||||||
test("стадии остановки сервисов не подавляют код возврата", () => {
|
test("стадии остановки сервисов не подавляют код возврата", () => {
|
||||||
const start = installSource.indexOf("async function 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"),
|
||||||
|
);
|
||||||
const muted = body
|
const muted = body
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter((line) => line.includes("runMutatingVisible`"))
|
.filter((line) => line.includes("runMutatingVisible`"))
|
||||||
.filter((line) => line.includes("|| true"));
|
.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { parseUpstreamHashes } from "../src/build/hysteriaRelease";
|
import { parseUpstreamHashes } from "../src/build/hysteriaRelease";
|
||||||
|
import { readText } from "../src/lib/fs";
|
||||||
|
|
||||||
function fail(message: string): never {
|
function fail(message: string): never {
|
||||||
console.error(`[hy2xs-build] ERROR: ${message}`);
|
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));
|
const { file, asset } = parseArgs(Bun.argv.slice(2));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await Bun.file(file).text();
|
const text = await readText(file);
|
||||||
process.stdout.write(`${parseUpstreamHashes(text, asset)}\n`);
|
process.stdout.write(`${parseUpstreamHashes(text, asset)}\n`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(error instanceof Error ? error.message : String(error));
|
fail(error instanceof Error ? error.message : String(error));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import { parseRuntimeEnv } from "../src/config/env";
|
import { parseRuntimeEnv } from "../src/config/env";
|
||||||
import { HYSTERIA_OBFS_TYPES } from "../src/config/profile";
|
import { HYSTERIA_OBFS_TYPES } from "../src/config/profile";
|
||||||
|
import { readText } from "../src/lib/fs";
|
||||||
import { hysteriaConfigTemplatePath, renderHysteriaConfig } from "../src/steps/config";
|
import { hysteriaConfigTemplatePath, renderHysteriaConfig } from "../src/steps/config";
|
||||||
import type { RuntimeContext } from "../src/types/context";
|
import type { RuntimeContext } from "../src/types/context";
|
||||||
|
|
||||||
@@ -138,7 +139,7 @@ async function main(): Promise<void> {
|
|||||||
overrides.HY2XS_HYSTERIA_TRAFFIC_STATS_PORT = options.trafficStatsPort;
|
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 config = parseRuntimeEnv(overrideEnv(sourceEnv, overrides));
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
@@ -160,7 +161,7 @@ async function main(): Promise<void> {
|
|||||||
hysteriaResolution: "compat-gate"
|
hysteriaResolution: "compat-gate"
|
||||||
} satisfies RuntimeContext;
|
} satisfies RuntimeContext;
|
||||||
|
|
||||||
const template = await Bun.file(options.templatePath).text();
|
const template = await readText(options.templatePath);
|
||||||
await Bun.write(options.outPath, renderHysteriaConfig(context, template));
|
await Bun.write(options.outPath, renderHysteriaConfig(context, template));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,11 @@
|
|||||||
./install.sh --config /root/custom-hy2xs.env --non-interactive
|
./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.
|
В baseline нет target-side JavaScript, TypeScript, frontend или Go build step.
|
||||||
|
|
||||||
## Установка выполняется в две фазы
|
## Установка выполняется в две фазы
|
||||||
|
|||||||
+14
-9
@@ -63,15 +63,16 @@ Windows и macOS можно использовать для редактиров
|
|||||||
и сверяет каждый архив с контрольной суммой из `versions.env`.
|
и сверяет каждый архив с контрольной суммой из `versions.env`.
|
||||||
6. Выполняет `verify_versions_contract`: рассинхрон версий роняет сборку до создания tarball.
|
6. Выполняет `verify_versions_contract`: рассинхрон версий роняет сборку до создания tarball.
|
||||||
7. Прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`).
|
7. Прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`).
|
||||||
8. Разрешает upstream-версию Hysteria, берёт ожидаемый SHA-256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт.
|
8. Рано прогоняет dependency-free контракты панели.
|
||||||
9. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS.
|
9. Разрешает upstream-версию Hysteria, берёт ожидаемый SHA-256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт.
|
||||||
10. Копирует package skeleton.
|
10. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS.
|
||||||
11. Собирает install-only orchestrator в standalone binary.
|
11. Копирует package skeleton и собирает install-only orchestrator.
|
||||||
12. Собирает frontend и backend HY2XS admin в Linux amd64 binary, проставляя версию админки через ldflags.
|
12. Устанавливает frozen frontend lock-граф, runtime-компилирует все сообщения RU/EN реальным `vue-i18n`, проверяет типы и собирает production bundle.
|
||||||
13. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов).
|
13. Собирает backend HY2XS admin в Linux amd64 binary, проставляя версию через ldflags.
|
||||||
14. Записывает metadata и checksums.
|
14. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов).
|
||||||
15. Создаёт `dist/hy2xs-install-<version>.tar.gz`.
|
15. Проверяет зависимости через `govulncheck ./...` и `pnpm audit` по всему lock-графу.
|
||||||
16. Проверяет архив и прогоняет acceptance-проверки.
|
16. Записывает metadata и checksums.
|
||||||
|
17. Создаёт архив и прогоняет его acceptance-проверки.
|
||||||
|
|
||||||
## Контракт версий
|
## Контракт версий
|
||||||
|
|
||||||
@@ -99,6 +100,10 @@ Go-коде больше нет: она уже успела разъехатьс
|
|||||||
только политика `HYSTERIA_CHANNEL`, результат резолва — в
|
только политика `HYSTERIA_CHANNEL`, результат резолва — в
|
||||||
[`hysteria-lock.env`](hysteria-lock.env)).
|
[`hysteria-lock.env`](hysteria-lock.env)).
|
||||||
|
|
||||||
|
Security overrides frontend находятся в `apps/frontend/pnpm-workspace.yaml`:
|
||||||
|
это канонический файл настроек pnpm. Поле `pnpm` в `package.json` для этой цели
|
||||||
|
не используется, потому что новые версии package manager его игнорируют.
|
||||||
|
|
||||||
## Версия Hysteria: разрешение и compatibility gate
|
## Версия Hysteria: разрешение и compatibility gate
|
||||||
|
|
||||||
Builder не хранит версию Hysteria вручную. По умолчанию он определяет последнюю стабильную версию сам и замораживает её в пакете.
|
Builder не хранит версию Hysteria вручную. По умолчанию он определяет последнюю стабильную версию сам и замораживает её в пакете.
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ main() {
|
|||||||
log_step "Checking orchestrator contracts"
|
log_step "Checking orchestrator contracts"
|
||||||
run_orchestrator_tests
|
run_orchestrator_tests
|
||||||
|
|
||||||
# Контракты панели проверяются рано: они не требуют ни собранного bundle, ни
|
# Dependency-free контракты панели проверяются рано. Runtime-компиляция
|
||||||
# установленных зависимостей, и падать на них после резолва Hysteria и сборки
|
# словарей запускается внутри bundle_ui сразу после frozen install, потому
|
||||||
# артефактов означало бы платить минутами за ошибку, видимую сразу.
|
# что ей нужен реальный vue-i18n из frontend lock-графа.
|
||||||
log_step "Checking HY2XS admin frontend contracts"
|
log_step "Checking HY2XS admin frontend contracts"
|
||||||
run_frontend_tests
|
run_frontend_tests
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,17 @@ code_without_comments() {
|
|||||||
# bash отключает errexit на весь вызов, поэтому неудачное чтение прошло бы
|
# bash отключает errexit на весь вызов, поэтому неудачное чтение прошло бы
|
||||||
# незамеченным и снова дало бы ложный PASS. `fail` выполняется в самой функции,
|
# незамеченным и снова дало бы ложный PASS. `fail` выполняется в самой функции,
|
||||||
# а не в подоболочке подстановки, поэтому его `exit` останавливает сборку.
|
# а не в подоболочке подстановки, поэтому его `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() {
|
code_has() {
|
||||||
local file="$1"
|
local file="$1"
|
||||||
shift
|
shift
|
||||||
@@ -172,11 +183,23 @@ run_fix20_acceptance_subset() {
|
|||||||
|| fail "acceptance: the default obfs type must not be re-declared in: $default_declarations"
|
|| 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"
|
log_step "Acceptance: runtime env is derived from config, not literals"
|
||||||
! grep -Eq 'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' orchestrator/src/config/env.ts \
|
# runtime env mappings live in runtimeEnvEntries(); renderRuntimeEnv only
|
||||||
|| fail "acceptance: renderRuntimeEnv must not print a hardcoded obfs type"
|
# serializes that shared list. Do not require the old inline interpolation.
|
||||||
grep -q 'HY2XS_HYSTERIA_OBFS_TYPE=\${config.hysteriaObfsType}' orchestrator/src/config/env.ts \
|
! grep -Eq \
|
||||||
|| fail "acceptance: renderRuntimeEnv must derive the obfs type from the parsed config"
|
'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' \
|
||||||
! grep -Eq '\|\|\s*"(gecko|salamander)"' orchestrator/src/config/env.ts \
|
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"
|
|| fail "acceptance: env.ts must not carry its own obfs fallback default"
|
||||||
|
|
||||||
log_step "Acceptance: package metadata records how the Hysteria version was chosen"
|
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_legacy_account_acceptance
|
||||||
run_scheduler_lifecycle_acceptance
|
run_scheduler_lifecycle_acceptance
|
||||||
run_dead_config_acceptance
|
run_dead_config_acceptance
|
||||||
|
run_admin_login_acceptance
|
||||||
run_dependency_hygiene_acceptance "$package_dir"
|
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-сервер.
|
# Планировщик принадлежит процессу и не перезапускает HTTP-сервер.
|
||||||
run_scheduler_lifecycle_acceptance() {
|
run_scheduler_lifecycle_acceptance() {
|
||||||
log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server"
|
log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server"
|
||||||
@@ -978,14 +1345,20 @@ $piped_matcher"
|
|||||||
|| fail "acceptance: прогон тестов оркестратора не фиксируется результатом"
|
|| fail "acceptance: прогон тестов оркестратора не фиксируется результатом"
|
||||||
grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \
|
grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \
|
||||||
|| fail "acceptance: прогон тестов админки не фиксируется результатом"
|
|| 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 \
|
grep -q 'FRONTEND_TESTS_PASSED' tools/build/lib/package.sh \
|
||||||
|| fail "acceptance: прогон контрактных тестов панели не фиксируется результатом"
|
|| fail "acceptance: общий результат тестов панели не фиксируется"
|
||||||
"$BUN_BIN" -e '
|
"$BUN_BIN" -e '
|
||||||
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
|
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
|
||||||
for (const [fn, flag] of [
|
for (const [fn, flag] of [
|
||||||
["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""],
|
["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""],
|
||||||
["run_admin_tests()", "ADMIN_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);
|
const start = source.indexOf(fn);
|
||||||
if (start < 0) throw new Error("не найдена функция " + fn);
|
if (start < 0) throw new Error("не найдена функция " + fn);
|
||||||
@@ -999,6 +1372,19 @@ $piped_matcher"
|
|||||||
if (set < 0) throw new Error(fn + ": результат прогона не фиксируется");
|
if (set < 0) throw new Error(fn + ": результат прогона не фиксируется");
|
||||||
if (set < run) 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: утверждение о пройденных тестах обязано следовать за прогоном"
|
' || fail "acceptance: утверждение о пройденных тестах обязано следовать за прогоном"
|
||||||
|
|
||||||
# Детектор гонок — часть прогона админки, а не пожелание.
|
# Детектор гонок — часть прогона админки, а не пожелание.
|
||||||
@@ -1192,6 +1578,39 @@ run_single_owner_acceptance() {
|
|||||||
}
|
}
|
||||||
' || fail "acceptance: a diagnostics failure must never cancel the rollback"
|
' || 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"
|
log_step "Acceptance: persisting the failure state never blocks the rollback"
|
||||||
# Тот же класс, что и «диагностика не отменяет откат», но уровнем раньше.
|
# Тот же класс, что и «диагностика не отменяет откат», но уровнем раньше.
|
||||||
# Запись маркера отказа — это mkdir/write/chown в /var/lib/hy2xs, то есть она
|
# Запись маркера отказа — это mkdir/write/chown в /var/lib/hy2xs, то есть она
|
||||||
@@ -1205,6 +1624,17 @@ run_single_owner_acceptance() {
|
|||||||
grep -q 'persistFailureState(' "$rollback_command" \
|
grep -q 'persistFailureState(' "$rollback_command" \
|
||||||
|| fail "acceptance: запись состояния отказа в $rollback_command не помечена как best effort"
|
|| fail "acceptance: запись состояния отказа в $rollback_command не помечена как best effort"
|
||||||
done
|
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 '
|
"$BUN_BIN" -e '
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
for (const [file, write] of [
|
for (const [file, write] of [
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ run_orchestrator_tests() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run_frontend_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 \
|
"$BUN_BIN" test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts \
|
||||||
|| fail "HY2XS admin frontend contract tests failed"
|
|| fail "HY2XS admin frontend contract tests failed"
|
||||||
|
|
||||||
FRONTEND_TESTS_PASSED="true"
|
FRONTEND_CONTRACT_TESTS_PASSED="true"
|
||||||
export FRONTEND_TESTS_PASSED
|
export FRONTEND_CONTRACT_TESTS_PASSED
|
||||||
}
|
}
|
||||||
|
|
||||||
run_admin_tests() {
|
run_admin_tests() {
|
||||||
@@ -146,6 +146,9 @@ build_orchestrator() {
|
|||||||
bundle_ui() {
|
bundle_ui() {
|
||||||
local ui_src="${UI_SRC:-apps}"
|
local ui_src="${UI_SRC:-apps}"
|
||||||
|
|
||||||
|
[ "${FRONTEND_CONTRACT_TESTS_PASSED:-false}" = "true" ] \
|
||||||
|
|| fail "bundle_ui: ранние контракты панели не проверялись"
|
||||||
|
|
||||||
rm -rf "$ADMIN_BUILD_DIR"
|
rm -rf "$ADMIN_BUILD_DIR"
|
||||||
mkdir -p "$ADMIN_BUILD_DIR"
|
mkdir -p "$ADMIN_BUILD_DIR"
|
||||||
|
|
||||||
@@ -164,6 +167,12 @@ bundle_ui() {
|
|||||||
|
|
||||||
"$PNPM_BIN" install --frozen-lockfile
|
"$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, и это обязательный шаг релиза.
|
# Проверка типов идёт ПЕРЕД сборкой bundle, и это обязательный шаг релиза.
|
||||||
#
|
#
|
||||||
# Раньше её здесь не было вовсе: `build:prod` означал
|
# Раньше её здесь не было вовсе: `build:prod` означал
|
||||||
@@ -176,7 +185,7 @@ bundle_ui() {
|
|||||||
# кода, который не проходит проверку типов, нет.
|
# кода, который не проходит проверку типов, нет.
|
||||||
"$PNPM_BIN" run typecheck
|
"$PNPM_BIN" run typecheck
|
||||||
"$PNPM_BIN" run build:prod
|
"$PNPM_BIN" run build:prod
|
||||||
)
|
) || fail "HY2XS admin frontend runtime tests or bundle failed"
|
||||||
|
|
||||||
# Версия админки приходит из versions.env через ldflags: один и тот же
|
# Версия админки приходит из versions.env через ldflags: один и тот же
|
||||||
# исполняемый файл обязан знать версию пакета, из которого он собран.
|
# исполняемый файл обязан знать версию пакета, из которого он собран.
|
||||||
@@ -196,6 +205,12 @@ bundle_ui() {
|
|||||||
mkdir -p "$STAGE_DIR/ui/hy2xs-admin/docs/sql"
|
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"
|
install -m 0644 "$ui_src/docs/sql/schema.sql" "$STAGE_DIR/ui/hy2xs-admin/docs/sql/schema.sql"
|
||||||
fi
|
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() {
|
write_metadata() {
|
||||||
|
|||||||
@@ -7,6 +7,23 @@ import {
|
|||||||
FLAMY_NAME,
|
FLAMY_NAME,
|
||||||
FLAMY_URL,
|
FLAMY_URL,
|
||||||
} from "../../apps/frontend/src/constants/branding";
|
} 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 ru from "../../apps/frontend/src/lang/package/ru";
|
||||||
import en from "../../apps/frontend/src/lang/package/en";
|
import en from "../../apps/frontend/src/lang/package/en";
|
||||||
|
|
||||||
@@ -300,9 +317,9 @@ describe("страницы журнала", () => {
|
|||||||
|
|
||||||
// Без width/min-width Element Plus делит ширину практически поровну, и
|
// Без width/min-width Element Plus делит ширину практически поровну, и
|
||||||
// сообщение — единственное содержимое журнала — получает треть строки.
|
// сообщение — единственное содержимое журнала — получает треть строки.
|
||||||
const columns = [...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g)].map(
|
const columns = [
|
||||||
(match) => match[0]
|
...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g),
|
||||||
);
|
].map((match) => match[0]);
|
||||||
expect(columns.length).toBeGreaterThanOrEqual(3);
|
expect(columns.length).toBeGreaterThanOrEqual(3);
|
||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
expect(column).toMatch(/\b(width|min-width)=/);
|
expect(column).toMatch(/\b(width|min-width)=/);
|
||||||
@@ -398,7 +415,9 @@ describe("действия над пиром", () => {
|
|||||||
// вызов здесь больше не стоит, обязан называть его по имени — иначе он
|
// вызов здесь больше не стоит, обязан называть его по имени — иначе он
|
||||||
// бесполезен. Это тот же урок, что записан в code_without_comments в
|
// бесполезен. Это тот же урок, что записан в code_without_comments в
|
||||||
// acceptance.sh, и он повторился прямо на этой проверке.
|
// acceptance.sh, и он повторился прямо на этой проверке.
|
||||||
const confirms = [...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g)];
|
const confirms = [
|
||||||
|
...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g),
|
||||||
|
];
|
||||||
// Единственный вызов — внутри confirmAction, который переводит отмену в
|
// Единственный вызов — внутри confirmAction, который переводит отмену в
|
||||||
// обычное `false`.
|
// обычное `false`.
|
||||||
expect(confirms.length).toBe(1);
|
expect(confirms.length).toBe(1);
|
||||||
@@ -513,7 +532,9 @@ describe("страница конфигурации Hysteria", () => {
|
|||||||
// Сам модуль view-модели удалён вместе с дефолтами: пока он существует,
|
// Сам модуль view-модели удалён вместе с дефолтами: пока он существует,
|
||||||
// его легко подключить обратно.
|
// его легко подключить обратно.
|
||||||
expect(
|
expect(
|
||||||
fs.existsSync(path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts"))
|
fs.existsSync(
|
||||||
|
path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts")
|
||||||
|
)
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -522,7 +543,12 @@ describe("страница конфигурации Hysteria", () => {
|
|||||||
test("страница не изображает редактируемую форму", () => {
|
test("страница не изображает редактируемую форму", () => {
|
||||||
const source = vueCodeOf(configPage());
|
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);
|
expect(source).not.toContain(editable);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -596,7 +622,15 @@ describe("страница конфигурации Hysteria", () => {
|
|||||||
expect(types).not.toMatch(/\bsecret\s*[?]?:/);
|
expect(types).not.toMatch(/\bsecret\s*[?]?:/);
|
||||||
// Секции, которых production-профиль не описывает, в модели ответа не
|
// Секции, которых production-профиль не описывает, в модели ответа не
|
||||||
// объявляются вовсе: они попадают в `drift` списком имён.
|
// объявляются вовсе: они попадают в `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}:`);
|
expect(types).not.toContain(`${section}:`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -641,10 +675,14 @@ describe("страница конфигурации Hysteria", () => {
|
|||||||
// существуют, «универсальный редактор Hysteria» отрастает заново.
|
// существуют, «универсальный редактор Hysteria» отрастает заново.
|
||||||
test("компоненты генерического редактора удалены", () => {
|
test("компоненты генерического редактора удалены", () => {
|
||||||
for (const component of ["MapAdd", "ImputMultiple", "UnitSelect"]) {
|
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(
|
expect(
|
||||||
fs.existsSync(path.join(FRONTEND_SRC, "views", "hysteria", "list", "components"))
|
fs.existsSync(
|
||||||
|
path.join(FRONTEND_SRC, "views", "hysteria", "list", "components")
|
||||||
|
)
|
||||||
).toBe(false);
|
).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("подсказки формы пира", () => {
|
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. Оператор не имел причин
|
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
|
||||||
// пробовать разрешённые символы.
|
// пробовать разрешённые символы.
|
||||||
test("подсказка имени описывает действующий набор символов", () => {
|
test("подсказка имени описывает действующий набор символов", () => {
|
||||||
// Набор символов объявлен на сервере ровно один раз; подсказка обязана
|
// Набор символов объявлен на сервере ровно один раз; подсказка обязана
|
||||||
// описывать именно его, а не набор из предыдущего поколения правила.
|
// описывать именно его, а не набор из предыдущего поколения правила.
|
||||||
const rule = fs.readFileSync(
|
expect(PEER_NAME_PUNCTUATION).toContain("!@#");
|
||||||
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("!@#");
|
|
||||||
|
|
||||||
for (const dictionary of [ru, en] as const) {
|
for (const dictionary of [ru, en] as const) {
|
||||||
const hint = (dictionary as any).peer?.form?.nameHint ?? "";
|
const hint = (dictionary as any).peer?.form?.nameHint ?? "";
|
||||||
expect(hint).toContain(punctuation);
|
expect(hint).toContain("{punctuation}");
|
||||||
expect(hint).toContain("32");
|
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}"`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user