Compare commits
8 Commits
v1.0.0-rc2
...
1.0.0-rc4
| Author | SHA1 | Date | |
|---|---|---|---|
| 079094591b | |||
| bf10810cfc | |||
| 6c286d1ff5 | |||
| a3445f79c5 | |||
| af9f476658 | |||
| ab788725cf | |||
| 65042ee335 | |||
| a8407cf16b |
+276
@@ -8,6 +8,33 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
По результатам runtime-проверки RC3 login smoke приведён к реальному HTTP
|
||||
контуру панели: positive и negative пробы используют один helper с wire-полем
|
||||
`pass`, явным `HY2XS-Installer/1.0` User-Agent и строгим разбором JSON envelope.
|
||||
Тесты теперь проходят через scanner middleware и отдельно доказывают отказ
|
||||
стандартного curl UA, успешный bootstrap-вход и `invalid_credentials` для
|
||||
случайного пароля. Rollback cleanup проверяет `ActiveState` после
|
||||
`reset-failed`: уже выгруженный юнит больше не создаёт ложное предупреждение о
|
||||
ручном восстановлении, а недоступный systemd и сохранившийся `failed` остаются
|
||||
ошибкой.
|
||||
|
||||
Diagnostics больше не пишет root-артефакты в `/var/log/hy2xs`, которым владеет
|
||||
`hy2xs-admin`. Staging и архив перенесены в проверяемый через `lstat` каталог
|
||||
`/var/lib/hy2xs/diagnostics` (`root:root 0700`), имена создаются эксклюзивно,
|
||||
symlink и ослабленные права дают fail-closed отказ, а временный staging после
|
||||
упаковки удаляется. Чтение metadata теперь использует fallback только при
|
||||
`ENOENT`; ошибки прав, ввода-вывода и неверного типа больше не маскируются.
|
||||
|
||||
Очередной hardening-проход закрывает границу кодировок. Оркестратор теперь
|
||||
читает конфигурацию и секреты как байты с fatal UTF-8 decoding, сохраняет BOM
|
||||
как U+FEFF до явного транспортного отказа и проверяет исходный EnvironmentFile
|
||||
до разбора ключей. HTTP API до `encoding/json` отвергает повреждённый UTF-8 и
|
||||
непарные UTF-16 surrogate escape, поэтому вход или импорт больше не могут
|
||||
молча превратиться в другое значение через U+FFFD. Diagnostics сначала
|
||||
санитизирует данные в памяти и никогда не кладёт сырые env/YAML/journal во
|
||||
временный каталог. U+FEFF во всех слоях отнесён к документированному домену
|
||||
systemd, а управляющие `Cc` — к политике HY2XS.
|
||||
|
||||
Hardening-проход перед релизом `1.0.0`. Основная тема — сделать политику
|
||||
«только чистая установка» настоящим системным инвариантом, а не строчкой в
|
||||
документации.
|
||||
@@ -96,6 +123,255 @@ Hysteria-интеграции с официальной документацие
|
||||
никто не проверял. Разбор задокументирован в
|
||||
[docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md).
|
||||
|
||||
Четырнадцатый проход — по итогам runtime-прогона `v1.0.0-rc2` на чистом
|
||||
Debian 13. Тема: **разница между «поднялось» и «работает»**. Установка прошла
|
||||
целиком и объявила `INSTALL EXIT CODE: 0`, юнит был активен, порт слушал,
|
||||
`/healthz` отвечал `ok: true` — и вход в панель отдавал HTTP 500 на каждый
|
||||
запрос, потому что валидатор паниковал на теге правила, пережившего
|
||||
переименование. Ни один из трёх признаков готовности не отвечал на вопрос, ради
|
||||
которого они проверялись. Вместе с самим дефектом закрыт его класс: барьер,
|
||||
проверяющий каждый тег валидации на существование, и smoke, выполняющий
|
||||
настоящий вход. Прогон задокументирован в
|
||||
[docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md](docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md).
|
||||
|
||||
Пятнадцатый проход — разбор того, что четырнадцатый построил, со сверкой по
|
||||
исходникам используемых библиотек, а не по их описаниям. Тема прохода: **новый
|
||||
контракт, объявленный точнее, чем он есть на самом деле**. Четырнадцатый проход
|
||||
свёл правило о пароле в одно место и объявил его границей в 64 символа,
|
||||
обосновав выбор пределом bcrypt в 72 байта. Обоснование верно только для ASCII:
|
||||
`golang.org/x/crypto/bcrypt` считает БАЙТЫ и отвечает `ErrPasswordTooLong`, а не
|
||||
«молча отбрасывает остаток», как утверждал комментарий, — так вела себя редакция
|
||||
пакета до v0.28. Пароль из 64 кириллических букв (128 байт) проходил панель,
|
||||
оркестратор и DTO и падал в хешировании: системной ошибкой на штатной смене
|
||||
пароля, а после установки — отсутствием администратора вовсе. Хуже самого
|
||||
дефекта было то, что новый тест закреплял это значение как ожидаемое.
|
||||
|
||||
Вместе с ним закрыты три соседних расхождения того же контракта: `HashPassword`
|
||||
и bootstrap триммили пароль, у которого краевой пробел объявлен частью значения;
|
||||
панель считала длину в единицах UTF-16 (`async-validator`), тогда как сервер
|
||||
считает code points; и сам транспорт — `hy2xs.env` — не был форматом,
|
||||
переживающим произвольное значение, хотя его читает не только оркестратор, но и
|
||||
systemd.
|
||||
|
||||
Шестнадцатый проход — разбор пятнадцатого, со сверкой по исходникам systemd
|
||||
v257.13, то есть той самой линии, что стоит на Debian 13. Тема та же и на слой
|
||||
глубже: **контракт, объявленный шире, чем его принимает чужая сторона**.
|
||||
Пятнадцатый проход сделал транспорт lossless для значений, которые systemd
|
||||
принимает, но не спросил, какие значения он принимает вообще. Вместе с этим
|
||||
закрыты рецепт восстановления доступа, ставший опасным ровно оттого, что файл
|
||||
научился нести произвольные значения, и порядок, при котором детерминированная
|
||||
ошибка конфигурации обнаруживалась уже после мутаций хоста.
|
||||
|
||||
### Исправлено — вход в панель
|
||||
|
||||
- **`POST /api/auth/login` паниковал до проверки учётных данных.** На
|
||||
`LoginDto.Username` стоял тег `validateStr` — правило с таким именем не
|
||||
регистрировалось: при переименовании в `credentialStr` правка не доехала до
|
||||
одного файла, оставив мёртвую регистрацию и живую ссылку на несуществующее
|
||||
имя. `go-playground/validator` на неизвестный тег ПАНИКУЕТ при разборе
|
||||
структуры, `gin.Recovery` превращал панику в HTTP 500, и панель после чистой
|
||||
установки была недоступна целиком.
|
||||
|
||||
- **Ни один тест не прогонял DTO через production-валидатор.** Проверялся сам
|
||||
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
|
||||
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
|
||||
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
|
||||
тега не доходит. Теперь `TestEveryValidationTagIsRegistered` извлекает все
|
||||
теги `validate:"…"` из `apps/model/**` и предъявляет каждый валидатору
|
||||
отдельно — закрыт класс, а не найденный экземпляр.
|
||||
|
||||
- **Установка не проверяла, что в панель можно войти.** Smoke отвечал на этот
|
||||
вопрос фактом `LISTEN` и ответом `/healthz`; RC2 доказал, что оба бывают
|
||||
истинны при полностью недоступной панели. Теперь smoke выполняет настоящий
|
||||
`POST /api/auth/login` bootstrap-учётными данными и требует конверт успеха с
|
||||
непустым токеном — по коду HTTP это неотличимо, админка отвечает `200 OK` и
|
||||
на отказ. Отрицательная проба выполняется в любом режиме операции.
|
||||
|
||||
- **Оркестратор не знал контракта, который сам порождает.** `HY2XS_ADMIN_USER`
|
||||
по умолчанию был `admin` — пять символов при минимуме панели в шесть, — и
|
||||
установка с ним завершалась успешно, создавая учётную запись, под которой
|
||||
невозможно войти. Про одно имя существовало три расходящихся умолчания. Оба
|
||||
значения теперь проверяются при разборе окружения, умолчание сведено к
|
||||
`hy2xsadmin` во всех трёх местах.
|
||||
|
||||
- **Панель была строже сервера и запирала оператора после смены пароля.** Форма
|
||||
входа ограничивала пароль 32 символами при серверном пределе в 64, а форма
|
||||
смены пароля назначала до 64: пароль, назначенный штатной операцией, после
|
||||
этого не вводился. Набор символов на пароле отвергал значение, которое сервер
|
||||
принял бы, — сервер его не ограничивает нигде. Контракт учётных данных теперь
|
||||
объявлен один раз в `apps/credential/admin.go`, а копии в панели и
|
||||
оркестраторе сверяются с ним тестами, читающими Go-исходник.
|
||||
|
||||
- **Класс символов логина был записан диапазоном по опечатке.** Неэкранированный
|
||||
дефис в `[a-zA-Z0-9!@#$%^&*()_+-=]` превращал `+-=` в диапазон, впускающий
|
||||
`, - . / 0-9 : ; < =`. С серверным набором это совпадало по совпадению: оба
|
||||
несли одну опечатку. Набор записан явно и НЕ сужен — он уже действует на
|
||||
установленных серверах.
|
||||
|
||||
- **Красная рамка ошибки обводила не то, что видит оператор.** Element Plus
|
||||
рисует состояние отказа на `el-input__wrapper` селектором из четырёх классов,
|
||||
а форма входа рисует видимую рамку поля на `el-form-item` и гасила чужую тень
|
||||
селектором из трёх — и проигрывала по специфичности. Рамка ложилась вокруг
|
||||
одного лишь ввода: у логина начиналась после иконки, у пароля обрывалась перед
|
||||
переключателем видимости. Индикация перенесена на элемент, который оператор и
|
||||
видит полем.
|
||||
|
||||
- **Требование не называлось.** «Неверный формат логина» и «Некорректное
|
||||
значение» не давали оператору ни одного способа узнать, что от него хотят:
|
||||
набор символов логина приходит из `hy2xs.env` и в панели нигде не показан.
|
||||
Фразы форм и серверная причина `credential_format` теперь перечисляют границы
|
||||
и набор.
|
||||
|
||||
- **`last_login_at` не записывался никогда.** Колонка объявлена в схеме и в
|
||||
entity, `UpdateAdminLastLoginAt` существовал и не вызывался ниоткуда. Отметка
|
||||
ставится в `service.Login` сразу после успешной проверки пароля; отказ записи
|
||||
вход не отменяет, но попадает в журнал.
|
||||
|
||||
### Исправлено — домен значений файла окружения
|
||||
|
||||
- **Контракт не знал, какие значения systemd вообще принимает.** Перед тем как
|
||||
принять пару, systemd прогоняет ключ и значение через `utf8_is_valid`
|
||||
(`src/basic/env-file.c`, `check_utf8ness_and_warn`), и отказ там возвращает
|
||||
`-EINVAL` — то есть НЕзагруженный `EnvironmentFile=` и юнит, который не
|
||||
стартует, а не предупреждение. `unichar_is_valid` (`src/basic/utf8.c`)
|
||||
отвергает суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`.
|
||||
Пароль `abcde` + `U+FDD0` — шесть символов, восемь байт, ни одного
|
||||
управляющего — проходил панель, оркестратор, DTO и хеширование, записывался в
|
||||
`hy2xs.env`, и после этого админка не поднималась: тот же класс дефекта, ради
|
||||
уничтожения которого контракт и существует, только слоем ниже. Введён
|
||||
`IsEnvTransportableText` (Go) / `isEnvTransportable` (TS), повторяющий
|
||||
множество systemd точно — не шире и не уже.
|
||||
|
||||
- **Одиночный суррогат подменял бы секрет молча.** Строка JavaScript вправе
|
||||
содержать непарный суррогат, а `TextEncoder` заменяет его на `U+FFFD`. Без
|
||||
отдельной проверки в файл уехал бы ДРУГОЙ пароль, а не отказ. Панель и
|
||||
оркестратор отвергают такие значения явно.
|
||||
|
||||
- **Проверка транспорта приписывала формату чужие запреты.** Она отвергала C0 и
|
||||
DEL с формулировкой «формат управляющих символов не несёт» — неправда: внутри
|
||||
двойных кавычек перевод строки накапливается как обычный байт и переживает
|
||||
round-trip. Именно эта подмена и позволила проверке не знать про
|
||||
noncharacters. Домен транспорта и политика продукта теперь разделены и
|
||||
проверяются отдельными тестами.
|
||||
|
||||
- **Политика была шире кода ровно на C1.** Документация обещала «без
|
||||
управляющих символов», а проверялись только `U+0000..U+001F` и `U+007F`.
|
||||
Запрещена категория `Cc` целиком, плюс `U+FEFF` — последний отдельным
|
||||
решением продукта, а не форматом: `0xFEFF & 0xFFFE` это `0xFEFE`, и systemd
|
||||
такое значение принимает (комментарий `/* BOM */` в его исходнике относится к
|
||||
`U+xFFFE` и является неточностью самого systemd).
|
||||
|
||||
- **Порт автомата расходился с upstream на `\<CR>`.** В состоянии
|
||||
`DOUBLE_QUOTE_VALUE_ESCAPE` systemd пишет `c != '\n'`, а не проверку на любой
|
||||
перевод строки (в `VALUE_ESCAPE` — наоборот, `strchr(NEWLINE, c)`). Порт
|
||||
съедал и `\<LF>`, и `\<CR>`, то есть прочитал бы такое значение иначе, чем
|
||||
тот, для кого файл в конечном счёте написан.
|
||||
|
||||
- **Комментарий обещал одно намеренное расхождение с systemd, а их два.** Кроме
|
||||
строки без `=`, HY2XS отказывает и на незакрытой кавычке в конце файла, тогда
|
||||
как systemd принимает накопленное. Оба расхождения fail-closed и теперь
|
||||
названы оба.
|
||||
|
||||
### Исправлено — рецепт восстановления выполнял env-файл как код
|
||||
|
||||
- **`set -a; . /etc/hy2xs/hy2xs.env` в инструкции «Забыт пароль
|
||||
администратора».** Строка стала опасной ровно тогда, когда файл научился нести
|
||||
произвольные значения. Для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(…)"` —
|
||||
буквальное значение: подстановок в `EnvironmentFile=` нет вовсе. Но `.`
|
||||
обрабатывает файл bash, а bash внутри двойных кавычек выполняет подстановку
|
||||
команд — от root, прямо в рецепте восстановления доступа. Причём соседний
|
||||
раздел той же страницы уже правильно запрещал `source`/`eval` для
|
||||
`bootstrap-admin.secret`: документ запрещал действие и тут же его предлагал.
|
||||
Рецепт читает нужные значения как ДАННЫЕ; поставлен гейт приёмки, запрещающий
|
||||
возврат `source`/`.`/`eval` над этими файлами в командах документации и в
|
||||
скриптах.
|
||||
|
||||
### Исправлено — отказ приходил после мутаций хоста
|
||||
|
||||
- **Проверка транспорта жила только внутри `renderRuntimeEnv`.** Она срабатывала
|
||||
на шаге «write runtime env» — уже после bootstrap оркестратора, установки
|
||||
пакетов и раскладки файловой системы, — а read-only `preflight-install`
|
||||
говорил PASS: он зовёт `parseRuntimeEnv` и ничего не рендерит.
|
||||
Детерминированно известная ошибка конфигурации роняла операцию, оставив за
|
||||
собой изменённый хост, что прямо противоречит контракту PHASE 0.
|
||||
`validateRuntimeEnvTransport` вызывается теперь из `parseRuntimeEnv` и
|
||||
проходит по ВСЕМ парам `runtimeEnvEntries`: ограничение принадлежит формату, а
|
||||
не полю пароля, и `HY2XS_ADMIN_CON_PASS` сломал бы загрузку юнита так же.
|
||||
|
||||
### Исправлено — контракт пароля администратора
|
||||
|
||||
- **Граница пароля не совпадала с границей bcrypt.** Предел был объявлен в 64
|
||||
СИМВОЛА и обоснован пределом bcrypt в 72 БАЙТА — верно только для ASCII. У 64
|
||||
символов длина от 64 до 256 байт: `64 x "я"` — это 128 байт, `19 x "😀"` — 76.
|
||||
`bcrypt.GenerateFromPassword` (`golang.org/x/crypto@v0.55.0`, `bcrypt.go:96`)
|
||||
отвечает на такое `ErrPasswordTooLong`. Отказ приходил не с формы, а из
|
||||
хеширования: на смене пароля — системной ошибкой, при установке — падением
|
||||
старта админки, то есть сервером без администратора после
|
||||
`INSTALL EXIT CODE: 0`. Введена отдельная константа `AdminPasswordMaxBytes =
|
||||
72`, зеркальная в оркестраторе и панели; граничные значения (36 x `я`, 18 x
|
||||
`😀` = 72 байта) закреплены тестами во всех четырёх слоях.
|
||||
|
||||
- **Тест закреплял дефект как ожидаемое поведение.** Проверка «длина считается в
|
||||
символах, а не в байтах» требовала ПРИЁМА пароля из 64 кириллических букв —
|
||||
ровно того значения, на котором продукт ломался. Тест инвертирован.
|
||||
|
||||
- **Пароль триммился в двух местах вопреки собственному контракту.** Контракт
|
||||
объявляет краевой пробел частью пароля и не снимает его ни на форме входа, ни
|
||||
при смене. При этом `util.HashPassword` вёл собственную проверку
|
||||
`len(strings.TrimSpace(password)) < 6`, а bootstrap читал
|
||||
`strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Следствия:
|
||||
пароль `abcde ` принимали все двери продукта и не мог захешировать никто, а
|
||||
первая учётная запись создавалась не с тем паролем, который оператор записал в
|
||||
`hy2xs.env`. Оба тримминга убраны.
|
||||
|
||||
- **Контракт переехал в leaf-пакет.** Собственная проверка в `HashPassword`
|
||||
существовала не по недосмотру: `service` импортирует `util`, и позвать оттуда
|
||||
контракт было невозможно. Правило переехало в `apps/credential`, ниже `util`,
|
||||
`dao` и `service`, — теперь у него физически не может появиться второго
|
||||
экземпляра.
|
||||
|
||||
- **Границы пароля стояли рядом с правилом, а не внутри него, и были неполны.**
|
||||
`min=6,max=64` в тегах DTO — та же конструкция, от которой уже отказались на
|
||||
логине; у пароля она вдобавок не могла выразить границу в байтах, потому что
|
||||
правила длины в байтах у `go-playground/validator` нет. Введено одно правило
|
||||
`adminPassword` и код причины `admin_password_format`, называющий обе границы.
|
||||
|
||||
- **Панель считала длину в единицах UTF-16.** Element Plus делегирует правила
|
||||
формы библиотеке `async-validator`, а она сравнивает `min`/`max` строки с
|
||||
`String.prototype.length`. Пароль из трёх эмодзи имел `length === 6`, проходил
|
||||
минимум формы и получал отказ сервера, который панель не могла объяснить:
|
||||
сервер видит три руны. Встроенные `min`/`max` на пароле заменены общим
|
||||
правилом `adminPasswordFormRule`, считающим code points и UTF-8 байты.
|
||||
|
||||
- **`hy2xs.env` не был форматом.** Значения писались интерполяцией, а читались
|
||||
построчным `split("=")` с `trim()`; совпадение поведения держалось на том, что
|
||||
в них не встречалось ни пробелов по краям, ни кавычек, ни обратных слешей. При
|
||||
этом файл читает не только оркестратор: он объявлен `EnvironmentFile=` в юните
|
||||
`hy2xs-admin`, и у незакавыченного значения systemd срезает краевые пробелы и
|
||||
трактует `\` как escape. Пароль с краевым пробелом терялся ещё до запуска
|
||||
админки. Добавлен `orchestrator/src/lib/envFile.ts` — порт конечного автомата
|
||||
`parse_env_file_internal` из systemd и обратный ему кодировщик; тем же
|
||||
кодировщиком пишется `bootstrap-admin.secret`. Обычные значения остаются без
|
||||
кавычек, поэтому релизные гейты и инструкции оператора не меняются.
|
||||
|
||||
- **Управляющие символы запрещены контрактом.** Формат `KEY=VALUE` их не несёт —
|
||||
перевод строки является границей записи, — а ввести такой пароль в
|
||||
однострочное поле формы входа всё равно нельзя: он умел только запереть
|
||||
оператора. Запрет делает транспорт полным: всякий принятый контрактом пароль
|
||||
доезжает до bcrypt побайтово тем же, каким его задал оператор.
|
||||
|
||||
- **Отрицательная проба smoke не доказывала отказа.** Она сверяла `%{http_code}`
|
||||
с `200` и доказывала ровно одно: что запрос не закончился пятисоткой — админка
|
||||
отвечает `200 OK` и на успешный вход тоже. Пароль пробы вдобавок был записан
|
||||
литералом и теоретически мог оказаться настоящим. Теперь пароль генерируется в
|
||||
момент пробы, а результат сверяется с конвертом: `code: 50000`, причина
|
||||
`invalid_credentials`, отсутствие `accessToken`.
|
||||
|
||||
- **Положительная проба smoke читала пароль шелл-конвейером.** `grep … | cut -d=
|
||||
-f2-` с `.trim()` был третьим по счёту слоем, срезающим краевые пробелы:
|
||||
проба взяла бы не тот пароль и объявила бы рабочую установку сломанной.
|
||||
Bootstrap-секрет читается парсером собственного формата, минуя shell.
|
||||
|
||||
### Исправлено — правило доступа
|
||||
|
||||
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
|
||||
|
||||
@@ -634,8 +634,8 @@ hy2xs-orchestrator status \
|
||||
| `HY2XS_UI_BIND_HOST` | IPv4 bind HY2XS admin | `127.0.0.1` |
|
||||
| `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` |
|
||||
| `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` |
|
||||
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора | `hy2xsadmin` |
|
||||
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора: 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`. Значение вне контракта роняет установку — панель его не приняла бы на форме входа | `hy2xsadmin` |
|
||||
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора: 6-64 символа Unicode **и** не более 72 байт в UTF‑8 (предел bcrypt); валидный UTF‑8 в документированном домене `EnvironmentFile=` (в частности, без U+FEFF), без управляющих символов; пробелы по краям — часть пароля, поэтому такое значение записывается в двойных кавычках; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||
| `HY2XS_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||
| `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` |
|
||||
| `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` |
|
||||
@@ -749,7 +749,7 @@ hy2xs-orchestrator reconfigure \
|
||||
| `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения |
|
||||
| `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию |
|
||||
| `hy2xs-orchestrator repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** |
|
||||
| `hy2xs-orchestrator diagnostics collect` | Собрать diagnostic bundle в `/var/log/hy2xs/diagnostics` |
|
||||
| `hy2xs-orchestrator diagnostics collect` | Собрать diagnostic bundle в `/var/lib/hy2xs/diagnostics` |
|
||||
| `hy2xs-orchestrator redact-config` | Отредактировать секреты в env/yaml перед публикацией логов |
|
||||
|
||||
`repair` без `--allow-partial-state` работает только поверх полностью успешной
|
||||
@@ -766,6 +766,17 @@ hy2xs-orchestrator diagnostics collect \
|
||||
--config /etc/hy2xs/hy2xs.env
|
||||
```
|
||||
|
||||
Бандл не содержит сырых промежуточных копий конфигурации или журналов:
|
||||
редакция выполняется в памяти до записи. Файл с повреждённым UTF-8 не
|
||||
декодируется с заменой и не попадает в архив; вместо него записывается
|
||||
безопасная причина пропуска.
|
||||
|
||||
Архив создаётся в `/var/lib/hy2xs/diagnostics` с режимом `0600`. Этот каталог
|
||||
принадлежит `root:root`, имеет режим `0700` и отделён от
|
||||
`HY2XS_LOG_DIR`, которым владеет сервисный пользователь `hy2xs-admin`.
|
||||
Оркестратор отказывает, если каталог подменён symlink, имеет другого владельца
|
||||
или ослабленные права. Незавершённый staging-каталог после упаковки удаляется.
|
||||
|
||||
## Проверка безопасности после установки
|
||||
|
||||
Минимальный набор проверок:
|
||||
|
||||
@@ -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) {
|
||||
var req dto.Hysteria2AuthDto
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := strictBindJSON(c, &req); err != nil {
|
||||
vo.Hysteria2AuthBadRequest(c)
|
||||
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
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
loginDto, err := validateField(c, dto.LoginDto{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
|
||||
if err != nil {
|
||||
// Неверные учётные данные получают код, чтобы панель показала
|
||||
// оператору внятную фразу на его языке. Отказ базы остаётся системной
|
||||
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
|
||||
// значит отправить оператора искать несуществующую опечатку.
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
|
||||
}
|
||||
|
||||
func PagePeer(c *gin.Context) {
|
||||
peerPageDto, err := validateField(c, dto.PeerPageDto{})
|
||||
if err != nil {
|
||||
@@ -210,6 +189,10 @@ func ImportPeer(c *gin.Context) {
|
||||
vo.Fail("the file is too big", c)
|
||||
return
|
||||
}
|
||||
if err = validateStrictJSONEncoding(content); err != nil {
|
||||
vo.Fail("content contains invalid UTF-8 or UTF-16", c)
|
||||
return
|
||||
}
|
||||
|
||||
var peerExports []bo.PeerExport
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// Разбор загруженного файла проверяется без базы: все три отказа наступают до
|
||||
// первого обращения к слою данных, и это часть контракта — испорченный файл не
|
||||
// должен доходить до транзакции.
|
||||
func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
func postImportBytes(t *testing.T, fileName string, content []byte) apiResult {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -27,7 +27,7 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать форму: %v", err)
|
||||
}
|
||||
if _, err := part.Write([]byte(content)); err != nil {
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatalf("не удалось записать файл в форму: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
@@ -49,6 +49,24 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
t.Helper()
|
||||
return postImportBytes(t, fileName, []byte(content))
|
||||
}
|
||||
|
||||
func TestImportPeerRejectsInvalidUTF8(t *testing.T) {
|
||||
for _, content := range [][]byte{
|
||||
{'[', '"', 0xFF, '"', ']'},
|
||||
{'[', '"', 0xC3, '"', ']'},
|
||||
{'[', '"', 0xED, 0xA0, 0x80, '"', ']'},
|
||||
} {
|
||||
result := postImportBytes(t, "peers.json", content)
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("повреждённый UTF-8 принят: %+v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия: json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл с
|
||||
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
|
||||
// половина файла молча не применялась.
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/vo"
|
||||
@@ -34,6 +34,7 @@ func init() {
|
||||
|
||||
mustRegister("peerName", validatePeerName)
|
||||
mustRegister("credentialStr", validateCredentialStr)
|
||||
mustRegister("adminPassword", validateAdminPassword)
|
||||
}
|
||||
|
||||
func mustRegister(tag string, fn validator.Func) {
|
||||
@@ -60,21 +61,38 @@ func validatePeerName(f validator.FieldLevel) bool {
|
||||
return service.IsValidPeerName(f.Field().String())
|
||||
}
|
||||
|
||||
// credentialStrPattern — набор символов логина и пароля администратора.
|
||||
// validateCredentialStr — единственное правило имени администратора.
|
||||
//
|
||||
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
|
||||
// последствия неэкранированного дефиса в исходной записи `_+-=`. Это сделано
|
||||
// намеренно: имя администратора приходит из HY2XS_ADMIN_USER в hy2xs.env,
|
||||
// оркестратор набор символов не ограничивает, и сужение правила означало бы,
|
||||
// что установка с логином вроде `admin.ops` перестаёт пускать оператора в
|
||||
// панель. Сужать этот набор можно только вместе с проверкой имени на стороне
|
||||
// оркестратора, и это отдельная работа, а не побочный эффект правки формы
|
||||
// пира.
|
||||
var credentialStrPattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+,\-./:;<=]{6,32}$`)
|
||||
|
||||
// Набор символов и длина берутся из service по той же причине, что и у имени
|
||||
// пира: собственная копия правила в слое контроллеров уже расходилась с
|
||||
// остальным продуктом. Здесь она вдобавок была единственным местом, знавшим
|
||||
// набор, — оркестратор писал в hy2xs.env любое имя, а панель проверяла своё
|
||||
// третье правило.
|
||||
//
|
||||
// Пустое значение НЕ впускается. Раньше здесь стояло `field == "" || …`, и это
|
||||
// была ловушка: у поля-указателя `required` считает непустым сам факт
|
||||
// ненулевого указателя, поэтому `{"username":""}` доходило сюда и объявлялось
|
||||
// корректным. Пустой логин отсекается нормализацией DTO, которая превращает
|
||||
// его в «не задано», и получает отказ `required` — то есть ту причину, которая
|
||||
// у него на самом деле.
|
||||
func validateCredentialStr(f validator.FieldLevel) bool {
|
||||
field := f.Field().String()
|
||||
return field == "" || credentialStrPattern.MatchString(field)
|
||||
return credential.IsValidAdminUsername(f.Field().String())
|
||||
}
|
||||
|
||||
// validateAdminPassword — единственное правило пароля администратора.
|
||||
//
|
||||
// Здесь стояли `min=6,max=64` прямо в теге, и это была та же конструкция, от
|
||||
// которой уже отказались на логине: границы жили РЯДОМ с правилом, а не внутри
|
||||
// него. У пароля она вдобавок была неполна. Тег `max=64` считает символы
|
||||
// Unicode, а bcrypt считает байты, поэтому пароль из 64 кириллических букв —
|
||||
// 128 байт — проходил проверку целиком и падал только в хешировании, уже как
|
||||
// системная ошибка на штатной смене пароля.
|
||||
//
|
||||
// Границу в байтах тегом не выразить: у валидатора нет правила длины в байтах.
|
||||
// Поэтому всё правило целиком переехало в contract-функцию, и теперь у формы
|
||||
// входа, формы смены пароля, bootstrap-установки и `reset-admin` она одна.
|
||||
func validateAdminPassword(f validator.FieldLevel) bool {
|
||||
return credential.IsValidAdminPassword(f.Field().String())
|
||||
}
|
||||
|
||||
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
|
||||
@@ -91,7 +109,7 @@ func validateField[T interface{}](c *gin.Context, field T) (T, error) {
|
||||
case http.MethodGet:
|
||||
bindErr = c.ShouldBindQuery(&field)
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
bindErr = c.ShouldBindJSON(&field)
|
||||
bindErr = strictBindJSON(c, &field)
|
||||
}
|
||||
if bindErr != nil {
|
||||
vo.FailValidation(
|
||||
@@ -202,8 +220,38 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
|
||||
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
|
||||
)
|
||||
case "credentialStr":
|
||||
// Причина называет набор и границы ровно так же, как это делает
|
||||
// `peerName`. Прежнее «содержит недопустимые символы» не отвечало на
|
||||
// вопрос оператора: набор символов логина ему негде посмотреть, а
|
||||
// отказ по длине этой фразой описывался как отказ по символам.
|
||||
described.Code = constant.ErrCodeCredentialStr
|
||||
described.Message = fmt.Sprintf("поле %q содержит недопустимые символы", field)
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", credential.AdminUsernameMinLength),
|
||||
"max": fmt.Sprintf("%d", credential.AdminUsernameMaxLength),
|
||||
"charset": credential.AdminUsernameCharset,
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"поле %q: от %d до %d символов из набора %s",
|
||||
field, credential.AdminUsernameMinLength, credential.AdminUsernameMaxLength, credential.AdminUsernameCharset,
|
||||
)
|
||||
case "adminPassword":
|
||||
// Причина называет ОБЕ границы, потому что их две и они в разных
|
||||
// единицах. Отказ «не длиннее 64 символов» на пароле из 40 эмодзи был
|
||||
// бы неправдой: символов там 40, а байтов 160, и оператор, послушав
|
||||
// такую причину, сокращал бы пароль, который отвергнут не за это.
|
||||
described.Code = constant.ErrCodeAdminPassword
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", credential.AdminPasswordMinLength),
|
||||
"max": fmt.Sprintf("%d", credential.AdminPasswordMaxLength),
|
||||
"maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes),
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
field,
|
||||
credential.AdminPasswordMinLength,
|
||||
credential.AdminPasswordMaxLength,
|
||||
credential.AdminPasswordMaxBytes,
|
||||
)
|
||||
default:
|
||||
described.Code = constant.ErrCodeRuleUnknown
|
||||
described.Params = map[string]string{"rule": fieldErr.Tag()}
|
||||
|
||||
@@ -4,40 +4,63 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Набор символов логина и пароля закреплён ФАКТИЧЕСКИМ множеством.
|
||||
// Правила валидатора обязаны спрашивать КОНТРАКТ, а не собственную копию.
|
||||
//
|
||||
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
||||
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
||||
// Новая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
||||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, оркестратор его
|
||||
// набор символов не ограничивает, и сужение правила означало бы, что установка
|
||||
// с логином вроде `admin.ops` перестаёт пускать оператора в панель.
|
||||
//
|
||||
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
||||
// классе символов уронит его, а не вход администратора на живом сервере.
|
||||
func TestCredentialCharsetIsUnchanged(t *testing.T) {
|
||||
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
||||
|
||||
for _, symbol := range strings.Split(historical, "") {
|
||||
candidate := "admin" + symbol
|
||||
if !credentialStrPattern.MatchString(candidate) {
|
||||
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rejected := range []string{
|
||||
"admi", // короче шести символов
|
||||
strings.Repeat("a", 33), // длиннее тридцати двух
|
||||
"admin пробел", // пробел
|
||||
"админ1", // кириллица
|
||||
"admin\n1", // перевод строки
|
||||
"admin'1", // апостроф вне набора
|
||||
// Прежние версии этих проверок обращались к регекспу внутри слоя контроллеров и
|
||||
// потому не увидели бы расхождения между копией и правилом — а именно так этот
|
||||
// класс дефектов и появлялся: набор символов логина существовал в четырёх
|
||||
// местах, и все четыре разошлись. Сам контракт проверяется в пакете credential;
|
||||
// здесь проверяется ПРОВОДКА: тег валидатора и функция контракта отвечают
|
||||
// одинаково на одних и тех же значениях.
|
||||
func TestCredentialStrTagDelegatesToContract(t *testing.T) {
|
||||
for _, candidate := range []string{
|
||||
"hy2xsadmin",
|
||||
"admin.ops",
|
||||
"admin+1",
|
||||
"admi",
|
||||
"админ12",
|
||||
"admin пробел",
|
||||
strings.Repeat("a", credential.AdminUsernameMaxLength+1),
|
||||
} {
|
||||
if credentialStrPattern.MatchString(rejected) {
|
||||
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
|
||||
expected := credential.IsValidAdminUsername(candidate)
|
||||
if got := validate.Var(candidate, "credentialStr") == nil; got != expected {
|
||||
t.Errorf("тег credentialStr на %q дал %v, контракт — %v", candidate, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Тот же вопрос про пароль, и он важнее.
|
||||
//
|
||||
// Границы пароля стояли прямо в теге (`min=6,max=64`), и тег НЕ МОГ выразить
|
||||
// предел bcrypt в байтах — правила длины в байтах у go-playground/validator
|
||||
// нет. Поэтому пароль из 64 кириллических букв проходил валидацию формы и
|
||||
// падал в хешировании, возвращаясь оператору системной ошибкой. Проверка
|
||||
// закрепляет, что правило теперь одно и что оно то же самое, которым
|
||||
// пользуются bootstrap-установка и `reset-admin`.
|
||||
func TestAdminPasswordTagDelegatesToContract(t *testing.T) {
|
||||
for _, candidate := range []string{
|
||||
"bootstrap-password",
|
||||
"abcde ",
|
||||
strings.Repeat("a", credential.AdminPasswordMinLength-1),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength+1),
|
||||
strings.Repeat("я", 36),
|
||||
strings.Repeat("я", 37),
|
||||
strings.Repeat("😀", 18),
|
||||
strings.Repeat("😀", 19),
|
||||
strings.Repeat("я", credential.AdminPasswordMaxLength),
|
||||
"abcde\n",
|
||||
} {
|
||||
expected := credential.IsValidAdminPassword(candidate)
|
||||
if got := validate.Var(candidate, "adminPassword") == nil; got != expected {
|
||||
t.Errorf(
|
||||
"тег adminPassword на значении из %d символов / %d байт дал %v, контракт — %v",
|
||||
len([]rune(candidate)), len(candidate), got, expected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/util"
|
||||
@@ -132,7 +134,14 @@ func ensureSecureBootstrapAdmin() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
|
||||
// Пароль читается КАК ЕСТЬ.
|
||||
//
|
||||
// Здесь стоял strings.TrimSpace, и он противоречил контракту продукта:
|
||||
// пробел по краям объявлен частью пароля и не снимается ни на форме входа,
|
||||
// ни при смене пароля, ни в оркестраторе. То есть bootstrap-путь
|
||||
// гарантированно создавал учётную запись НЕ с тем паролем, который оператор
|
||||
// записал в hy2xs.env, а войти в неё он пытался бы исходным значением.
|
||||
adminPassword := os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")
|
||||
if adminPassword == "" {
|
||||
return errors.New(
|
||||
"HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" +
|
||||
@@ -144,6 +153,28 @@ func ensureSecureBootstrapAdmin() error {
|
||||
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
||||
)
|
||||
}
|
||||
|
||||
// Контракт проверяется ЗДЕСЬ, а не только при хешировании.
|
||||
//
|
||||
// Отказ bcrypt на непригодном пароле выглядел бы как «bcrypt: password
|
||||
// length exceeds 72 bytes» в journal — сообщение, по которому оператор не
|
||||
// поймёт ни что чинить, ни где. Причина же чинится одной строкой в
|
||||
// hy2xs.env, и назвать её обязан тот, кто знает про этот файл.
|
||||
if !credential.IsValidAdminPassword(adminPassword) {
|
||||
return fmt.Errorf(
|
||||
"HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели: "+
|
||||
"от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF.\n"+
|
||||
"Набор символов не ограничен, пробелы по краям являются частью пароля.\n"+
|
||||
"Учётная запись администратора с таким значением создана НЕ будет: "+
|
||||
"установка завершилась бы успешно, а войти в панель было бы нельзя.\n"+
|
||||
"Исправьте значение в /etc/hy2xs/hy2xs.env и запустите\n"+
|
||||
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
||||
credential.AdminPasswordMinLength,
|
||||
credential.AdminPasswordMaxLength,
|
||||
credential.AdminPasswordMaxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
|
||||
status := int64(1)
|
||||
tokenVersion := int64(1)
|
||||
@@ -709,6 +740,7 @@ func tableExists(tableName string) bool {
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ
|
||||
// придумывает его сам.
|
||||
//
|
||||
|
||||
@@ -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[],
|
||||
};
|
||||
}
|
||||
@@ -21,8 +21,13 @@ export default {
|
||||
password: "Password",
|
||||
login: "Login",
|
||||
capsLockOn: "Caps lock is On",
|
||||
usernameFormatIncorrect: "Username format is incorrect",
|
||||
passwordFormatIncorrect: "Password format is incorrect",
|
||||
},
|
||||
// Требования к учётным данным администратора: общие для формы входа и формы
|
||||
// смены пароля. См. комментарий в ru.ts.
|
||||
credentials: {
|
||||
usernameFormat: "Username: {min} to {max} characters from {charset}",
|
||||
passwordFormat:
|
||||
"Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF",
|
||||
},
|
||||
dashboard: {
|
||||
stale: "Dashboard data is stale. Retrying automatically...",
|
||||
@@ -161,7 +166,9 @@ export default {
|
||||
gt: "“{field}”: must be greater than {gt}",
|
||||
peer_name:
|
||||
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
|
||||
credential_format: "“{field}”: contains characters that are not allowed",
|
||||
credential_format: "“{field}”: {min} to {max} characters from {charset}",
|
||||
admin_password_format:
|
||||
"“{field}”: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF",
|
||||
rule_violated: "“{field}”: value is not acceptable",
|
||||
validation_failed: "Validation failed",
|
||||
body_invalid:
|
||||
|
||||
@@ -19,8 +19,24 @@ export default {
|
||||
password: "Пароль",
|
||||
login: "Войти",
|
||||
capsLockOn: "Caps Lock включён",
|
||||
usernameFormatIncorrect: "Неверный формат логина",
|
||||
passwordFormatIncorrect: "Неверный формат пароля",
|
||||
},
|
||||
// Требования к учётным данным администратора. Фразы общие для формы входа и
|
||||
// формы смены пароля: требование одно, и второй его формулировки быть не
|
||||
// должно — расхождение здесь означало бы, что оператору обещают разное про
|
||||
// одно и то же поле.
|
||||
//
|
||||
// Обе фразы НАЗЫВАЮТ требование, а не сообщают о его нарушении. Прежние
|
||||
// «Неверный формат логина» и «Некорректное значение» не давали оператору ни
|
||||
// одного способа узнать, что именно от него хотят: набор символов логина
|
||||
// приходит из hy2xs.env, и посмотреть его в панели негде.
|
||||
credentials: {
|
||||
usernameFormat: "Логин: от {min} до {max} символов из набора {charset}",
|
||||
// Фраза называет ОБЕ границы, потому что их две и они в разных единицах.
|
||||
// Пароль из 40 эмодзи укладывается в 64 символа и не укладывается в 72
|
||||
// байта; сообщение «не длиннее {max} символов» отправило бы оператора
|
||||
// сокращать пароль, отвергнутый не за это.
|
||||
passwordFormat:
|
||||
"Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
},
|
||||
dashboard: {
|
||||
stale:
|
||||
@@ -167,7 +183,13 @@ export default {
|
||||
gt: "«{field}»: значение должно быть больше {gt}",
|
||||
peer_name:
|
||||
"«{field}»: от {min} до {max} символов из набора {charset}. Пробелы, кириллица и знаки / : ; . недопустимы",
|
||||
credential_format: "«{field}»: недопустимые символы",
|
||||
// Сервер присылает границы и набор в params — фраза называет требование
|
||||
// целиком. Прежнее «недопустимые символы» вдобавок описывало этими же
|
||||
// словами отказ по ДЛИНЕ: правило одно, и оно проверяет и то, и другое.
|
||||
credential_format:
|
||||
"«{field}»: от {min} до {max} символов из набора {charset}",
|
||||
admin_password_format:
|
||||
"«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
rule_violated: "«{field}»: значение не подходит",
|
||||
validation_failed: "Проверка данных не пройдена",
|
||||
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
|
||||
|
||||
@@ -44,6 +44,11 @@ export const ERR_CODE = {
|
||||
ruleViolated: "rule_violated",
|
||||
peerName: "peer_name",
|
||||
credentialFormat: "credential_format",
|
||||
// Правило пароля администратора целиком: длина в символах, длина в байтах
|
||||
// (предел bcrypt) и запрет управляющих символов. Отдельный код нужен потому,
|
||||
// что min_length/max_length не умеют назвать отказ по БАЙТАМ, а он бывает у
|
||||
// пароля, укладывающегося в границу символов.
|
||||
adminPasswordFormat: "admin_password_format",
|
||||
peerNameTaken: "peer_name_taken",
|
||||
peerNameReserved: "peer_name_reserved",
|
||||
peerBootstrapLocked: "peer_bootstrap_identity_locked",
|
||||
|
||||
@@ -33,6 +33,12 @@ import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { adminChangePasswordApi } from "@/api/admin";
|
||||
import { useAdminStore } from "@/store/modules/admin";
|
||||
import {
|
||||
ADMIN_PASSWORD_MAX_BYTES,
|
||||
ADMIN_PASSWORD_MAX_LENGTH,
|
||||
ADMIN_PASSWORD_MIN_LENGTH,
|
||||
adminPasswordFormRule,
|
||||
} from "@/constants/credentials";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
@@ -46,7 +52,33 @@ const form = reactive({
|
||||
newPassword: "",
|
||||
});
|
||||
|
||||
const passwordPattern = /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,64}$/;
|
||||
// Проверяется ТОЛЬКО длина, и она берётся из общего контракта.
|
||||
//
|
||||
// Здесь стояло правило набора символов `[a-zA-Z0-9!@#$%^&*()_+-=]`, которого
|
||||
// сервер не предъявляет ни при смене пароля, ни при установке. То есть панель
|
||||
// отказывала оператору в пароле, который сервер принял бы, и сообщала об этом
|
||||
// фразой «Некорректное значение», не называя ни одного требования.
|
||||
//
|
||||
// Границы совпадают с формой входа не случайно: пока они расходились, длинный
|
||||
// пароль, назначенный здесь, невозможно было ввести там.
|
||||
//
|
||||
// Правило — собственное, а не встроенные `min`/`max` Element Plus: те
|
||||
// делегируются `async-validator`, который считает длину строки в единицах
|
||||
// UTF-16, тогда как сервер считает code points и вдобавок ограничивает длину в
|
||||
// БАЙТАХ пределом bcrypt. Пароль из 64 кириллических букв проходил здесь и
|
||||
// возвращался с сервера системной ошибкой хеширования.
|
||||
//
|
||||
// Комментарий записан строчными `//`, а не блоком: скан релизных гейтов
|
||||
// отбрасывает только их, и объяснение, называющее убранную конструкцию по
|
||||
// имени, иначе роняет проверку «этой конструкции здесь больше нет».
|
||||
const passwordRule = adminPasswordFormRule(
|
||||
t("credentials.passwordFormat", {
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||
maxBytes: ADMIN_PASSWORD_MAX_BYTES,
|
||||
})
|
||||
);
|
||||
|
||||
const rules: FormRules = {
|
||||
oldPassword: [
|
||||
{
|
||||
@@ -54,11 +86,7 @@ const rules: FormRules = {
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: passwordPattern,
|
||||
message: t("common.invalid"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{ ...passwordRule },
|
||||
],
|
||||
newPassword: [
|
||||
{
|
||||
@@ -66,11 +94,7 @@ const rules: FormRules = {
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: passwordPattern,
|
||||
message: t("common.invalid"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{ ...passwordRule },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -83,6 +83,16 @@ import { useAdminStore } from "@/store/modules/admin";
|
||||
// Зависимость API
|
||||
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
|
||||
import { AdminLoginDto } from "@/api/admin/types";
|
||||
import {
|
||||
ADMIN_PASSWORD_MAX_BYTES,
|
||||
ADMIN_PASSWORD_MAX_LENGTH,
|
||||
ADMIN_PASSWORD_MIN_LENGTH,
|
||||
ADMIN_USERNAME_CHARSET,
|
||||
ADMIN_USERNAME_MAX_LENGTH,
|
||||
ADMIN_USERNAME_MIN_LENGTH,
|
||||
ADMIN_USERNAME_PATTERN,
|
||||
adminPasswordFormRule,
|
||||
} from "@/constants/credentials";
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const route = useRoute();
|
||||
@@ -114,6 +124,21 @@ const loginForm = ref<AdminLoginDto>({
|
||||
pass: "",
|
||||
});
|
||||
|
||||
/**
|
||||
* Правила формы входа берутся из общего контракта, а не пишутся здесь.
|
||||
*
|
||||
* У пароля проверяется ТОЛЬКО длина. Прежнее правило требовало ещё и набор
|
||||
* символов, из-за чего форма входа отказывалась отправлять пароль, который
|
||||
* сервер принимает: набор пароля сервер не ограничивает нигде. Проверка,
|
||||
* которая умеет только запереть оператора и ничего не защищает, — не проверка.
|
||||
*
|
||||
* Правило пароля — собственное, а не встроенные `min`/`max` Element Plus.
|
||||
* Element Plus делегирует правила формы библиотеке `async-validator`, а она
|
||||
* сравнивает `min`/`max` строки с `String.prototype.length`, то есть считает
|
||||
* единицы UTF-16, тогда как сервер считает code points. Пароль из трёх эмодзи
|
||||
* имел `value.length === 6` и проходил минимум формы, а сервер видел три руны и
|
||||
* отказывал. См. isValidAdminPassword.
|
||||
*/
|
||||
const loginRules = {
|
||||
username: [
|
||||
{
|
||||
@@ -122,8 +147,12 @@ const loginRules = {
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
|
||||
message: t("login.usernameFormatIncorrect"),
|
||||
pattern: ADMIN_USERNAME_PATTERN,
|
||||
message: t("credentials.usernameFormat", {
|
||||
min: ADMIN_USERNAME_MIN_LENGTH,
|
||||
max: ADMIN_USERNAME_MAX_LENGTH,
|
||||
charset: ADMIN_USERNAME_CHARSET,
|
||||
}),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
@@ -133,11 +162,13 @@ const loginRules = {
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
|
||||
message: t("login.passwordFormatIncorrect"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
adminPasswordFormRule(
|
||||
t("credentials.passwordFormat", {
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||
maxBytes: ADMIN_PASSWORD_MAX_BYTES,
|
||||
})
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -205,10 +236,61 @@ const handleLogin = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Видимое поле формы входа — это `el-form-item`, а не `el-input`.
|
||||
//
|
||||
// Рамка и фон нарисованы здесь, потому что внутрь одного поля кладутся три
|
||||
// вещи: иконка, ввод и переключатель видимости пароля. `el-input` занимает лишь
|
||||
// среднюю из них.
|
||||
//
|
||||
// Отсюда и дефект индикации ошибки, который был виден на форме. Element Plus
|
||||
// рисует состояние отказа на `el-input__wrapper` правилом
|
||||
//
|
||||
// .el-form-item.is-error .el-form-item__content .el-input__wrapper
|
||||
//
|
||||
// то есть селектором из ЧЕТЫРЁХ классов, а здешнее гашение тени записывалось
|
||||
// селектором из трёх — и проигрывало по специфичности. В результате красная
|
||||
// рамка ложилась вокруг одного лишь поля ввода: у логина она начиналась после
|
||||
// иконки пользователя, у пароля обрывалась перед «глазом», и ни одна её сторона
|
||||
// не совпадала с видимой границей поля.
|
||||
//
|
||||
// Чинится это не увеличением специфичности ради победы, а переносом индикации
|
||||
// на тот элемент, который оператор и видит полем.
|
||||
.el-form-item {
|
||||
background: var(--subMenuBg);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
border-radius: 5px;
|
||||
|
||||
// Просвет под полем принадлежит сообщению об отказе: `el-form-item__error`
|
||||
// позиционируется абсолютно от `top: 100%`, то есть живёт ВНЕ рамки. При
|
||||
// стандартных 18px оно вплотную прижималось к границе снизу и к следующему
|
||||
// полю сверху.
|
||||
margin-bottom: 26px;
|
||||
|
||||
&.is-error {
|
||||
border-color: var(--el-color-danger);
|
||||
|
||||
// Штатная индикация Element Plus гасится ЗДЕСЬ, а не в блоке `.el-input`:
|
||||
// селектор повторяет её собственный и добавляет атрибут scoped-стиля,
|
||||
// поэтому выигрывает по специфичности. Прежнее гашение стояло на два
|
||||
// класса ниже и проигрывало — из-за чего красный прямоугольник вокруг
|
||||
// одного лишь поля ввода и появлялся. `!important` здесь не нужен: правило
|
||||
// не сильнее чужого, а конкретнее.
|
||||
:deep(.el-form-item__content .el-input__wrapper) {
|
||||
&,
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.is-focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сообщение выравнивается по тексту поля, а не по краю рамки: иначе оно
|
||||
// висит на сдвиг левее всего, что находится над ним.
|
||||
:deep(.el-form-item__error) {
|
||||
padding-top: 6px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input {
|
||||
|
||||
@@ -52,6 +52,15 @@ const (
|
||||
ErrCodeGreaterThan string = "gt"
|
||||
ErrCodePeerName string = "peer_name"
|
||||
ErrCodeCredentialStr string = "credential_format"
|
||||
// ErrCodeAdminPassword — пароль администратора не удовлетворяет контракту.
|
||||
//
|
||||
// Отдельный код, а не пара min_length/max_length, потому что правило одно и
|
||||
// проверяет три вещи сразу: длину в символах, длину в байтах (предел
|
||||
// bcrypt) и отсутствие управляющих символов. Пока границы стояли двумя
|
||||
// тегами, отказ по байтам было нечем назвать — его просто не существовало,
|
||||
// и пароль из 64 кириллических букв доходил до хеширования, где становился
|
||||
// системной ошибкой.
|
||||
ErrCodeAdminPassword string = "admin_password_format"
|
||||
ErrCodeRuleUnknown string = "rule_violated"
|
||||
|
||||
// Доменные коды: правило соблюдено, но операция всё равно невозможна.
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
package dto
|
||||
|
||||
// Правило пароля здесь и в LoginDto обязано быть ОДНИМ И ТЕМ ЖЕ.
|
||||
//
|
||||
// Пока границы расходились, панель запирала оператора снаружи после операции,
|
||||
// которую сама же и предлагала: форма смены пароля принимала 64 символа, а
|
||||
// форма входа — 32, и назначенный длинный пароль становился паролем, с которым
|
||||
// невозможно войти.
|
||||
//
|
||||
// Числа из тегов ушли внутрь правила `adminPassword`. Записанные тегом
|
||||
// `min=6,max=64` они, кроме дублирования, были ещё и неполны: тег считает
|
||||
// символы Unicode, а bcrypt — байты, поэтому новый пароль из 64 кириллических
|
||||
// букв проходил проверку формы, доходил до util.HashPassword и возвращался
|
||||
// оператору системной ошибкой. Отказ по байтам тегом валидатора выразить
|
||||
// нечем — правила длины в байтах у него нет.
|
||||
//
|
||||
// Набора символов здесь нет и не было: пароль принадлежит оператору целиком.
|
||||
|
||||
type AdminChangePasswordDto struct {
|
||||
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,min=6,max=64"`
|
||||
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,min=6,max=64"`
|
||||
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,adminPassword"`
|
||||
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,adminPassword"`
|
||||
}
|
||||
|
||||
// Normalize: пароль НЕ триммится ни в одном из полей.
|
||||
//
|
||||
// Пустое поле обязано выглядеть незаполненным и получить отказ `required`: у
|
||||
// поля-указателя `required` считает значение заданным по самому факту
|
||||
// ненулевого указателя, поэтому `{"oldPassword":""}` иначе получило бы отказ по
|
||||
// длине вместо «поле обязательно».
|
||||
//
|
||||
// Пробел по краям при этом остаётся частью значения. Форма входа ведёт себя так
|
||||
// же (LoginDto.Normalize), и иначе быть не может: пароль, назначенный здесь,
|
||||
// вводится там.
|
||||
func (d *AdminChangePasswordDto) Normalize() {
|
||||
emptyToNil(&d.OldPassword)
|
||||
emptyToNil(&d.NewPassword)
|
||||
}
|
||||
|
||||
+52
-2
@@ -1,6 +1,56 @@
|
||||
package dto
|
||||
|
||||
// Контракт формы входа.
|
||||
//
|
||||
// Раньше на Username стояло `required,min=6,max=32,validateStr`, и правила
|
||||
// `validateStr` не существовало: оно пережило переименование в `credentialStr`
|
||||
// и осталось ссылкой на незарегистрированную функцию. go-playground/validator в
|
||||
// таком случае ПАНИКУЕТ, поэтому каждый POST /api/auth/login заканчивался
|
||||
// паникой, которую gin.Recovery превращал в HTTP 500 — панель после чистой
|
||||
// установки была недоступна целиком. Ни один тест этого не видел: проверялся
|
||||
// сам регексп, а не LoginDto через production-валидатор, и цепочка правил на
|
||||
// нулевом DTO обрывается на `required` раньше, чем доходит до испорченного
|
||||
// тега.
|
||||
//
|
||||
// Границы длины логина живут ВНУТРИ правила `credentialStr`, а не рядом с ним.
|
||||
// Два правила на одном поле уже приводили к отказу, который невозможно
|
||||
// объяснить: имя пира с `min=1,max=32,validateStr` принималось одним правилом
|
||||
// и отвергалось другим, а оператор видел «invalid». Здесь та же конструкция
|
||||
// была бы той же ошибкой.
|
||||
//
|
||||
// У пароля границы длины живут внутри правила `adminPassword` по той же
|
||||
// причине. Здесь стояло `min=6,max=64`, и эта запись была не только вторым
|
||||
// правилом на одном поле, но и НЕПОЛНОЙ: `max` считает символы Unicode, а
|
||||
// bcrypt — байты, поэтому 64 кириллические буквы (128 байт) проходили проверку
|
||||
// и падали в хешировании. Границу в байтах тегом валидатора не выразить.
|
||||
//
|
||||
// Набора символов у пароля НЕТ, и это осознанно: сервер нигде его не
|
||||
// ограничивает — ни при установке, ни при смене пароля. Ограничение на входе
|
||||
// не защищало бы ничего (значение всё равно сравнивается с хешем) и умело
|
||||
// только запирать оператора с паролем, который сервер принял бы. Единственное
|
||||
// исключение — управляющие символы, и оно принадлежит контракту, а не форме:
|
||||
// см. credential.IsValidAdminPassword.
|
||||
type LoginDto struct {
|
||||
Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"`
|
||||
Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=64"`
|
||||
Username *string `json:"username" form:"username" validate:"required,credentialStr"`
|
||||
Pass *string `json:"pass" form:"pass" validate:"required,adminPassword"`
|
||||
}
|
||||
|
||||
// Normalize: незаполненное поле обязано выглядеть незаполненным.
|
||||
//
|
||||
// У поля-указателя `required` считает значение заданным по ненулевому
|
||||
// указателю, поэтому `{"username":"","pass":""}` проходило `required` целиком и
|
||||
// получало отказ по набору символов и по длине — то есть оператор, отправивший
|
||||
// пустую форму, читал про недопустимые символы вместо «поле обязательно».
|
||||
//
|
||||
// Логин при этом ещё и триммится: скопированное из bootstrap-admin.secret
|
||||
// значение регулярно приезжает с пробелом или переводом строки на конце, и
|
||||
// раньше это давало «неверный логин или пароль» — отказ, который невозможно
|
||||
// связать с причиной.
|
||||
//
|
||||
// Пароль НЕ триммится. Набор его символов не ограничен, пробел по краям —
|
||||
// законная часть значения, и молчаливое его удаление означало бы, что панель
|
||||
// отправляет не то, что ввёл оператор.
|
||||
func (d *LoginDto) Normalize() {
|
||||
blankToNil(&d.Username)
|
||||
emptyToNil(&d.Pass)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,24 @@ func trimValue(field *string) {
|
||||
*field = strings.TrimSpace(*field)
|
||||
}
|
||||
|
||||
// emptyToNil: пустая строка означает «не задано», и ничего больше.
|
||||
//
|
||||
// Отличается от blankToNil тем, что НЕ трогает пробелы. Применяется к полям,
|
||||
// содержимое которых принадлежит оператору целиком, — к паролю: набор его
|
||||
// символов не ограничен, пробел по краям является частью значения, и
|
||||
// молчаливое его удаление означало бы, что на сервер уезжает не то, что было
|
||||
// введено. Пустое поле при этом обязано получить отказ `required`, а не отказ
|
||||
// по длине: у поля-указателя `required` считает значение заданным по самому
|
||||
// факту ненулевого указателя.
|
||||
func emptyToNil(field **string) {
|
||||
if *field == nil {
|
||||
return
|
||||
}
|
||||
if **field == "" {
|
||||
*field = nil
|
||||
}
|
||||
}
|
||||
|
||||
// zeroToNil: ноль у необязательного числового поля означает «не задано».
|
||||
//
|
||||
// Применяется ТОЛЬКО там, где ноль не является осмысленным значением:
|
||||
|
||||
@@ -119,4 +119,56 @@ func TestNormalizableIsImplemented(t *testing.T) {
|
||||
var _ Normalizable = (*PeerPageDto)(nil)
|
||||
var _ Normalizable = (*BaseDto)(nil)
|
||||
var _ Normalizable = (*LogDto)(nil)
|
||||
var _ Normalizable = (*LoginDto)(nil)
|
||||
var _ Normalizable = (*AdminChangePasswordDto)(nil)
|
||||
}
|
||||
|
||||
// Пароль НЕ триммится ни на одной из двух форм.
|
||||
//
|
||||
// Набор его символов не ограничен, пробел по краям — часть значения, и
|
||||
// молчаливое его удаление означало бы, что на сервер уезжает не то, что ввёл
|
||||
// оператор. Логин при этом триммится: скопированное из bootstrap-admin.secret
|
||||
// значение регулярно приезжает с пробелом или переводом строки на конце.
|
||||
func TestPasswordIsNeverTrimmedByNormalization(t *testing.T) {
|
||||
login := LoginDto{Username: strPtr(" hy2xsadmin\n"), Pass: strPtr(" пароль ")}
|
||||
login.Normalize()
|
||||
|
||||
if login.Username == nil || *login.Username != "hy2xsadmin" {
|
||||
t.Fatalf("логин не приведён к каноничному виду: %v", login.Username)
|
||||
}
|
||||
if login.Pass == nil || *login.Pass != " пароль " {
|
||||
t.Fatalf("пароль изменён нормализацией: %q", *login.Pass)
|
||||
}
|
||||
|
||||
change := AdminChangePasswordDto{
|
||||
OldPassword: strPtr("abcde "),
|
||||
NewPassword: strPtr(" abcdef"),
|
||||
}
|
||||
change.Normalize()
|
||||
|
||||
if change.OldPassword == nil || *change.OldPassword != "abcde " {
|
||||
t.Fatalf("старый пароль изменён нормализацией: %v", change.OldPassword)
|
||||
}
|
||||
if change.NewPassword == nil || *change.NewPassword != " abcdef" {
|
||||
t.Fatalf("новый пароль изменён нормализацией: %v", change.NewPassword)
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой пароль обязан выглядеть НЕЗАДАННЫМ.
|
||||
//
|
||||
// У поля-указателя `required` считает значение заданным по самому факту
|
||||
// ненулевого указателя, поэтому без этого шага оператор, отправивший пустую
|
||||
// форму, получал отказ по длине вместо «поле обязательно».
|
||||
func TestEmptyPasswordBecomesAbsent(t *testing.T) {
|
||||
login := LoginDto{Username: strPtr("hy2xsadmin"), Pass: strPtr("")}
|
||||
login.Normalize()
|
||||
if login.Pass != nil {
|
||||
t.Fatalf("пустой пароль остался заданным: %q", *login.Pass)
|
||||
}
|
||||
|
||||
change := AdminChangePasswordDto{OldPassword: strPtr(""), NewPassword: strPtr("")}
|
||||
change.Normalize()
|
||||
if change.OldPassword != nil || change.NewPassword != nil {
|
||||
t.Fatal("пустые пароли формы смены остались заданными")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
@@ -58,6 +59,23 @@ func Login(username string, plainPassword string) (string, bool, error) {
|
||||
if tokenErr != nil {
|
||||
return "", false, tokenErr
|
||||
}
|
||||
// Отметка о входе ставится здесь, а не в слое контроллеров.
|
||||
//
|
||||
// Колонка `last_login_at` объявлена и в схеме, и в entity, а писать её было
|
||||
// некому: UpdateAdminLastLoginAt существовал и не вызывался ниоткуда, то
|
||||
// есть панель показывала бы «последний вход» как 1970 год на живом сервере.
|
||||
// Место выбрано так, чтобы записать её было невозможно забыть: успешная
|
||||
// проверка пароля — единственная дверь, и она здесь одна.
|
||||
//
|
||||
// Отказ записи НЕ отменяет вход. Учётные данные уже подтверждены, и
|
||||
// превращать неудачу бухгалтерии в отказ аутентификации значило бы закрыть
|
||||
// панель из-за строки статистики. Молчать при этом тоже нельзя: неписаная
|
||||
// отметка — расхождение между тем, что показывает панель, и тем, что
|
||||
// произошло.
|
||||
if err = UpdateAdminLastLoginAt(*admin.Id, time.Now().UnixMilli()); err != nil {
|
||||
logrus.Errorf("не удалось записать время входа администратора %d: %v", *admin.Id, err)
|
||||
}
|
||||
|
||||
// Признак «пароль в старом формате, заставить сменить» отсюда убран вместе
|
||||
// с поддержкой самого формата: единственный источник этого требования —
|
||||
// флаг force_password_change, который ставит установка.
|
||||
|
||||
+31
-2
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"hy2xs-admin/credential"
|
||||
)
|
||||
|
||||
// Функции SHA224String здесь больше нет, как и ветки сравнения паролей по ней.
|
||||
@@ -32,9 +33,37 @@ import (
|
||||
// осталась запасным путём проверки пароля со слабым алгоритмом — в обработчике
|
||||
// логина, доступном без аутентификации.
|
||||
|
||||
// HashPassword проверяет пароль ОБЩИМ контрактом и хеширует его.
|
||||
//
|
||||
// Здесь стояла собственная проверка:
|
||||
//
|
||||
// if len(strings.TrimSpace(password)) < 6 { … }
|
||||
//
|
||||
// и она была третьей копией правила о пароле — вдобавок расходящейся с двумя
|
||||
// остальными сразу по двум признакам.
|
||||
//
|
||||
// Первое: `TrimSpace`. Контракт продукта прямо объявляет пробел по краям частью
|
||||
// пароля и не снимает его нигде — ни в нормализации DTO, ни при чтении
|
||||
// bootstrap-окружения. Пароль `"abcde "` считался корректным формой входа,
|
||||
// формой смены и оркестратором, а здесь превращался в `"abcde"` и получал
|
||||
// отказ. То есть значение, принятое всеми дверями продукта, невозможно было
|
||||
// захешировать.
|
||||
//
|
||||
// Второе: у проверки не было верхней границы вовсе, поэтому предел bcrypt в 72
|
||||
// БАЙТА всплывал ошибкой хеширования уже после того, как все проверки пройдены.
|
||||
//
|
||||
// Теперь правило одно на весь продукт. Возможность позвать его отсюда — прямая
|
||||
// причина, по которой контракт живёт в пакете `credential`, а не в `service`:
|
||||
// `service` импортирует `util`, и обратный импорт был бы циклическим.
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(strings.TrimSpace(password)) < 6 {
|
||||
return "", errors.New("password too short")
|
||||
if !credential.IsValidAdminPassword(password) {
|
||||
return "", fmt.Errorf(
|
||||
"пароль администратора не удовлетворяет контракту: от %d до %d символов, "+
|
||||
"не более %d байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
credential.AdminPasswordMinLength,
|
||||
credential.AdminPasswordMaxLength,
|
||||
credential.AdminPasswordMaxBytes,
|
||||
)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/credential"
|
||||
)
|
||||
|
||||
// Здесь был TestSHA224String, который печатал хеш в stdout и ничего не
|
||||
@@ -41,12 +43,81 @@ func TestHashPasswordIsSalted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPasswordRejectsShortPassword(t *testing.T) {
|
||||
if _, err := HashPassword("12345"); err == nil {
|
||||
t.Fatal("слишком короткий пароль принят")
|
||||
// HashPassword проверяет ОБЩИЙ контракт, а не собственную копию правила.
|
||||
//
|
||||
// Здесь стояло `len(strings.TrimSpace(password)) < 6`, и это была третья копия
|
||||
// правила о пароле, расходящаяся с двумя остальными. Тест закреплял её обе
|
||||
// ошибки: он требовал ОТКАЗА на шести пробелах — то есть на значении, которое
|
||||
// контракт продукта принимает, — и не проверял верхнюю границу вовсе.
|
||||
//
|
||||
// Шесть пробелов принимаются намеренно. Пробел объявлен частью пароля, набор
|
||||
// символов не ограничен, и «слабый пароль» здесь не тема: значение назначает
|
||||
// оператор, а хеширование не является местом, где оценивают стойкость.
|
||||
func TestHashPasswordFollowsTheCredentialContract(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
password string
|
||||
}{
|
||||
{"ровно минимум", strings.Repeat("a", credential.AdminPasswordMinLength)},
|
||||
{"на символ короче минимума", strings.Repeat("a", credential.AdminPasswordMinLength-1)},
|
||||
{"шесть пробелов", strings.Repeat(" ", credential.AdminPasswordMinLength)},
|
||||
{"краевой пробел", "abcde "},
|
||||
{"ровно максимум в ASCII", strings.Repeat("a", credential.AdminPasswordMaxLength)},
|
||||
{"на символ длиннее максимума", strings.Repeat("a", credential.AdminPasswordMaxLength+1)},
|
||||
{"ровно предел bcrypt", strings.Repeat("я", 36)},
|
||||
{"на букву длиннее предела bcrypt", strings.Repeat("я", 37)},
|
||||
{"эмодзи ровно в предел", strings.Repeat("😀", 18)},
|
||||
{"эмодзи за пределом", strings.Repeat("😀", 19)},
|
||||
{"64 кириллические буквы", strings.Repeat("я", credential.AdminPasswordMaxLength)},
|
||||
{"перевод строки", "abcde\n"},
|
||||
{"пусто", ""},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
_, err := HashPassword(testCase.password)
|
||||
accepted := err == nil
|
||||
expected := credential.IsValidAdminPassword(testCase.password)
|
||||
if accepted != expected {
|
||||
t.Fatalf(
|
||||
"HashPassword принял=%v, контракт=%v (%d символов, %d байт): %v",
|
||||
accepted, expected,
|
||||
len([]rune(testCase.password)), len(testCase.password), err,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Контракт обязан быть НЕ ШИРЕ того, что умеет bcrypt.
|
||||
//
|
||||
// Это и было исходным дефектом: контракт принимал 64 символа любого алфавита, а
|
||||
// bcrypt.GenerateFromPassword отвечает ErrPasswordTooLong строго при длине
|
||||
// больше 72 БАЙТ. Проверяются граничные значения, на которых расхождение
|
||||
// проявляется: всё, что контракт принял, обязано хешироваться.
|
||||
func TestEveryPasswordAcceptedByContractCanBeHashed(t *testing.T) {
|
||||
candidates := []string{
|
||||
strings.Repeat("a", credential.AdminPasswordMinLength),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength),
|
||||
strings.Repeat("я", 36),
|
||||
strings.Repeat("😀", 18),
|
||||
strings.Repeat(" ", credential.AdminPasswordMinLength),
|
||||
"пароль-администратора",
|
||||
`p@$$w0rd"\'#;`,
|
||||
"abcde ",
|
||||
}
|
||||
|
||||
for _, password := range candidates {
|
||||
if !credential.IsValidAdminPassword(password) {
|
||||
t.Fatalf("подготовка: контракт отверг значение из %d байт", len(password))
|
||||
}
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("контракт принял пароль из %d байт, а хеширование отказало: %v", len(password), err)
|
||||
}
|
||||
if !VerifyPassword(password, hash) {
|
||||
t.Fatalf("пароль из %d байт не проходит проверку собственным хешем", len(password))
|
||||
}
|
||||
if _, err := HashPassword(" "); err == nil {
|
||||
t.Fatal("пароль из пробелов принят")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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-04 | `1.0.0-rc2` | `82e5ca40` | clean install + runtime, Debian 13 | [RUNTIME REJECTED — вход в панель недоступен](2026-09-04-v1.0.0-rc2-runtime-findings.md) |
|
||||
| 2026-09-07 | `1.0.0-rc3` | `bf10810c` | clean install + failure rollback, Debian 13 | [RUNTIME REJECTED — installer smoke блокируется middleware](2026-09-07-v1.0.0-rc3-runtime-findings.md) |
|
||||
|
||||
## Открытые дефекты приёмки
|
||||
|
||||
| Прогон | Дефекты |
|
||||
| --- | --- |
|
||||
| 2026-09-01, `1.0.0-rc1` | [UX-01…UX-05 и найденное сверх отчёта](2026-09-01-v1.0.0-rc1-ux-findings.md) |
|
||||
| 2026-09-04, `1.0.0-rc2` | [LOGIN-01…LOGIN-08](2026-09-04-v1.0.0-rc2-runtime-findings.md) — все закрыты |
|
||||
| 2026-09-07, `1.0.0-rc3` | [RC3-LOGIN-01…04, RC3-ROLLBACK-01](2026-09-07-v1.0.0-rc3-runtime-findings.md) — исправления подготовлены для RC4 |
|
||||
|
||||
## Разборы кода между прогонами
|
||||
|
||||
|
||||
@@ -1117,6 +1117,18 @@ B входит со своим устаревшим 0 -> lastOnline снова 0
|
||||
Если переменной нет, а создавать учётную запись нужно, админка **отказывает в
|
||||
старте** с сообщением, называющим причину и способ починки.
|
||||
|
||||
То же и при значении вне контракта пароля: `HY2XS_ADMIN_INITIAL_PASSWORD`
|
||||
проверяется против того же правила, что и форма входа
|
||||
(`apps/credential/admin.go`), и непригодное значение роняет старт с внятным
|
||||
текстом, а не доходит до `bcrypt.GenerateFromPassword`, чтобы вернуться оттуда
|
||||
строкой `password length exceeds 72 bytes`. Учётная запись при этом не
|
||||
создаётся: установка иначе завершилась бы успешно, а войти было бы нельзя.
|
||||
|
||||
Пароль читается **как есть**: пробелы по краям объявлены его частью и не
|
||||
снимаются ни здесь, ни при хешировании, ни на форме входа. Раньше bootstrap
|
||||
делал `strings.TrimSpace`, и учётная запись создавалась не с тем паролем,
|
||||
который оператор записал в `hy2xs.env`.
|
||||
|
||||
Раньше она в этом случае придумывала пароль сама и печатала его двумя
|
||||
`logrus.Warnf` — открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, то есть в
|
||||
файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо
|
||||
|
||||
@@ -259,3 +259,195 @@ control plane показывал всех пиров офлайн. Теперь
|
||||
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
|
||||
ни config API, ни таблица `config`, ни настройки панели его не содержат и не
|
||||
могут переопределить.
|
||||
|
||||
---
|
||||
|
||||
## 10. Форма входа
|
||||
|
||||
**Правило.** Панель не имеет права быть строже сервера. Значение, которое
|
||||
сервер принял бы, форма обязана отправить.
|
||||
|
||||
### Где живёт контракт
|
||||
|
||||
Требования к логину и паролю администратора объявлены **один раз**, в
|
||||
`apps/credential/admin.go`:
|
||||
|
||||
| Что | Значение | Владелец |
|
||||
| --- | --- | --- |
|
||||
| Длина логина | 6-32 символа | `AdminUsernameMinLength` / `AdminUsernameMaxLength` |
|
||||
| Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` |
|
||||
| Длина пароля | 6-64 символа Unicode | `AdminPasswordMinLength` / `AdminPasswordMaxLength` |
|
||||
| Размер пароля | не более 72 байт в UTF-8 | `AdminPasswordMaxBytes` |
|
||||
| Домен пароля | документированный домен systemd `EnvironmentFile=`: валидный UTF-8 без NUL, U+FEFF, суррогатов и noncharacters | `IsEnvTransportableText` |
|
||||
| Набор символов пароля | не ограничен, кроме `Cc` | `hasForbiddenRune` |
|
||||
| Пробелы по краям пароля | часть значения, не снимаются | — |
|
||||
|
||||
Контракт живёт в отдельном **leaf-пакете**, а не в `service`, и это не
|
||||
вкусовщина. Его зовут `util.HashPassword` и слой данных при создании первой
|
||||
учётной записи, а `service` импортирует `util` — обратный импорт был бы
|
||||
циклическим. Пока контракт лежал в `service`, `HashPassword` завёл собственную
|
||||
проверку `len(strings.TrimSpace(password)) < 6`, и она разошлась с остальным
|
||||
продуктом.
|
||||
|
||||
Остальные стороны продукта только повторяют этот контракт, и каждая копия
|
||||
сверяется с оригиналом тестом, читающим Go-исходник:
|
||||
|
||||
* панель — `apps/frontend/src/constants/credentials.ts`
|
||||
(`tools/test/frontend-contract.test.ts`);
|
||||
* оркестратор — `orchestrator/src/config/profile.ts`
|
||||
(`orchestrator/test/admin-credentials.test.ts`);
|
||||
* правила валидатора — `credentialStr` и `adminPassword` в
|
||||
`apps/controller/validator.go`, длина живёт ВНУТРИ них.
|
||||
|
||||
### Почему у пароля нет набора символов
|
||||
|
||||
Пароль назначает оператор — установкой через `HY2XS_ADMIN_INITIAL_PASSWORD` или
|
||||
формой смены. Сервер его набор не проверяет нигде: значение сравнивается с
|
||||
bcrypt-хешем. Ограничение набора на форме не защищает ничего и умеет только
|
||||
отвергнуть пароль, который сервер принял бы.
|
||||
|
||||
Исключения два, и они **разного происхождения**. Их важно не путать: одно
|
||||
описывает чужое ограничение, другое — наше решение.
|
||||
|
||||
**Домен systemd — не наше правило.** Первый пароль администратора уезжает в
|
||||
`/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`. Перед тем
|
||||
как принять пару, systemd прогоняет ключ и значение через `utf8_is_valid`
|
||||
(`src/basic/env-file.c`, `check_utf8ness_and_warn`), и отказ там возвращает
|
||||
`-EINVAL`: это **незагруженный файл окружения**, то есть юнит, который не
|
||||
стартует, а не предупреждение. `unichar_is_valid` (`src/basic/utf8.c`)
|
||||
отвергает:
|
||||
|
||||
```text
|
||||
U+D800..U+DFFF суррогаты
|
||||
U+FDD0..U+FDEF noncharacters
|
||||
(cp & 0xFFFE) == 0xFFFE U+FFFE, U+FFFF, U+1FFFE, … U+10FFFF
|
||||
```
|
||||
|
||||
плюс встроенный NUL, U+FEFF и любую невалидную последовательность UTF-8.
|
||||
|
||||
U+FEFF запрещён публичной документацией EnvironmentFile. Реализация systemd
|
||||
v257.13 случайно пропускает его (`0xFEFF & 0xFFFE == 0xFEFE`); HY2XS следует
|
||||
документированному контракту, а не закрепляет ошибку конкретной версии.
|
||||
|
||||
Пока контракт этого не знал, пароль `abcde` + `U+FDD0` — шесть символов, восемь
|
||||
байт, ни одного управляющего — проходил панель, оркестратор, DTO и хеширование,
|
||||
записывался в `hy2xs.env`, и после этого админка не поднималась. Тот же класс
|
||||
дефекта, ради уничтожения которого контракт и существует, только слоем ниже.
|
||||
|
||||
На стороне панели и оркестратора отдельно отвергаются **одиночные суррогаты**:
|
||||
строка JavaScript вправе их содержать, а `TextEncoder` молча заменит непарный
|
||||
суррогат на `U+FFFD`. Без этой проверки не было бы отказа — было бы тихое
|
||||
изменение пароля по дороге в файл.
|
||||
|
||||
**Политика HY2XS — наше решение.** Сверх транспортного домена запрещены управляющие
|
||||
символы Unicode целиком (категория `Cc`: `U+0000..U+001F`, `U+007F`,
|
||||
`U+0080..U+009F`). Их невозможно ни увидеть в
|
||||
поле ввода, ни повторить при следующем входе: они умеют ровно одно — запереть
|
||||
оператора снаружи.
|
||||
|
||||
На HTTP-границе проверяется не только уже декодированная Go-строка. Сырые JSON
|
||||
байты должны быть валидным UTF-8, а `\uXXXX` — не содержать непарных UTF-16
|
||||
суррогатов. Это делается до `encoding/json`, который иначе молча заменил бы оба
|
||||
дефекта на допустимый U+FFFD и мог бы аутентифицировать другое значение.
|
||||
|
||||
### Почему границ у пароля две
|
||||
|
||||
Их две потому, что они в **разных единицах**, и вывести одну из другой нельзя.
|
||||
|
||||
Граница в символах — та, которую видит оператор. Она считается в code points, а
|
||||
не в байтах и не в единицах UTF-16: `go-playground/validator` считает `min`/`max`
|
||||
на строке через `utf8.RuneCountInString`, и «пароль из 64 символов» обязано
|
||||
означать одно и то же для латиницы и для кириллицы.
|
||||
|
||||
Граница в байтах — та, которую ставит bcrypt. `golang.org/x/crypto/bcrypt`
|
||||
отвечает `ErrPasswordTooLong` на пароль длиннее **72 байт**
|
||||
(`GenerateFromPassword`, `bcrypt.go:96`). У 64 символов длина от 64 до 256 байт:
|
||||
|
||||
```text
|
||||
64 x "a" = 64 байта -> принимается
|
||||
36 x "я" = 72 байта -> принимается (граница)
|
||||
37 x "я" = 74 байта -> отвергается
|
||||
18 x "😀" = 72 байта -> принимается (граница)
|
||||
19 x "😀" = 76 байт -> отвергается
|
||||
64 x "я" = 128 байт -> отвергается
|
||||
```
|
||||
|
||||
Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72
|
||||
байт» — верно только для ASCII, — а сопровождающий текст утверждал, что bcrypt
|
||||
«молча отбрасывает остаток». Так вела себя редакция пакета до v0.28;
|
||||
действующая отвечает ошибкой. Следствие: пароль из 64 кириллических букв
|
||||
проходил панель, оркестратор и DTO, а отказ приходил из хеширования — системной
|
||||
ошибкой на штатной смене пароля, а после установки — отсутствием администратора
|
||||
вовсе.
|
||||
|
||||
### Панель считает длину так же, как сервер
|
||||
|
||||
Встроенных `min`/`max` Element Plus у пароля **нет**. Правила формы Element Plus
|
||||
делегирует библиотеке `async-validator`, а та сравнивает `min`/`max` строки с
|
||||
`String.prototype.length`, то есть считает единицы UTF-16:
|
||||
|
||||
```text
|
||||
"😀😀😀" Go: 3 руны -> сервер отказывает (минимум 6)
|
||||
JS: length === 6 -> форма считала минимум достигнутым
|
||||
```
|
||||
|
||||
Панель отправляла бы заведомо отвергаемый пароль и не могла бы объяснить отказ.
|
||||
Поэтому у обеих форм одно общее правило `adminPasswordFormRule`, и оно считает
|
||||
code points итератором строки, а байты — через `TextEncoder`.
|
||||
|
||||
### Границы обеих форм обязаны совпадать
|
||||
|
||||
Форма входа и форма смены пароля предъявляют к паролю **одно и то же**
|
||||
требование. Расхождение здесь запирает оператора снаружи после операции,
|
||||
которую панель ему же и предложила: пароль длиннее предела формы входа
|
||||
назначается успешно и после этого не вводится.
|
||||
|
||||
### Границы обеих форм обязаны совпадать
|
||||
|
||||
Форма входа и форма смены пароля предъявляют к паролю **одно и то же**
|
||||
требование. Расхождение здесь запирает оператора снаружи после операции,
|
||||
которую панель ему же и предложила: пароль длиннее предела формы входа
|
||||
назначается успешно и после этого не вводится.
|
||||
|
||||
### Индикация ошибки принадлежит видимому полю
|
||||
|
||||
Element Plus рисует состояние отказа на `el-input__wrapper` правилом
|
||||
|
||||
```text
|
||||
.el-form-item.is-error .el-form-item__content .el-input__wrapper
|
||||
```
|
||||
|
||||
то есть селектором из четырёх классов. На форме входа видимое поле — это
|
||||
`el-form-item`: внутрь одного поля кладутся иконка, ввод и переключатель
|
||||
видимости пароля, а `el-input` занимает лишь среднюю часть. Поэтому штатная
|
||||
индикация ложится вокруг одного лишь ввода и ни одной стороной не совпадает с
|
||||
границей поля.
|
||||
|
||||
**Правило.** Там, где рамка поля нарисована на `el-form-item`, состояние отказа
|
||||
рисуется на нём же, а штатная тень враппера гасится селектором, который
|
||||
повторяет чужой и добавляет атрибут scoped-стиля — то есть выигрывает
|
||||
специфичностью, а не `!important`. Сообщению об отказе оставляется место под
|
||||
полем: `el-form-item__error` позиционируется абсолютно от `top: 100%` и живёт
|
||||
вне рамки.
|
||||
|
||||
**Что машина не докажет.** Совпадение рамки с границей поля на экране. Проверка
|
||||
остаётся ручной и фиксируется в отчёте приёмки; тест закрепляет только наличие
|
||||
правил, которые её обеспечивают.
|
||||
|
||||
### Требование называется, а не нарушается
|
||||
|
||||
Фразы `credentials.usernameFormat` и `credentials.passwordFormat` перечисляют
|
||||
границы и набор символов. Набор логина приходит из `HY2XS_ADMIN_USER`, и
|
||||
посмотреть его в панели больше негде — сообщение «Неверный формат логина» не
|
||||
давало оператору ни одного способа узнать, что от него хотят.
|
||||
|
||||
Серверная причина `credential_format` несёт те же значения в `params`
|
||||
(`min`, `max`, `charset`), и фраза панели обязана их использовать: правило одно
|
||||
и проверяет и длину, и набор, поэтому описывать его только через символы —
|
||||
значит описывать отказ по длине неверно.
|
||||
|
||||
У пароля причина отдельная — `admin_password_format` с `params`
|
||||
(`min`, `max`, `maxBytes`), — и фраза обязана называть **обе** границы. Пароль
|
||||
из 40 эмодзи укладывается в 64 символа и не укладывается в 72 байта: сообщение
|
||||
«не длиннее 64 символов» отправило бы оператора сокращать пароль, отвергнутый
|
||||
не за это.
|
||||
|
||||
@@ -541,6 +541,57 @@ grep -c '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret
|
||||
оркестратору, он же приводит `hy2xs.env` и `bootstrap-admin.secret` в
|
||||
согласованное состояние.
|
||||
|
||||
### `hy2xs-admin` не стартует: «HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели»
|
||||
|
||||
Значение задано, но панель его не приняла бы на форме входа, поэтому учётная
|
||||
запись администратора с ним не создаётся: установка иначе завершилась бы
|
||||
успешно, а войти было бы нельзя.
|
||||
|
||||
Контракт пароля — **две** границы в разных единицах и один запрет:
|
||||
|
||||
| Требование | Кто его ставит |
|
||||
| --- | --- |
|
||||
| 6-64 символа Unicode | форма входа и форма смены пароля |
|
||||
| не более 72 байт в UTF-8 | bcrypt (`ErrPasswordTooLong`) |
|
||||
| валидный UTF-8 без NUL, U+FEFF, суррогатов и noncharacters | документированный домен systemd `EnvironmentFile=` |
|
||||
| без управляющих символов Unicode (`Cc`) | политика HY2XS: значение должно воспроизводиться в однострочной форме входа |
|
||||
|
||||
Границы независимы: у 64 символов длина от 64 до 256 байт. Пароль из 64
|
||||
кириллических букв — это 128 байт, и он отвергается, хотя в границу символов
|
||||
укладывается. Практический предел: 36 кириллических букв или 18 эмодзи.
|
||||
|
||||
Набор символов не ограничен ничем сверх этого, а пробелы по краям являются
|
||||
частью пароля. Именно поэтому такое значение записывается в `hy2xs.env` в
|
||||
двойных кавычках:
|
||||
|
||||
```text
|
||||
HY2XS_ADMIN_INITIAL_PASSWORD="пароль с пробелом на конце "
|
||||
```
|
||||
|
||||
Без кавычек пробелы по краям срежет **systemd** — файл объявлен
|
||||
`EnvironmentFile=` в юните, — и админка получит не то значение, которое вы
|
||||
записали.
|
||||
|
||||
Починка: исправьте значение в `/etc/hy2xs/hy2xs.env` и выполните
|
||||
`hy2xs-orchestrator repair --allow-partial-state`.
|
||||
|
||||
### Как посмотреть bootstrap-пароль
|
||||
|
||||
`/etc/hy2xs/bootstrap-admin.secret` — файл того же формата `KEY=VALUE`, и
|
||||
значения в нём могут быть закавычены. Читать их `cut -d= -f2-` нельзя: кавычки
|
||||
уедут в пароль. `source` и `eval` тоже не годятся — shell выполнит подстановку
|
||||
команд внутри двойных кавычек, чего сам systemd не делает.
|
||||
|
||||
```bash
|
||||
read_bootstrap_field() {
|
||||
sudo sed -n "s/^$1=//p" /etc/hy2xs/bootstrap-admin.secret | head -n1 \
|
||||
| sed -e 's/^"//' -e 's/"$//' -e 's/\\\(["\\]\)/\1/g'
|
||||
}
|
||||
|
||||
read_bootstrap_field ADMIN_USER
|
||||
read_bootstrap_field ADMIN_INITIAL_PASSWORD
|
||||
```
|
||||
|
||||
Аналогичное сообщение про `HY2XS_ADMIN_CON_PASS` относится к пиру установщика.
|
||||
Его секрет продублирован в `bootstrap-admin.secret`, откуда его читает проверка
|
||||
machine-auth, поэтому придуманный секрет разошёлся бы с файлом и первая же
|
||||
@@ -550,13 +601,43 @@ machine-auth, поэтому придуманный секрет разошёл
|
||||
|
||||
```bash
|
||||
systemctl stop hy2xs-admin
|
||||
set -a; . /etc/hy2xs/hy2xs.env; set +a
|
||||
|
||||
# Значения читаются КАК ДАННЫЕ. Обоснование — ниже, оно существенно.
|
||||
read_runtime_field() {
|
||||
sed -n "s/^$1=//p" /etc/hy2xs/hy2xs.env | head -n1 \
|
||||
| sed -e 's/^"//' -e 's/"$//' -e 's/\\\(["\\]\)/\1/g'
|
||||
}
|
||||
|
||||
HY2XS_INSTALL_DIR="$(read_runtime_field HY2XS_INSTALL_DIR)"
|
||||
HY2XS_DATA_DIR="$(read_runtime_field HY2XS_DATA_DIR)"
|
||||
HY2XS_LOG_DIR="$(read_runtime_field HY2XS_LOG_DIR)"
|
||||
export HY2XS_DATA_DIR HY2XS_LOG_DIR
|
||||
|
||||
"$HY2XS_INSTALL_DIR/hy2xs-admin" reset-admin
|
||||
systemctl start hy2xs-admin
|
||||
```
|
||||
|
||||
Runtime env подключается намеренно: из него берутся пути к базе и журналу
|
||||
(`HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`) — те же, с которыми работает юнит.
|
||||
Пути к базе и журналу (`HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`) берутся из runtime env
|
||||
намеренно: это те же значения, с которыми работает юнит.
|
||||
|
||||
**Почему не `set -a; . /etc/hy2xs/hy2xs.env`.** Здесь стояла именно эта строка, и
|
||||
она стала опасной ровно тогда, когда файл научился нести произвольные значения.
|
||||
Оператор задаёт `HY2XS_ADMIN_INITIAL_PASSWORD`, набор символов у пароля не
|
||||
ограничен, и запись в файле выглядит так:
|
||||
|
||||
```text
|
||||
HY2XS_ADMIN_INITIAL_PASSWORD="$(touch /tmp/pwn)"
|
||||
```
|
||||
|
||||
Для systemd это **буквальное значение**: подстановки в `EnvironmentFile=` нет
|
||||
вовсе, `$` там обычный символ. Но `.` (`source`) обрабатывает файл **bash**, а
|
||||
bash внутри двойных кавычек выполняет подстановку команд — и выполнил бы её от
|
||||
root, вместе с рецептом восстановления доступа.
|
||||
|
||||
То же правило действует и для `/etc/hy2xs/bootstrap-admin.secret` (см. «Как
|
||||
посмотреть bootstrap-пароль» выше): файлы этого формата читаются как ДАННЫЕ.
|
||||
Результат `$(read_runtime_field …)` повторно как код не исполняется — он
|
||||
становится значением переменной, и это принципиальная разница.
|
||||
|
||||
Команда печатает новые логин и пароль в консоль и требует смены пароля при
|
||||
первом входе. Работает поверх существующей установки; на машине без базы она
|
||||
|
||||
@@ -75,11 +75,12 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env
|
||||
одной из своих стадий. Поэтому в журнале нужно читать две разные вещи:
|
||||
|
||||
| Строка в журнале | Что она означает |
|
||||
| --- | --- |
|
||||
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `failed to persist failure state, continuing with the mandatory rollback` | маркер не обновился (обычно заполненный диск), но восстановление выполнено; после освобождения места запустить `doctor` |
|
||||
| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены |
|
||||
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
|
||||
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
|
||||
| `systemctl reset-failed <unit> exited with …, but the cleanup postcondition is satisfied` | юнит уже выгружен или не находится в `failed`; это допустимый идемпотентный cleanup, ручное восстановление не требуется |
|
||||
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
|
||||
| `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)` | guard взведён; с этого момента операция обязана снять его до фиксации успеха |
|
||||
| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания |
|
||||
@@ -336,6 +337,7 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/
|
||||
```
|
||||
|
||||
Инварианты:
|
||||
|
||||
- команда не выводит исходные секреты в stdout;
|
||||
- требуется выбрать ровно один режим: `--in-place` или `--out <path>`;
|
||||
- `--format auto` пытается определить формат по имени файла, при неоднозначности используйте `--format env|yaml`;
|
||||
@@ -349,9 +351,11 @@ hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/
|
||||
(`hy2xs-orchestrator diagnostics collect`), который собирается автоматически при
|
||||
неудачной установке или реконфигурации. Бандл предназначен для передачи наружу,
|
||||
поэтому попадающие в него `hy2xs.env`, `post-install.env` и `config.yaml`
|
||||
редактируются перед упаковкой.
|
||||
редактируются перед упаковкой. Готовый архив находится в
|
||||
`/var/lib/hy2xs/diagnostics`, принадлежит `root:root` и имеет режим `0600`.
|
||||
Сам каталог имеет режим `0700`; не ослабляйте его права и не переносите bundle
|
||||
в `HY2XS_LOG_DIR`, доступный сервисному пользователю.
|
||||
|
||||
При отказе **до** начала применения изменений (`fatal_pre_apply`) бандл не
|
||||
собирается: его сбор сам создал бы каталоги в `/var/log/hy2xs` на сервере,
|
||||
собирается: его сбор сам создал бы каталог в `/var/lib/hy2xs/diagnostics` на сервере,
|
||||
который мы обещали не трогать.
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
## Технологический стек оркестратора
|
||||
|
||||
Оркестратор фиксируется как:
|
||||
|
||||
- **Bun + TypeScript** по исходникам
|
||||
- локальная сборка builder layer'ом
|
||||
- поставка на target в виде **готового install-артефакта**
|
||||
|
||||
Это означает:
|
||||
|
||||
- на target нет `npm`, `pnpm`, `yarn` или `bun install`
|
||||
- на target нет transpile/build step
|
||||
- shell на target допустим только как thin wrapper entrypoint
|
||||
@@ -19,6 +21,7 @@
|
||||
## Главная роль оркестратора
|
||||
|
||||
Оркестратор работает **только на target machine** и умеет:
|
||||
|
||||
- выполнить read-only проверку чистоты хоста (`preflight-install`)
|
||||
- выполнить первичную установку (`install`)
|
||||
- выполнить явную реконфигурацию (`reconfigure --dry-run|--apply`)
|
||||
@@ -42,6 +45,7 @@
|
||||
## Предусловия
|
||||
|
||||
Оркестратор рассчитан только на:
|
||||
|
||||
- чистый Debian 13
|
||||
- root/sudo install context
|
||||
- один сервер
|
||||
@@ -128,7 +132,7 @@ Guard умеет останавливать только то, что через
|
||||
универсального раннера в `lib/process.ts` нет — есть два явных набора:
|
||||
|
||||
| Набор | Guard | Назначение |
|
||||
| --- | --- | --- |
|
||||
| -------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------- |
|
||||
| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` |
|
||||
| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост |
|
||||
|
||||
@@ -254,7 +258,7 @@ preflight-ошибка со словом `nftables` приводила к отк
|
||||
него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом
|
||||
случае system rollback не выполняется, `install-state.json` не пишется,
|
||||
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
|
||||
`/var/log/hy2xs`).
|
||||
`/var/lib/hy2xs/diagnostics`).
|
||||
- `systemctl stop/disable` выполняется **только если текущая операция сама
|
||||
развернула эти unit-файлы**.
|
||||
|
||||
@@ -278,8 +282,8 @@ 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 |
|
||||
|
||||
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
|
||||
@@ -294,6 +298,14 @@ preflight-ошибка со словом `nftables` приводила к отк
|
||||
ошибка операции: проблема внутри отката — это дополнительная информация о том,
|
||||
что осталось не восстановленным, а не замена диагноза.
|
||||
|
||||
`reset-failed` для каждого сервиса является отдельной стадией и завершается
|
||||
проверкой `LoadState`/`ActiveState`. Ненулевой код команды допустим, если юнит
|
||||
уже выгружен (`not-found` + `inactive`): failed-состояния у него больше нет, а
|
||||
значит cleanup завершён. Текст `Unit … not loaded` намеренно не разбирается — он
|
||||
зависит от версии и локали systemd. Ошибка чтения состояния или сохранившийся
|
||||
`ActiveState=failed` остаются настоящим отказом и попадают в manual-recovery
|
||||
сводку.
|
||||
|
||||
Команды внутри стадий **не глушат собственные ошибки**. Это правило обратно
|
||||
тому, что действовало раньше. Пока непрерывность держалась на `|| true` в каждой
|
||||
команде, стадия физически не могла сообщить, что восстановление не выполнилось:
|
||||
@@ -368,7 +380,11 @@ reconfigure B → создание копии упало, ошибка скры
|
||||
"version": 1,
|
||||
"opId": "2026-08-30T10-00-00.000Z",
|
||||
"entries": [
|
||||
{ "path": "/etc/hysteria/config.yaml", "present": true, "stored": "etc_hysteria_config.yaml" },
|
||||
{
|
||||
"path": "/etc/hysteria/config.yaml",
|
||||
"present": true,
|
||||
"stored": "etc_hysteria_config.yaml"
|
||||
},
|
||||
{ "path": "/etc/nftables.d/hy2xs.nft", "present": false, "stored": null }
|
||||
]
|
||||
}
|
||||
@@ -424,7 +440,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
||||
Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY`:
|
||||
|
||||
| Значение | Поведение |
|
||||
| --- | --- |
|
||||
| ----------------------- | ------------------------------------------------ |
|
||||
| `strict` (по умолчанию) | расхождение останавливает операцию |
|
||||
| `warn` | печатается предупреждение, операция продолжается |
|
||||
| `off` | сравнение не выполняется |
|
||||
@@ -436,6 +452,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
||||
## Что приходит на target
|
||||
|
||||
На target должен попадать уже готовый package, содержащий:
|
||||
|
||||
- thin install entrypoint
|
||||
- compiled orchestrator artifact
|
||||
- bundled HY2XS admin
|
||||
@@ -447,6 +464,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
||||
## Логическая модульность
|
||||
|
||||
Даже если на target приезжает один собранный артефакт, внутри исходников оркестратор должен быть разложен по шагам:
|
||||
|
||||
- preflight
|
||||
- deps
|
||||
- filesystem
|
||||
@@ -474,17 +492,20 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
||||
## Модель поставки
|
||||
|
||||
Рекомендуемая baseline-модель:
|
||||
|
||||
- исходники оркестратора хранятся в `orchestrator/`
|
||||
- builder выполняет локальную сборку через Bun
|
||||
- в install package кладётся готовый артефакт, который запускается thin wrapper'ом
|
||||
|
||||
Например:
|
||||
|
||||
- `package/install.sh` — проверка контекста и вызов оркестратора
|
||||
- `package/orchestrator/hy2xs-orchestrator` — собранный артефакт
|
||||
|
||||
## Логирование и коды возврата
|
||||
|
||||
Оркестратор должен:
|
||||
|
||||
- печатать понятные step-based сообщения
|
||||
- завершаться ненулевым кодом при ошибке
|
||||
- не скрывать первичный источник падения
|
||||
@@ -500,6 +521,7 @@ preflight общий для `install`, `reconfigure` и `doctor`, инвариа
|
||||
## CLI baseline
|
||||
|
||||
Команды:
|
||||
|
||||
- `preflight-install --package-dir <path> [--config <source-env>]`
|
||||
- `install --package-dir <path> [--config <source-env>]`
|
||||
- `reconfigure --package-dir <path> --config /etc/hy2xs/hy2xs.env --dry-run`
|
||||
@@ -513,6 +535,7 @@ PHASE 0 ничего не меняет.
|
||||
`--allow-partial-state` допустим только для `repair`.
|
||||
|
||||
Инварианты:
|
||||
|
||||
- только IPv4 bind/listen;
|
||||
- TLS modes: `acme | file | self_signed_dev`;
|
||||
- `trafficStats.secret` отдельный от `JWT_SECRET`;
|
||||
@@ -542,11 +565,179 @@ production-профилем, а не ищет подстроки. Проверя
|
||||
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
|
||||
уходит в логи и в diagnostics-бандл.
|
||||
|
||||
## Формат env-файлов: у него два читателя
|
||||
|
||||
`/etc/hy2xs/hy2xs.env` разбирает не только оркестратор. Файл объявлен
|
||||
`EnvironmentFile=` в юните `hy2xs-admin`, то есть его читает **systemd**, и
|
||||
формат обязан совпадать у обоих. Пока значения писались интерполяцией
|
||||
(`` `HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}` ``), а читались
|
||||
построчным `split("=")` с `trim()`, форматом это не являлось: совпадение
|
||||
поведения держалось на том, что в значениях не встречалось ни пробелов по краям,
|
||||
ни кавычек, ни обратных слешей. Продукт при этом обещает оператору, что набор
|
||||
символов пароля не ограничен, а краевой пробел — часть значения.
|
||||
|
||||
Запись и разбор живут в `orchestrator/src/lib/envFile.ts` и повторяют конечный
|
||||
автомат `parse_env_file_internal` из systemd (`src/basic/env-file.c`).
|
||||
Существенны четыре его свойства:
|
||||
|
||||
1. у **незакавыченного** значения срезаются пробелы в конце, `\` уводит в
|
||||
escape, а `\<перевод строки>` склеивает строки;
|
||||
2. в **одинарных** кавычках всё literal до закрывающей кавычки — escape там
|
||||
нет (отличие от `sh`);
|
||||
3. в **двойных** кавычках `\` уводит в escape, и обратный слеш снимается только
|
||||
перед `"`, `\`, `` ` `` и `$` (`SHELL_NEED_ESCAPE`); перед любым другим
|
||||
символом он СОХРАНЯЕТСЯ;
|
||||
4. подстановки переменных в env-файле нет вовсе: `$` внутри значения — обычный
|
||||
символ.
|
||||
|
||||
Из (3) и (4) следует кодирование, которое переживает любое издание systemd:
|
||||
двойные кавычки и экранирование **только** `\` и `"`. Оба входят в
|
||||
`SHELL_NEED_ESCAPE` и разворачиваются одинаково в действующей редакции и в тех,
|
||||
где escape в двойных кавычках снимался безусловно.
|
||||
|
||||
Кавычки ставятся только там, где они нужны: обычные значения (порты, пути,
|
||||
домены, `50 mbps`, base64url-секреты) остаются побайтово прежними, поэтому
|
||||
релизные гейты и инструкции оператора вида `grep '^HY2XS_UI_PORT=8080$'`
|
||||
продолжают работать. Тем же кодировщиком пишется `bootstrap-admin.secret`.
|
||||
|
||||
Расхождений с systemd ровно два, оба намеренные и оба **fail-closed**:
|
||||
|
||||
1. строка без `=` — **отказ**, а не пропуск. systemd такую строку молча
|
||||
отбрасывает; молчаливая потеря строки из `hy2xs.env` означала бы установку с
|
||||
настройкой, которую оператор задал, а продукт не увидел;
|
||||
2. незакрытая кавычка или escape в конце файла — **отказ**. systemd в
|
||||
состояниях `VALUE_ESCAPE` / `SINGLE_QUOTE_VALUE` / `DOUBLE_QUOTE_VALUE`
|
||||
принимает на EOF то, что успел накопить; для конфигурации, от которой зависит
|
||||
доступ в панель, «что успели накопить» — не ответ.
|
||||
|
||||
Оба останавливают операцию там, где её можно починить, вместо того чтобы
|
||||
применить не то, что написано в файле.
|
||||
|
||||
### Домен значений принадлежит systemd, а не нам
|
||||
|
||||
Формат несёт не всякую строку, и граница здесь чужая. Перед тем как принять
|
||||
пару, systemd прогоняет ключ и значение через `utf8_is_valid`
|
||||
(`check_utf8ness_and_warn`), и отказ там — `-EINVAL`, то есть **незагруженный
|
||||
файл окружения** и юнит, который не стартует. `unichar_is_valid` отвергает
|
||||
суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`, а сам
|
||||
`utf8_is_valid` — встроенный NUL и невалидный UTF-8. Публичная документация
|
||||
EnvironmentFile дополнительно запрещает U+FEFF. Реализация v257.13 случайно
|
||||
пропускает его из-за маски; HY2XS следует документированному контракту.
|
||||
|
||||
`isEnvTransportable` в `lib/envFile.ts` повторяет документированное множество.
|
||||
Управляющие символы формат несёт — внутри двойных кавычек перевод
|
||||
строки накапливается как обычный байт и переживает round-trip, — и запрещает их
|
||||
контракт учётных данных, а не транспорт. Приписывать формату чужие запреты
|
||||
нельзя: именно так проверка и пропустила noncharacters, о которых ничего не
|
||||
знала.
|
||||
|
||||
Одиночные суррогаты проверяются отдельно и по своей причине: строка JavaScript
|
||||
вправе их содержать, а `TextEncoder` молча заменит непарный суррогат на
|
||||
`U+FFFD` — то есть без проверки в файл уехал бы **другой** секрет, а не отказ.
|
||||
|
||||
Сам файл читается только как байты и декодируется через
|
||||
`TextDecoder("utf-8", { fatal: true, ignoreBOM: true })`. Обычный
|
||||
`Bun.file(...).text()` запрещён на этой границе: он заменяет повреждённые байты
|
||||
на U+FFFD. `ignoreBOM: true` сохраняет BOM как U+FEFF, чтобы тот не исчез до
|
||||
транспортной проверки. Исходный текст целиком проверяется **до** разбора ключей:
|
||||
запрещённый символ не может спрятаться в комментарии или неизвестной переменной.
|
||||
|
||||
### Непригодная конфигурация отвергается до первой мутации
|
||||
|
||||
`validateRuntimeEnvTransport` вызывается из `parseRuntimeEnv`, а не при записи
|
||||
файла, и проходит по **всем** парам `runtimeEnvEntries` — не только по паролю
|
||||
администратора.
|
||||
|
||||
Раньше проверка жила только внутри `renderRuntimeEnv`, то есть срабатывала на
|
||||
шаге «write runtime env» — уже после bootstrap оркестратора, установки пакетов и
|
||||
раскладки файловой системы. Read-only `preflight-install` при этом говорил PASS:
|
||||
он зовёт `parseRuntimeEnv` и ничего не рендерит. Детерминированно известная
|
||||
ошибка конфигурации роняла операцию, оставив за собой изменённый хост, — что
|
||||
прямо противоречит контракту PHASE 0.
|
||||
|
||||
## Smoke проверяет, что панель ВПУСКАЕТ
|
||||
|
||||
Открытый порт — это не работающая панель.
|
||||
|
||||
До RC3 установка отвечала на вопрос «работает ли панель» тремя фактами: юнит
|
||||
активен, `127.0.0.1:8080` в `LISTEN`, `/healthz` отвечает `ok: true`. RC2
|
||||
доказал, что все три бывают истинными одновременно с полностью недоступной
|
||||
панелью: на поле логина стоял тег незарегистрированного правила валидации,
|
||||
`POST /api/auth/login` паниковал ещё до проверки учётных данных, `gin.Recovery`
|
||||
превращал панику в HTTP 500 — и установка завершалась `INSTALL EXIT CODE: 0`.
|
||||
|
||||
Поэтому smoke выполняет **настоящий вход** на `POST /api/auth/login`:
|
||||
|
||||
| Проба | Когда | Что требуется |
|
||||
| ---------------------------------------------------- | ---------------- | ----------------------------------------------------------------------- |
|
||||
| настоящий логин + СЛУЧАЙНЫЙ пароль | всегда | `code: 50000`, причина `invalid_credentials`, `accessToken` отсутствует |
|
||||
| bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` |
|
||||
|
||||
Детали, которые здесь существенны:
|
||||
|
||||
- **успех определяется конвертом, а не кодом HTTP.** Админка отвечает `200 OK` и
|
||||
на отказ тоже — причина живёт в поле `code`. Проверка «HTTP 200» приняла бы за
|
||||
успешный вход любой отказ, то есть не проверяла бы ничего;
|
||||
- **отказ определяется конвертом по той же причине.** Отрицательная проба
|
||||
сверяла `%{http_code}` с `200` и доказывала ровно одно — что запрос не
|
||||
закончился пятисоткой. Теперь требуются три признака сразу: код конверта
|
||||
`50000` (отказ операции, а не успех и не отказ валидации, который означал бы
|
||||
негодный запрос), доменная причина `invalid_credentials` и ОТСУТСТВИЕ
|
||||
`accessToken`;
|
||||
- **конверт разбирается как JSON**, а не ищется регулярным выражением в сыром
|
||||
тексте. Подстрока `invalid_credentials` внутри `message` или сломанный JSON не
|
||||
имеют права превратить неизвестный ответ в успешную проверку;
|
||||
- **обе пробы используют один request helper.** Wire-поле называется `pass`, а
|
||||
не `password`; `Content-Type`, User-Agent и настройки curl не дублируются и не
|
||||
могут разойтись между positive и negative ветками;
|
||||
- **smoke отправляет явный `HY2XS-Installer/1.0` User-Agent.** Стандартный
|
||||
`curl/<version>` отклоняется действующим scanner middleware раньше DTO. UA
|
||||
установщика называется своим именем, не имитирует браузер и при этом проходит
|
||||
существующий фильтр;
|
||||
- **пароль отрицательной пробы генерируется**, а не записан литералом. Записанное
|
||||
в исходнике значение теоретически может оказаться настоящим паролем — и тогда
|
||||
проверка «неверные данные отвергаются» отчиталась бы об успешном входе. На
|
||||
`install`, где настоящий пароль известен, дополнительно утверждается, что
|
||||
проба ему не равна;
|
||||
- **bootstrap-секрет читается парсером формата**, а не `grep … | cut -d= -f2-`.
|
||||
Набор символов пароля не ограничен, пробелы по краям являются его частью, и
|
||||
шелл-конвейер срезал бы их — положительная проба взяла бы не тот пароль и
|
||||
объявила бы рабочую установку сломанной;
|
||||
- **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы
|
||||
панель, которая пускает и не выдаёт сессию;
|
||||
- **тело общего helper'а собирается `JSON.stringify`**, а не интерполяцией в строку: пароль
|
||||
задаёт оператор, и кавычка в нём сломала бы сам запрос, а не панель — проверка
|
||||
объявила бы рабочую установку сломанной;
|
||||
- **общий helper идёт через `runReadOnlySecret`**: он не кладёт команду в текст
|
||||
ошибки, а команда несёт пароль администратора. Наружу отдаётся только код
|
||||
ответа: тело успешного входа содержит токен доступа, а текст ошибки уезжает в
|
||||
журнал установки и в diagnostics-бандл;
|
||||
- **положительная проба install-only.** На `reconfigure` пароль в
|
||||
`bootstrap-admin.secret` устаревает в тот момент, когда оператор сменил его в
|
||||
панели, и требовать по нему вход значило бы ронять законную операцию.
|
||||
Отрицательная проба от пароля не зависит и выполняется всегда — именно она
|
||||
воспроизводит дефект RC2.
|
||||
|
||||
## Редактирование секретов
|
||||
|
||||
`redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML
|
||||
разбирается и обходится как дерево.
|
||||
|
||||
Diagnostics не копирует env/YAML и не перенаправляет сырой journal/systemctl
|
||||
сразу в staging. Сначала данные читаются или захватываются в память, проходят
|
||||
редакцию и лишь затем записываются с режимом `0600`. Некорректный UTF-8 в
|
||||
конфигурационном файле даёт безопасный маркер пропуска без исходных байтов.
|
||||
Вывод каждой внешней команды ограничен 8 МиБ на поток и при усечении явно
|
||||
помечается.
|
||||
|
||||
Staging и архив лежат только в `/var/lib/hy2xs/diagnostics`, а не в
|
||||
service-writable `HY2XS_LOG_DIR`. Родитель проверяется через `lstat`: symlink,
|
||||
не-root владелец, доступ на запись для группы/остальных или режим дочернего
|
||||
каталога не `0700` останавливают сбор fail closed. Рабочий каталог получает
|
||||
непредсказуемое имя через `mkdtemp`, archive path заранее резервируется через
|
||||
эксклюзивный `open("wx")`, итоговый файл проверяется как обычный
|
||||
`root:root 0600`. После успешной упаковки staging удаляется.
|
||||
|
||||
Это не косметика. Построчное правило `auth:\s*(.*)` подставляло маркер в
|
||||
заголовок mapping'а и оставляло нетронутым вложенный
|
||||
`auth.http.url` с `access_token=<секрет>`, то есть бандл уносил machine token
|
||||
@@ -554,6 +745,7 @@ production-профилем, а не ищет подстроки. Проверя
|
||||
дерево.
|
||||
|
||||
Редактируются:
|
||||
|
||||
- поля с секретоподобным именем (`password`, `secret`, `token`, `apiKey`,
|
||||
`privateKey`, `authorization`, `cookie`, `bearer`, `signature`, …);
|
||||
- карты, где секретны все значения (`auth.userpass`, `acme.dns.config`);
|
||||
|
||||
@@ -140,10 +140,98 @@ anycast. Отсутствие A-записи фатально при любом
|
||||
Важно:
|
||||
- `HY2XS_ADMIN_INITIAL_PASSWORD` используется только для первичного bootstrap seed;
|
||||
- `HY2XS_ADMIN_CON_PASS` — отдельная runtime-сущность для Hysteria auth/smoke;
|
||||
- bootstrap secret хранится в явном формате `KEY=VALUE` (`ADMIN_USER`, `ADMIN_INITIAL_PASSWORD`, `ADMIN_CON_PASS`), права `0600`;
|
||||
- bootstrap secret хранится в том же формате `KEY=VALUE` (`ADMIN_USER`, `ADMIN_INITIAL_PASSWORD`, `ADMIN_CON_PASS`), права `0600`; значения с пробелами по краям, кавычками или обратными слешами записываются в двойных кавычках — читать файл следует парсером формата, а не `cut -d= -f2-`;
|
||||
- `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован);
|
||||
- после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`.
|
||||
|
||||
### Учётные данные администратора проверяются при разборе окружения
|
||||
|
||||
`HY2XS_ADMIN_USER` и `HY2XS_ADMIN_INITIAL_PASSWORD` — это значения, которые
|
||||
потом принимает **форма входа в панель**. Оркестратор проверяет их против того
|
||||
же контракта, что и админка (`apps/credential/admin.go`):
|
||||
|
||||
| Переменная | Требование | Значение по умолчанию |
|
||||
| --- | --- | --- |
|
||||
| `HY2XS_ADMIN_USER` | 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `hy2xsadmin` |
|
||||
| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа Unicode **и** не более 72 байт в UTF-8; документированный транспортный домен systemd, включая запрет U+FEFF; набор не ограничен, кроме `Cc` | генерируется |
|
||||
|
||||
Значение вне контракта **роняет установку** с явным текстом, называющим границы
|
||||
и набор. Так и должно быть: отказ, пришедший установщику, чинится одной строкой
|
||||
в `hy2xs.env`, а неработающий вход на готовом сервере — переустановкой.
|
||||
|
||||
Проверяется и сгенерированный пароль, а не только заданный оператором:
|
||||
генератор — такой же источник значения.
|
||||
|
||||
Границ у пароля **две**, и они в разных единицах. Предел в 72 байта ставит
|
||||
bcrypt: `GenerateFromPassword` отвечает `ErrPasswordTooLong` на пароль длиннее
|
||||
72 байт, а у 64 символов длина от 64 до 256 байт в зависимости от алфавита.
|
||||
Пока байтовой границы здесь не было, `HY2XS_ADMIN_INITIAL_PASSWORD` из 64
|
||||
кириллических букв (128 байт) проходил установку целиком, а первая учётная
|
||||
запись администратора не создавалась вовсе — админка падала при старте. Подробно
|
||||
границы описаны в `docs/admin/15-ui-contracts.md`.
|
||||
|
||||
Окружающие пробелы у `HY2XS_ADMIN_USER` снимаются. Иначе они уезжали бы в имя
|
||||
учётной записи в SQLite, и вход отказывал бы «неверным логином или паролем» —
|
||||
отказом, который невозможно связать с причиной.
|
||||
|
||||
У `HY2XS_ADMIN_INITIAL_PASSWORD` пробелы по краям, наоборот, **являются частью
|
||||
пароля** и не снимаются нигде — ни оркестратором, ни админкой, ни хешированием.
|
||||
Чтобы такое значение пережило запись и чтение, оно записывается **в двойных
|
||||
кавычках** с экранированием `\` и `"`:
|
||||
|
||||
```text
|
||||
HY2XS_ADMIN_INITIAL_PASSWORD="пароль с пробелом на конце "
|
||||
```
|
||||
|
||||
Кавычки здесь не украшение. Файл читает не только оркестратор, но и systemd —
|
||||
он объявлен `EnvironmentFile=` в юните `hy2xs-admin`, — а у **незакавыченного**
|
||||
значения systemd срезает пробелы по краям и трактует `\` как escape. Если вы
|
||||
правите `hy2xs.env` руками и в пароле есть пробел по краям, кавычка или обратный
|
||||
слеш, закавычьте значение тем же способом. Обычные значения (порты, пути,
|
||||
домены) кавычек не требуют и записываются как раньше.
|
||||
|
||||
Есть два ограничения набора символов, и они разного происхождения.
|
||||
|
||||
**Домен systemd.** Значение обязано быть загружаемым из `EnvironmentFile=`:
|
||||
валидный UTF-8 из Unicode scalar values, без NUL, U+FEFF, суррогатов и
|
||||
noncharacters (`U+FDD0..U+FDEF` и все `*FFFE`/`*FFFF`). Это не наше правило —
|
||||
systemd прогоняет значение через `utf8_is_valid` и отвечает `-EINVAL`, то есть
|
||||
файл окружения **не загружается** и юнит не стартует. Оркестратор проверяет
|
||||
домен на **каждом** значении файла, а не только на пароле: `HY2XS_ADMIN_CON_PASS`
|
||||
или obfs-пароль сломали бы загрузку юнита ровно так же.
|
||||
|
||||
**Политика HY2XS.** Сверх этого запрещены управляющие символы Unicode
|
||||
(категория `Cc`). Формат их несёт, но ввести такой пароль в
|
||||
однострочное поле формы входа всё равно нельзя.
|
||||
|
||||
Ограничение U+FEFF берётся из публичной документации systemd. В реализации
|
||||
v257.13 он случайно проходит через `unichar_is_valid` из-за маски; HY2XS не
|
||||
делает эту ошибку версии частью собственного контракта.
|
||||
|
||||
Отказ по любому из двух правил приходит **при разборе конфигурации**, то есть до
|
||||
первой необратимой операции над хостом: `preflight-install` и `install` видят
|
||||
его одинаково.
|
||||
|
||||
Значение по умолчанию совпадает в трёх местах и обязано совпадать:
|
||||
`package/config/hy2xs.env`, `orchestrator/src/config/env.ts` и запасное
|
||||
значение в `apps/dao/sqlite.go`. Раньше оркестратор писал `admin` — пять
|
||||
символов при минимуме панели в шесть, — и установка завершалась
|
||||
`INSTALL EXIT CODE: 0`, оставляя панель, в которую невозможно войти.
|
||||
|
||||
### Backlog: секреты в окружении
|
||||
|
||||
Документация systemd отдельно рекомендует **не** передавать секреты через
|
||||
переменные окружения и предлагает для них `LoadCredential=` /
|
||||
`LoadCredentialEncrypted=`: окружение процесса видно шире, чем файл с правами
|
||||
`0600`.
|
||||
|
||||
HY2XS v1 этим не пользуется, и это осознанное решение по срокам, а не
|
||||
недосмотр: переход затрагивает модель секретов всего продукта (машинный токен
|
||||
Hysteria, obfs-пароль, `con_pass`), а не только пароль администратора.
|
||||
Действующая защита — права `0600`, владелец `root:root` и отсутствие доступа у
|
||||
служебных пользователей (`hy2xs-admin` и `hysteria` файл прочитать не могут, что
|
||||
проверяет smoke). Пункт остаётся в backlog как отдельная работа.
|
||||
|
||||
### Immutable-bootstrap контракт
|
||||
|
||||
- `/etc/hy2xs/bootstrap-admin.secret` создаётся оркестратором только при первичной установке.
|
||||
|
||||
@@ -89,6 +89,51 @@
|
||||
|
||||
Отдельно — round-trip `parse(render(config)) == config`. Этот тест ловит класс ошибок «в рендер runtime-конфига попал литерал вместо значения из конфигурации».
|
||||
|
||||
### A4a. Формат env-файла совпадает с systemd
|
||||
|
||||
`orchestrator/test/env-file.test.ts`. У `hy2xs.env` два читателя, и один из них
|
||||
не наш: файл объявлен `EnvironmentFile=` в юните `hy2xs-admin`. Поэтому
|
||||
проверяется не «работает на наших данных», а совпадение с правилами systemd
|
||||
(`src/basic/env-file.c`) на тех значениях, где прежний разбор расходился:
|
||||
|
||||
| Вход | Ожидание |
|
||||
| --- | --- |
|
||||
| `KEY= value ` | `value` — незакавыченное значение теряет краевые пробелы, как и у systemd |
|
||||
| `KEY=" value "` | ` value ` — в кавычках сохраняются |
|
||||
| `KEY="a\"b"` | `a"b` |
|
||||
| `KEY="a\\b"` | `a\b` |
|
||||
| `KEY="a\nb"` | `a\nb` — `n` не входит в `SHELL_NEED_ESCAPE`, слеш сохраняется |
|
||||
| `KEY="$HOME"` | `$HOME` — подстановок в env-файле нет |
|
||||
| `KEY='a\b'` | `a\b` — в одинарных кавычках escape нет вовсе |
|
||||
| строка без `=` | отказ (намеренное расхождение: systemd её отбрасывает молча) |
|
||||
| незакрытая кавычка на EOF | отказ (второе намеренное расхождение: systemd принял бы накопленное) |
|
||||
|
||||
И обратимость: любое значение — с краевыми пробелами, кавычками, обратными
|
||||
слешами, `$`, `` ` ``, `#`, эмодзи — переживает `render -> parse` побайтово, а
|
||||
обычные значения (`8080`, `/etc/hysteria/server.crt`, `50 mbps`) остаются без
|
||||
кавычек, чтобы релизные гейты и инструкции оператора продолжали работать.
|
||||
|
||||
Отдельно проверяется **домен** значений — чужое множество, а не наша политика:
|
||||
|
||||
| Вход | Ожидание |
|
||||
| --- | --- |
|
||||
| NUL, `U+FEFF`, `U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`, `U+1FFFF`, `U+10FFFF` | отказ ЗАПИСИ: публичный контракт EnvironmentFile запрещает такое значение |
|
||||
| одиночный суррогат `U+D800` | отказ — иначе `TextEncoder` молча заменил бы его на `U+FFFD`, то есть подменил бы секрет |
|
||||
| `U+FDCF`, `U+FDF0`, `U+FFFD`, `U+10FFFD`, `U+1F600` | принимаются: правило описывает диапазон, а не окрестность |
|
||||
| `\n`, `\r`, `\t`, `U+007F`, `U+0085` | формат их НЕСЁТ и round-trip сохраняет; запрещает их контракт учётных данных, а не транспорт |
|
||||
|
||||
### A4b. Непригодная конфигурация отвергается до первой мутации
|
||||
|
||||
Там же. `validateRuntimeEnvTransport` вызывается из `parseRuntimeEnv`, поэтому
|
||||
`preflight-install` и `install` видят отказ одинаково — до bootstrap
|
||||
оркестратора, apt и раскладки файловой системы. Проверяется:
|
||||
|
||||
- `parseRuntimeEnv` отвергает значение вне домена systemd;
|
||||
- проверяется КАЖДОЕ значение файла, а не только пароль администратора
|
||||
(`HY2XS_ADMIN_CON_PASS`, `HY2XS_HYSTERIA_BANDWIDTH_UP`, `HY2XS_ACME_EMAIL`);
|
||||
- запись и проверка ходят по одному списку `runtimeEnvEntries`;
|
||||
- всё, что `parseRuntimeEnv` принял, записывается без отказа.
|
||||
|
||||
Рендер конфига (`orchestrator/test/render-config.test.ts`):
|
||||
|
||||
- Gecko рендерит **только** gecko-подблок;
|
||||
@@ -361,6 +406,15 @@
|
||||
хвостовую пунктуацию; идемпотентен — регрессия на diagnostics-бандл, где
|
||||
редактировались env и YAML, а `journal-admin.log` копировался как есть.
|
||||
|
||||
`orchestrator/test/diagnostics-storage.test.ts` отдельно фиксирует границу
|
||||
привилегий: production path не входит в `HY2XS_LOG_DIR`, symlink и чужой
|
||||
владелец отвергаются, режим root-каталога равен `0700`, рабочие каталоги
|
||||
уникальны, а archive path резервируется эксклюзивно до запуска `tar`.
|
||||
|
||||
`orchestrator/test/package-meta-utf8.test.ts` проверяет соседнюю fail-closed
|
||||
границу metadata: fallback разрешён только для отсутствующего файла; каталог
|
||||
вместо файла и повреждённый UTF-8 пробрасываются как ошибка пакета.
|
||||
|
||||
## A7. Machine token в журналах (unit)
|
||||
|
||||
`apps/middleware/log_test.go` — запрос
|
||||
@@ -508,7 +562,88 @@ wildcard-маршрутом фронтенда или дублирующая р
|
||||
|
||||
- `HashPassword` выдаёт bcrypt и солит: два хеша одного пароля различаются;
|
||||
- вход по несолёному SHA-224 (формат предыдущего поколения) **невозможен**;
|
||||
- любая не-bcrypt строка в поле хеша отклоняется.
|
||||
- любая не-bcrypt строка в поле хеша отклоняется;
|
||||
- `HashPassword` следует ОБЩЕМУ контракту, а не собственной проверке. Раньше
|
||||
здесь стояло `len(strings.TrimSpace(password)) < 6` — третья копия правила, и
|
||||
она расходилась с двумя остальными: значение `abcde ` считалось корректным
|
||||
всеми дверями продукта и не могло быть захешировано, а верхней границы у
|
||||
копии не было вовсе;
|
||||
- **всё, что контракт принял, обязано хешироваться.** Проверяются граничные
|
||||
значения (36 x `я` = 72 байта, 18 x `😀` = 72 байта): именно здесь
|
||||
расхождение с bcrypt и проявлялось.
|
||||
|
||||
## A9c1. Контракт учётных данных администратора (unit)
|
||||
|
||||
Контракт объявлен один раз в `apps/credential/admin.go` — в leaf-пакете, потому
|
||||
что его зовут и `util.HashPassword`, и слой данных при создании первой учётной
|
||||
записи, а `service` импортирует `util`.
|
||||
|
||||
`apps/credential/admin_test.go` — сам контракт:
|
||||
|
||||
- набор символов логина закреплён ФАКТИЧЕСКИМ множеством: сужение уронит тест, а
|
||||
не вход администратора на живом сервере;
|
||||
- границы пароля проверяются таблицей, и она включает случаи, где границы в
|
||||
символах и в байтах расходятся:
|
||||
|
||||
| значение | символов | байт | результат |
|
||||
| --- | --- | --- | --- |
|
||||
| 64 x `a` | 64 | 64 | принят |
|
||||
| 36 x `я` | 36 | 72 | принят (граница bcrypt) |
|
||||
| 37 x `я` | 37 | 74 | **отвергнут** |
|
||||
| 18 x `😀` | 18 | 72 | принят (граница bcrypt) |
|
||||
| 19 x `😀` | 19 | 76 | **отвергнут** |
|
||||
| 64 x `я` | 64 | 128 | **отвергнут** |
|
||||
|
||||
последняя строка — исходный дефект: прежний тест требовал ПРИЁМА этого
|
||||
значения, то есть закреплял как ожидаемое ровно то, на чём продукт ломался;
|
||||
- пробел по краям — часть пароля, шесть пробелов являются корректным паролем;
|
||||
- управляющие символы Unicode целиком, то есть `Cc`: `\n`, `\r`, `\t`, `NUL`,
|
||||
`DEL` и C1 (`U+0085`, `U+009F`). Раньше проверялись только C0 и DEL, а
|
||||
документация обещала «без управляющих символов» — то есть была шире кода;
|
||||
- значения вне документированного домена systemd (`U+FEFF`, `U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`,
|
||||
`U+1FFFF`, `U+10FFFF`, невалидный UTF-8) отвергаются: с ними
|
||||
`/etc/hy2xs/hy2xs.env` не загрузится и юнит не стартует;
|
||||
- соседи запрещённых диапазонов (`U+FDCF`, `U+FDF0`, `U+FFFD`) принимаются:
|
||||
правило описывает множество systemd, а не окрестность подозрительных значений;
|
||||
- `U+FEFF` отвергается **транспортным доменом**: публичная документация systemd
|
||||
запрещает его, хотя реализация v257.13 случайно пропускает из-за маски
|
||||
`0xFEFF & 0xFFFE == 0xFEFE`. Тест
|
||||
`TestEnvTransportDomainMatchesDocumentedSystemdContract` закрепляет публичный контракт, а
|
||||
`TestProductPolicyIsWiderThanTransportDomain` — что политика и домен остаются
|
||||
различимы.
|
||||
|
||||
`orchestrator/test/strict-text-read.test.ts` подаёт reader'у реальные байтовые
|
||||
последовательности `0xFF`, оборванную `0xC3` и `ED A0 80`. Ни одна из них не
|
||||
превращается в U+FFFD; начальный BOM сохраняется как U+FEFF и доходит до
|
||||
транспортного отказа.
|
||||
|
||||
`apps/controller/json_body_test.go` и HTTP-тест входа доказывают то же на API:
|
||||
повреждённый UTF-8 и непарные `\uD800`/`\uDC00` отвергаются до стандартного
|
||||
Go-декодера, а настоящий U+FFFD остаётся допустимым значением.
|
||||
|
||||
`apps/controller/validator_test.go` — ПРОВОДКА, а не контракт: теги
|
||||
`credentialStr` и `adminPassword` прогоняются через production-валидатор и
|
||||
обязаны отвечать так же, как функции контракта, на тех же граничных значениях.
|
||||
|
||||
`apps/controller/auth_test.go`:
|
||||
|
||||
- ни один тег валидации ни в одном DTO не ссылается на незарегистрированное
|
||||
правило (обход исходников, а не проверка одного экземпляра);
|
||||
- границы пароля **не стоят рядом** с правилом: тег умеет считать только
|
||||
символы, а у пароля есть ещё граница в байтах, которую тегом не выразить;
|
||||
- пароль в 72 байта пускает в панель, а на символ длиннее — получает конверт
|
||||
валидации с причиной `admin_password_format` на поле `pass`, а не системную
|
||||
ошибку из bcrypt;
|
||||
- пароль не триммится: `bootstrap-password ` и `bootstrap-password` — разные
|
||||
пароли.
|
||||
|
||||
`apps/dao/bootstrap_admin_test.go` — bootstrap-путь на настоящей SQLite:
|
||||
|
||||
- пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом, и вход
|
||||
обрезанным значением невозможен;
|
||||
- пароль вне контракта роняет старт с текстом, называющим переменную и файл, а
|
||||
не сообщением bcrypt;
|
||||
- пароль ровно в 72 байта проходит установку целиком.
|
||||
|
||||
`apps/util/rand_test.go` — отсутствие modulo bias: на выборке 200 000 символов
|
||||
частоты первых восьми символов алфавита не отличаются от остальных более чем на
|
||||
@@ -815,4 +950,3 @@ Hysteria. Вторая не была замечена только потому,
|
||||
Отсутствие legacy-маршрута доказывает не `grep` по исходникам, а
|
||||
`TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера —
|
||||
и существование этого теста само проверяется контрактом.
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
## B. Target install tests
|
||||
|
||||
### На чистом Debian 13 проверяем
|
||||
|
||||
1. пакет запускается без ручной сборки на сервере
|
||||
2. Hysteria2 скачивается с official upstream
|
||||
3. bundled HY2XS admin раскладывается локально из пакета
|
||||
@@ -52,6 +53,69 @@
|
||||
32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd
|
||||
33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON
|
||||
34. страница конфигурации показывает фактические значения `/etc/hysteria/config.yaml`, перечисляет секции вне production-профиля и не содержит паролей и токенов
|
||||
35. **оператор входит в панель**: `POST /api/auth/login` с bootstrap-учётными данными из `/etc/hy2xs/bootstrap-admin.secret` отвечает `code: 20000` и непустым `accessToken`. Заведомо неверные учётные данные (со СЛУЧАЙНЫМ паролем, а не с литералом) дают HTTP 200 с конвертом отказа: `code: 50000`, причина `invalid_credentials` и отсутствие `accessToken`
|
||||
36. пароль предельной длины, назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными. Границ **две** — 6-64 символа Unicode и не более 72 байт в UTF-8 (предел bcrypt): пароль из 36 кириллических букв (72 байта) принимается, из 37 (74 байта) — отвергается конвертом валидации, а не системной ошибкой
|
||||
37. `HY2XS_ADMIN_INITIAL_PASSWORD` с пробелом по краям доезжает до учётной записи неизменным: значение записано в `hy2xs.env` в двойных кавычках, и вход выполняется ровно им, а не обрезанным
|
||||
37a. `HY2XS_ADMIN_INITIAL_PASSWORD` со значением вне документированного домена systemd (`U+FEFF`, `U+FDD0`, `U+FFFF`) или файл с байтами `0xFF`, оборванной `0xC3`, `ED A0 80` **роняет `preflight-install`** — отказ приходит до первой мутации хоста; сервер остаётся нетронутым
|
||||
37b. API входа и импорта отвергает повреждённый UTF-8 и непарные UTF-16 surrogate escape до `encoding/json`; настоящий U+FFFD работает как обычный символ
|
||||
38. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки
|
||||
|
||||
## C0. Панель обязана впускать, а не слушать порт
|
||||
|
||||
Проверки 1-4 отвечают на вопрос «поднялось ли», и ни одна из них не отвечает на
|
||||
вопрос «работает ли». RC2 показал разницу: юнит активен, `127.0.0.1:8080` в
|
||||
`LISTEN`, `/healthz` отвечает `ok: true` — и `POST /api/auth/login` отдаёт
|
||||
HTTP 500 на каждый запрос, потому что валидатор паникует на теге
|
||||
несуществующего правила. Установка при этом завершилась `INSTALL EXIT CODE: 0`.
|
||||
|
||||
Поэтому вход в панель проверяется **настоящим запросом**, а не косвенными
|
||||
признаками, и эта проверка встроена в smoke оркестратора — то есть релиз с
|
||||
недоступной панелью физически не может завершиться успешной установкой.
|
||||
Ручной эквивалент:
|
||||
|
||||
```bash
|
||||
# Значение читается ПО ФОРМАТУ, а не `cut -d= -f2-`.
|
||||
#
|
||||
# Набор символов пароля не ограничен, а пробелы по краям являются его частью,
|
||||
# поэтому такое значение записано в файле в двойных кавычках с экранированием
|
||||
# `\` и `"`. `cut` отдал бы кавычки как часть пароля, а `read -r` вдобавок
|
||||
# срезал бы пробелы — и проверка объявила бы рабочую установку сломанной.
|
||||
#
|
||||
# `source` и `eval` здесь НЕ годятся: внутри двойных кавычек shell выполняет
|
||||
# подстановку команд, то есть пароль вида `$(...)` был бы исполнен. У самого
|
||||
# systemd подстановок в EnvironmentFile нет, и снимать кавычки надо без shell.
|
||||
read_bootstrap_field() {
|
||||
sudo sed -n "s/^$1=//p" /etc/hy2xs/bootstrap-admin.secret | head -n1 \
|
||||
| sed -e 's/^"//' -e 's/"$//' -e 's/\\\(["\\]\)/\1/g'
|
||||
}
|
||||
|
||||
BOOTSTRAP_USER="$(read_bootstrap_field ADMIN_USER)"
|
||||
BOOTSTRAP_PASS="$(read_bootstrap_field ADMIN_INITIAL_PASSWORD)"
|
||||
|
||||
# Положительная проба: конверт успеха и выданный токен.
|
||||
curl -sS --max-time 5 -X POST \
|
||||
--user-agent 'HY2XS-Installer/1.0' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$BOOTSTRAP_PASS" '{username:$u,pass:$p}')" \
|
||||
http://127.0.0.1:8080/api/auth/login | jq '.code, (.data.accessToken | length)'
|
||||
|
||||
# Отрицательная проба: пароль СЛУЧАЙНЫЙ, а проверяется конверт отказа целиком.
|
||||
# HTTP 200 сам по себе ничего не доказывает — админка отвечает 200 и на успех.
|
||||
curl -sS --max-time 5 -X POST \
|
||||
--user-agent 'HY2XS-Installer/1.0' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$(head -c 18 /dev/urandom | base64)" '{username:$u,pass:$p}')" \
|
||||
http://127.0.0.1:8080/api/auth/login \
|
||||
| jq '{code, reason: (.errors[0].code), token: (.data.accessToken // null)}'
|
||||
# Ожидается: {"code":50000,"reason":"invalid_credentials","token":null}
|
||||
|
||||
unset BOOTSTRAP_PASS
|
||||
```
|
||||
|
||||
Ожидается `20000` и ненулевая длина токена. Сам токен не печатается: это
|
||||
действующая сессия администратора. Явный User-Agent обязателен: стандартный
|
||||
`curl/<version>` осознанно отклоняется scanner middleware с HTTP 403 ещё до
|
||||
разбора JSON. Значение совпадает с production smoke и не маскируется под браузер.
|
||||
|
||||
## C1. Семантический smoke конфига
|
||||
|
||||
@@ -179,4 +243,3 @@ idle timeout проходил семантическую проверку. То
|
||||
|
||||
Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами:
|
||||
граница определяется значением, а не именем ключа.
|
||||
|
||||
|
||||
@@ -1,74 +1,130 @@
|
||||
import { join } from "node:path";
|
||||
import type { CommonOptions } from "../types/context";
|
||||
import { InvalidUtf8Error, readText, writeTextAtomic } from "../lib/fs";
|
||||
import { info, setOperationContext } from "../lib/log";
|
||||
import { runMutating } from "../lib/process";
|
||||
import { runMutatingArgv, runReadOnlyArgvStatus } from "../lib/process";
|
||||
import { redactEnv, redactLogText, redactYaml } from "../lib/redaction";
|
||||
import {
|
||||
cleanupDiagnosticsWorkspace,
|
||||
createDiagnosticsWorkspace,
|
||||
discardDiagnosticsArchive,
|
||||
finalizeDiagnosticsArchive
|
||||
} from "../lib/diagnosticsStorage";
|
||||
|
||||
function shellEscapeSingleQuotes(value: string): string {
|
||||
return value.replaceAll("'", "'\\''");
|
||||
const unavailable = (reason: string): string =>
|
||||
`[HY2XS: источник не включён в диагностику: ${reason}]\n`;
|
||||
|
||||
async function writeDiagnostic(outDir: string, name: string, content: string): Promise<void> {
|
||||
await writeTextAtomic(join(outDir, name), content, { mode: 0o600, owner: "root", group: "root" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Вывод сначала попадает в память, редактируется и лишь затем записывается.
|
||||
* В staging-каталоге ни на мгновение нет сырого journal/systemctl-вывода с
|
||||
* machine token.
|
||||
*/
|
||||
async function collectCommand(outDir: string, name: string, argv: string[]): Promise<void> {
|
||||
try {
|
||||
const result = await runReadOnlyArgvStatus(argv);
|
||||
const combined = [result.stdout, result.stderr]
|
||||
.filter(Boolean)
|
||||
.join(result.stdout && result.stderr ? "\n" : "");
|
||||
const status = result.exitCode === 0 ? "" : `[exit code: ${result.exitCode}]\n`;
|
||||
const truncated = result.stdoutTruncated || result.stderrTruncated
|
||||
? "[HY2XS: вывод обрезан по безопасному пределу 8 МиБ на поток]\n"
|
||||
: "";
|
||||
await writeDiagnostic(outDir, name, redactLogText(`${status}${truncated}${combined}`));
|
||||
} catch (error) {
|
||||
info(`diagnostics: ${name} пропущен: ${error instanceof Error ? error.message : String(error)}`);
|
||||
await writeDiagnostic(outDir, name, unavailable("команда недоступна"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфигурация никогда не копируется в staging как есть. Повреждённый UTF-8
|
||||
* не декодируется с заменой: в бандл попадает только безопасное объяснение без
|
||||
* исходных байтов.
|
||||
*/
|
||||
async function collectFile(
|
||||
outDir: string,
|
||||
name: string,
|
||||
source: string,
|
||||
sanitize: (content: string) => string
|
||||
): Promise<void> {
|
||||
await writeDiagnostic(outDir, name, await prepareDiagnosticFile(source, sanitize));
|
||||
}
|
||||
|
||||
/** Чистая граница «прочитать строго -> санитизировать -> вернуть текст». */
|
||||
export async function prepareDiagnosticFile(
|
||||
source: string,
|
||||
sanitize: (content: string) => string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const raw = await readText(source);
|
||||
return sanitize(raw);
|
||||
} catch (error) {
|
||||
const reason = error instanceof InvalidUtf8Error ? "некорректный UTF-8" : "файл недоступен";
|
||||
info(`diagnostics: ${source} пропущен: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return unavailable(reason);
|
||||
}
|
||||
}
|
||||
|
||||
export async function diagnosticsCollect(_options: CommonOptions): Promise<void> {
|
||||
const opId = `diag-${Date.now().toString(36)}`;
|
||||
setOperationContext(opId);
|
||||
|
||||
const outDir = `/var/log/hy2xs/diagnostics/${opId}`;
|
||||
const archive = `/var/log/hy2xs/diagnostics/${opId}.tar.gz`;
|
||||
await runMutating`mkdir -p ${outDir}`;
|
||||
|
||||
await runMutating`sh -c ${`systemctl status hysteria-server > '${shellEscapeSingleQuotes(`${outDir}/systemd-hysteria.txt`)}' 2>&1 || true`}`;
|
||||
await runMutating`sh -c ${`systemctl status hy2xs-admin > '${shellEscapeSingleQuotes(`${outDir}/systemd-admin.txt`)}' 2>&1 || true`}`;
|
||||
await runMutating`sh -c ${`journalctl -u hysteria-server -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-hysteria.log`)}' 2>&1 || true`}`;
|
||||
await runMutating`sh -c ${`journalctl -u hy2xs-admin -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-admin.log`)}' 2>&1 || true`}`;
|
||||
await runMutating`sh -c ${`nft list ruleset > '${shellEscapeSingleQuotes(`${outDir}/nftables.ruleset`)}' 2>&1 || true`}`;
|
||||
await runMutating`sh -c ${`uname -a > '${shellEscapeSingleQuotes(`${outDir}/uname.txt`)}' 2>&1 || true`}`;
|
||||
await runMutating`sh -c ${`cat /etc/os-release > '${shellEscapeSingleQuotes(`${outDir}/os-release.txt`)}' 2>&1 || true`}`;
|
||||
await runMutating`sh -c ${`cp -a /etc/hysteria/post-install.env '${shellEscapeSingleQuotes(`${outDir}/post-install.env`)}' 2>/dev/null || true`}`;
|
||||
await runMutating`sh -c ${`cp -a /etc/hy2xs/hy2xs.env '${shellEscapeSingleQuotes(`${outDir}/hy2xs.env`)}' 2>/dev/null || true`}`;
|
||||
await runMutating`sh -c ${`cp -a /etc/hysteria/config.yaml '${shellEscapeSingleQuotes(`${outDir}/hysteria-config.yaml`)}' 2>/dev/null || true`}`;
|
||||
await runMutating`sh -c ${`cp -a /var/lib/hy2xs/install-state.json '${shellEscapeSingleQuotes(`${outDir}/install-state.json`)}' 2>/dev/null || true`}`;
|
||||
await runMutating`sh -c ${`ss -ltnup > '${shellEscapeSingleQuotes(`${outDir}/ss-ltnup.txt`)}' 2>&1 || true`}`;
|
||||
const workspace = await createDiagnosticsWorkspace();
|
||||
setOperationContext(workspace.operationId);
|
||||
let archiveComplete = false;
|
||||
let collectionFailed = false;
|
||||
|
||||
try {
|
||||
const envRaw = await Bun.file(`${outDir}/hy2xs.env`).text();
|
||||
await Bun.write(`${outDir}/hy2xs.env`, redactEnv(envRaw));
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
await collectCommand(workspace.outDir, "systemd-hysteria.txt", ["systemctl", "status", "hysteria-server"]);
|
||||
await collectCommand(workspace.outDir, "systemd-admin.txt", ["systemctl", "status", "hy2xs-admin"]);
|
||||
await collectCommand(workspace.outDir, "journal-hysteria.log", ["journalctl", "-u", "hysteria-server", "-n", "300", "--no-pager"]);
|
||||
await collectCommand(workspace.outDir, "journal-admin.log", ["journalctl", "-u", "hy2xs-admin", "-n", "300", "--no-pager"]);
|
||||
await collectCommand(workspace.outDir, "nftables.ruleset", ["nft", "list", "ruleset"]);
|
||||
await collectCommand(workspace.outDir, "uname.txt", ["uname", "-a"]);
|
||||
await collectCommand(workspace.outDir, "ss-ltnup.txt", ["ss", "-ltnup"]);
|
||||
|
||||
await collectFile(workspace.outDir, "os-release.txt", "/etc/os-release", redactLogText);
|
||||
await collectFile(workspace.outDir, "post-install.env", "/etc/hysteria/post-install.env", redactEnv);
|
||||
await collectFile(workspace.outDir, "hy2xs.env", "/etc/hy2xs/hy2xs.env", redactEnv);
|
||||
await collectFile(workspace.outDir, "hysteria-config.yaml", "/etc/hysteria/config.yaml", redactYaml);
|
||||
await collectFile(workspace.outDir, "install-state.json", "/var/lib/hy2xs/install-state.json", redactLogText);
|
||||
|
||||
// Archive path уже создан через open("wx") внутри доверенного root-only
|
||||
// namespace. argv-форма не запускает shell и сохраняет границы аргументов.
|
||||
await runMutatingArgv(["tar", "-czf", workspace.archive, "-C", workspace.outDir, "."]);
|
||||
await finalizeDiagnosticsArchive(workspace.archive);
|
||||
archiveComplete = true;
|
||||
} catch (error) {
|
||||
collectionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
const cleanupErrors: unknown[] = [];
|
||||
try {
|
||||
const postInstallRaw = await Bun.file(`${outDir}/post-install.env`).text();
|
||||
await Bun.write(`${outDir}/post-install.env`, redactEnv(postInstallRaw));
|
||||
} catch {
|
||||
// noop
|
||||
await cleanupDiagnosticsWorkspace(workspace);
|
||||
} catch (error) {
|
||||
cleanupErrors.push(error);
|
||||
}
|
||||
|
||||
if (!archiveComplete) {
|
||||
try {
|
||||
const cfgRaw = await Bun.file(`${outDir}/hysteria-config.yaml`).text();
|
||||
await Bun.write(`${outDir}/hysteria-config.yaml`, redactYaml(cfgRaw));
|
||||
} catch {
|
||||
// noop
|
||||
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)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
// Журналы и вывод systemctl — такой же артефакт, покидающий сервер, как env
|
||||
// и YAML. Раньше редактировались только последние два, а journal-admin.log
|
||||
// копировался как есть — и уносил machine token, который админка логировала
|
||||
// в составе RequestURI.
|
||||
for (const logFile of [
|
||||
"journal-hysteria.log",
|
||||
"journal-admin.log",
|
||||
"systemd-hysteria.txt",
|
||||
"systemd-admin.txt"
|
||||
]) {
|
||||
try {
|
||||
const raw = await Bun.file(`${outDir}/${logFile}`).text();
|
||||
await Bun.write(`${outDir}/${logFile}`, redactLogText(raw));
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
await runMutating`sh -c ${`tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`;
|
||||
|
||||
info(`diagnostics bundle collected: ${archive}`);
|
||||
info(`diagnostics bundle collected: ${workspace.archive}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import type { InstallContext, InstallOptions } from "../types/context";
|
||||
import { fileExists, readText, writeTextAtomic } from "../lib/fs";
|
||||
import { runMutatingVisible } from "../lib/process";
|
||||
import { resetFailedUnit } from "../lib/systemd";
|
||||
import { info, setOperationContext, step, stepDone } from "../lib/log";
|
||||
import { readPackageValue } from "../lib/packageMeta";
|
||||
import { REPAIR_HINT, buildInstallStateRecord } from "../lib/installState";
|
||||
import { persistInstallState } from "../lib/installStateWriter";
|
||||
import { persistFailureState, runRollbackStages, type RollbackStage } from "../lib/rollback";
|
||||
import {
|
||||
persistFailureState,
|
||||
runRollbackStages,
|
||||
type RollbackStage,
|
||||
} from "../lib/rollback";
|
||||
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
|
||||
import {
|
||||
ORCHESTRATOR_INSTALL_DIR,
|
||||
ORCHESTRATOR_INSTALL_PATH,
|
||||
ORCHESTRATOR_SYMLINK_PATH,
|
||||
RUNTIME_PACKAGE_DIR
|
||||
RUNTIME_PACKAGE_DIR,
|
||||
} from "../config/profile";
|
||||
import { preflight } from "../steps/preflight";
|
||||
import { bootstrapRuntime } from "../steps/bootstrap";
|
||||
@@ -27,7 +32,7 @@ import {
|
||||
cleanupFirewallRollback,
|
||||
disarmFirewallRollback,
|
||||
operationKeyFor,
|
||||
rollbackFirewallNow
|
||||
rollbackFirewallNow,
|
||||
} from "../steps/firewall";
|
||||
import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
|
||||
import { smoke } from "../steps/smoke";
|
||||
@@ -125,7 +130,7 @@ function newOwnership(): OperationOwnership {
|
||||
firewallTouched: false,
|
||||
postInstallTouched: false,
|
||||
bootstrapSecretTouched: false,
|
||||
servicesStarted: false
|
||||
servicesStarted: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,14 +151,14 @@ function installOwnedPaths(context: InstallContext): string[] {
|
||||
ORCHESTRATOR_INSTALL_DIR,
|
||||
ORCHESTRATOR_INSTALL_PATH,
|
||||
ORCHESTRATOR_SYMLINK_PATH,
|
||||
RUNTIME_PACKAGE_DIR
|
||||
RUNTIME_PACKAGE_DIR,
|
||||
];
|
||||
}
|
||||
|
||||
async function writeInstallState(
|
||||
context: InstallContext,
|
||||
phase: InstallPhase,
|
||||
lastError: string
|
||||
lastError: string,
|
||||
): Promise<void> {
|
||||
const record = buildInstallStateRecord({
|
||||
productVersion: context.packageVersion,
|
||||
@@ -168,7 +173,7 @@ async function writeInstallState(
|
||||
installed: phase === "installed",
|
||||
ownedPaths: installOwnedPaths(context),
|
||||
lastError,
|
||||
repairHint: phase === "installed" ? undefined : REPAIR_HINT
|
||||
repairHint: phase === "installed" ? undefined : REPAIR_HINT,
|
||||
});
|
||||
|
||||
await persistInstallState(record);
|
||||
@@ -178,7 +183,7 @@ async function advanceInstallState(
|
||||
context: InstallContext,
|
||||
ownership: OperationOwnership,
|
||||
phase: InstallPhase,
|
||||
lastError = ""
|
||||
lastError = "",
|
||||
): Promise<void> {
|
||||
// Флаг взводится ПЕРЕД записью, а не после неё: см. комментарий к
|
||||
// stateTouched. Частично выполненная запись маркера — это уже изменение
|
||||
@@ -211,7 +216,7 @@ async function advanceInstallState(
|
||||
export function classifyFailure(
|
||||
ownership: OperationOwnership,
|
||||
phase: InstallPhase,
|
||||
error?: unknown
|
||||
error?: unknown,
|
||||
): FailureKind {
|
||||
if (error instanceof FirewallGuardFiredError) {
|
||||
return "firewall_guard_fired";
|
||||
@@ -256,10 +261,12 @@ export function classifyFailure(
|
||||
async function rollbackFailedInstall(
|
||||
context: InstallContext,
|
||||
ownership: OperationOwnership,
|
||||
failureKind: FailureKind
|
||||
failureKind: FailureKind,
|
||||
): Promise<void> {
|
||||
if (failureKind === "fatal_pre_apply") {
|
||||
info("pre-apply failure: nothing was applied, system rollback is not required");
|
||||
info(
|
||||
"pre-apply failure: nothing was applied, system rollback is not required",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -275,7 +282,7 @@ async function rollbackFailedInstall(
|
||||
name: "firewall",
|
||||
run: async () => {
|
||||
await rollbackFirewallNow(context);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -295,23 +302,31 @@ async function rollbackFailedInstall(
|
||||
name: "stop services",
|
||||
run: async () => {
|
||||
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin`;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disable services",
|
||||
run: async () => {
|
||||
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin`;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reset failed services",
|
||||
name: "reset failed hysteria-server",
|
||||
run: async () => {
|
||||
await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin`;
|
||||
}
|
||||
}
|
||||
await resetFailedUnit("hysteria-server");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reset failed hy2xs-admin",
|
||||
run: async () => {
|
||||
await resetFailedUnit("hy2xs-admin");
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
info("rollback: systemd units were not deployed by this operation, leaving services untouched");
|
||||
info(
|
||||
"rollback: systemd units were not deployed by this operation, leaving services untouched",
|
||||
);
|
||||
}
|
||||
|
||||
await runRollbackStages(stages);
|
||||
@@ -319,11 +334,15 @@ async function rollbackFailedInstall(
|
||||
|
||||
export async function install(options: InstallOptions): Promise<void> {
|
||||
setOperationContext(`install-${Date.now().toString(36)}`);
|
||||
const hasSourceConfig = options.sourceConfigPath ? await fileExists(options.sourceConfigPath) : false;
|
||||
const hasSourceConfig = options.sourceConfigPath
|
||||
? await fileExists(options.sourceConfigPath)
|
||||
: false;
|
||||
if (options.sourceConfigPath && !hasSourceConfig) {
|
||||
throw new Error(`config source not found: ${options.sourceConfigPath}`);
|
||||
}
|
||||
const sourceConfigPath = hasSourceConfig ? options.sourceConfigPath : `${options.packageDir}/config/hy2xs.env`;
|
||||
const sourceConfigPath = hasSourceConfig
|
||||
? options.sourceConfigPath
|
||||
: `${options.packageDir}/config/hy2xs.env`;
|
||||
const sourceConfigRaw = await readText(sourceConfigPath);
|
||||
const config = parseRuntimeEnv(sourceConfigRaw);
|
||||
|
||||
@@ -331,18 +350,48 @@ export async function install(options: InstallOptions): Promise<void> {
|
||||
mode: "install",
|
||||
options,
|
||||
config,
|
||||
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
|
||||
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
|
||||
packageVersion: await readPackageValue(
|
||||
options.packageDir,
|
||||
"package.version",
|
||||
"unknown",
|
||||
),
|
||||
packageBuildId: await readPackageValue(
|
||||
options.packageDir,
|
||||
"package.build_id",
|
||||
"unknown",
|
||||
),
|
||||
installDate: new Date().toISOString(),
|
||||
hysteriaVersion: "unknown",
|
||||
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown"),
|
||||
hysteriaTargetVersion: await readPackageValue(options.packageDir, "hysteria.version", ""),
|
||||
hysteriaArtifactUrl: await readPackageValue(options.packageDir, "hysteria.url", ""),
|
||||
hysteriaArtifactSha256: await readPackageValue(options.packageDir, "hysteria.sha256", "")
|
||||
hysteriaResolution: await readPackageValue(
|
||||
options.packageDir,
|
||||
"hysteria.resolution",
|
||||
"unknown",
|
||||
),
|
||||
hysteriaTargetVersion: await readPackageValue(
|
||||
options.packageDir,
|
||||
"hysteria.version",
|
||||
"",
|
||||
),
|
||||
hysteriaArtifactUrl: await readPackageValue(
|
||||
options.packageDir,
|
||||
"hysteria.url",
|
||||
"",
|
||||
),
|
||||
hysteriaArtifactSha256: await readPackageValue(
|
||||
options.packageDir,
|
||||
"hysteria.sha256",
|
||||
"",
|
||||
),
|
||||
};
|
||||
|
||||
if (!context.hysteriaTargetVersion || !context.hysteriaArtifactUrl || !context.hysteriaArtifactSha256) {
|
||||
throw new Error("missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256");
|
||||
if (
|
||||
!context.hysteriaTargetVersion ||
|
||||
!context.hysteriaArtifactUrl ||
|
||||
!context.hysteriaArtifactSha256
|
||||
) {
|
||||
throw new Error(
|
||||
"missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256",
|
||||
);
|
||||
}
|
||||
|
||||
const ownership = newOwnership();
|
||||
@@ -356,7 +405,10 @@ export async function install(options: InstallOptions): Promise<void> {
|
||||
// Всё, что дальше, уже создаёт наши собственные пути, и повторная проверка
|
||||
// опознала бы их как чужую установку.
|
||||
step("preflight");
|
||||
await preflight(context, { requireCapabilities: false, checkCleanHost: true });
|
||||
await preflight(context, {
|
||||
requireCapabilities: false,
|
||||
checkCleanHost: true,
|
||||
});
|
||||
stepDone("preflight");
|
||||
|
||||
await advanceInstallState(context, ownership, "preflight_ok");
|
||||
@@ -372,7 +424,10 @@ export async function install(options: InstallOptions): Promise<void> {
|
||||
await installDeps(context);
|
||||
stepDone("system dependencies");
|
||||
step("preflight capabilities");
|
||||
await preflight(context, { requireCapabilities: true, checkCleanHost: false });
|
||||
await preflight(context, {
|
||||
requireCapabilities: true,
|
||||
checkCleanHost: false,
|
||||
});
|
||||
stepDone("preflight capabilities");
|
||||
await advanceInstallState(context, ownership, "deps_ok");
|
||||
phase = "deps_ok";
|
||||
@@ -387,7 +442,7 @@ export async function install(options: InstallOptions): Promise<void> {
|
||||
await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), {
|
||||
mode: 0o600,
|
||||
owner: "root",
|
||||
group: "root"
|
||||
group: "root",
|
||||
});
|
||||
stepDone("write runtime env");
|
||||
await advanceInstallState(context, ownership, "runtime_env_written");
|
||||
@@ -468,8 +523,13 @@ export async function install(options: InstallOptions): Promise<void> {
|
||||
try {
|
||||
await cleanupFirewallRollback(context);
|
||||
} catch (cleanupError) {
|
||||
const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
||||
info(`firewall rollback data cleanup failed after a successful install: ${cleanupMessage}`);
|
||||
const cleanupMessage =
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: String(cleanupError);
|
||||
info(
|
||||
`firewall rollback data cleanup failed after a successful install: ${cleanupMessage}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -498,7 +558,12 @@ export async function install(options: InstallOptions): Promise<void> {
|
||||
: "failed";
|
||||
|
||||
await persistFailureState(() =>
|
||||
advanceInstallState(context, ownership, failurePhase, `${failureKind}: ${message}`)
|
||||
advanceInstallState(
|
||||
context,
|
||||
ownership,
|
||||
failurePhase,
|
||||
`${failureKind}: ${message}`,
|
||||
),
|
||||
);
|
||||
|
||||
// Диагностика — best effort, откат — обязателен.
|
||||
@@ -512,8 +577,12 @@ export async function install(options: InstallOptions): Promise<void> {
|
||||
await diagnosticsCollect(options);
|
||||
} catch (diagnosticsError) {
|
||||
const diagnosticsMessage =
|
||||
diagnosticsError instanceof Error ? diagnosticsError.message : String(diagnosticsError);
|
||||
info(`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`);
|
||||
diagnosticsError instanceof Error
|
||||
? diagnosticsError.message
|
||||
: String(diagnosticsError);
|
||||
info(
|
||||
`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`,
|
||||
);
|
||||
}
|
||||
|
||||
await rollbackFailedInstall(context, ownership, failureKind);
|
||||
|
||||
+126
-59
@@ -1,9 +1,12 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { FirewallMode, PublicEndpointPolicy, RuntimeConfig, TlsMode } from "../types/context";
|
||||
import { assertEnvTransportable, parseEnvFile, renderEnvFile } from "../lib/envFile";
|
||||
import {
|
||||
GECKO_DEFAULT_MAX_PACKET_SIZE,
|
||||
GECKO_DEFAULT_MIN_PACKET_SIZE,
|
||||
HY2XS_CONFIG_SCHEMA_VERSION,
|
||||
assertValidAdminPassword,
|
||||
assertValidAdminUsername,
|
||||
normalizeHysteriaObfsType,
|
||||
validateGeckoPacketSizes
|
||||
} from "./profile";
|
||||
@@ -21,22 +24,17 @@ function valueOrGenerate(value: string | undefined): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбор env-файла делегирован lib/envFile.
|
||||
*
|
||||
* Здесь стоял построчный разбор с `value.trim()`, и он молча срезал пробелы по
|
||||
* краям КАЖДОГО значения. Для пароля администратора это прямое нарушение
|
||||
* контракта — продукт объявляет краевой пробел частью пароля, — а для
|
||||
* значений с кавычками разбор вдобавок расходился с systemd, который читает
|
||||
* этот же файл как EnvironmentFile.
|
||||
*/
|
||||
function parseEnv(content: string): EnvMap {
|
||||
const result: EnvMap = {};
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const separator = line.indexOf("=");
|
||||
if (separator < 1) {
|
||||
throw new Error(`invalid env line: ${rawLine}`);
|
||||
}
|
||||
const key = line.slice(0, separator).trim();
|
||||
const value = line.slice(separator + 1).trim();
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
return parseEnvFile(content);
|
||||
}
|
||||
|
||||
function parsePort(name: string, raw: string, fallback: number): number {
|
||||
@@ -223,6 +221,11 @@ function normalizeSafeAbsolutePath(name: string, value: string, options?: { disa
|
||||
}
|
||||
|
||||
export function parseRuntimeEnv(content: string): RuntimeConfig {
|
||||
// Проверяется ИСХОДНЫЙ текст до разбора. Иначе запрещённый символ в
|
||||
// комментарии, неизвестном ключе или перед первым ключом мог бы исчезнуть
|
||||
// при нормализации, и preflight объявил бы повреждённый EnvironmentFile
|
||||
// корректным. Нормализованные значения проверяются повторно ниже.
|
||||
assertEnvTransportable("runtime EnvironmentFile", content);
|
||||
const env = parseEnv(content);
|
||||
|
||||
const uiPort = parsePort("HY2XS_UI_PORT", env.HY2XS_UI_PORT, 8080);
|
||||
@@ -250,8 +253,23 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
|
||||
uiBindHost,
|
||||
uiPublicAccess: parseBool("HY2XS_UI_PUBLIC_ACCESS", env.HY2XS_UI_PUBLIC_ACCESS, false),
|
||||
uiPort,
|
||||
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"),
|
||||
adminInitialPassword: valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD),
|
||||
// Умолчание — `hy2xsadmin`, и оно совпадает с package/config/hy2xs.env и с
|
||||
// запасным значением в apps/dao/sqlite.go. Раньше здесь стояло `admin`:
|
||||
// пять символов при минимуме панели в шесть, и третье расходящееся
|
||||
// умолчание про одно и то же имя. Установка при этом завершалась успешно, а
|
||||
// войти было нельзя — отказ приходил не установщику, а оператору, и уже без
|
||||
// объяснения.
|
||||
adminUser: assertValidAdminUsername(
|
||||
"HY2XS_ADMIN_USER",
|
||||
requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "hy2xsadmin")
|
||||
),
|
||||
// Проверяется и сгенерированный пароль, а не только заданный оператором:
|
||||
// генератор — такой же источник значения, и его расхождение с контрактом
|
||||
// панели обязано ронять установку, а не всплывать на форме входа.
|
||||
adminInitialPassword: assertValidAdminPassword(
|
||||
"HY2XS_ADMIN_INITIAL_PASSWORD",
|
||||
valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD)
|
||||
),
|
||||
adminConPass: requireValue("HY2XS_ADMIN_CON_PASS", valueOrGenerate(env.HY2XS_ADMIN_CON_PASS)),
|
||||
forcePasswordChange: parseBool("HY2XS_FORCE_PASSWORD_CHANGE", env.HY2XS_FORCE_PASSWORD_CHANGE, false),
|
||||
allowSelfSignedDev: parseBool("HY2XS_ALLOW_SELF_SIGNED_DEV", env.HY2XS_ALLOW_SELF_SIGNED_DEV, false),
|
||||
@@ -296,6 +314,18 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
|
||||
};
|
||||
|
||||
validateRuntimeConfig(config);
|
||||
// Проверка транспорта идёт ЗДЕСЬ, а не при записи файла.
|
||||
//
|
||||
// Раньше она жила только внутри renderRuntimeEnv, то есть срабатывала на шаге
|
||||
// «write runtime env» — уже ПОСЛЕ bootstrap оркестратора, установки пакетов и
|
||||
// раскладки файловой системы. Детерминированно известная ошибка конфигурации
|
||||
// роняла операцию, оставив за собой изменённый хост, а read-only
|
||||
// `preflight-install` про неё говорил PASS: он вызывает parseRuntimeEnv и не
|
||||
// рендерит ничего.
|
||||
//
|
||||
// Это противоречит контракту PHASE 0: всё, что про конфигурацию известно
|
||||
// детерминированно, обязано быть отвергнуто ДО первой необратимой мутации.
|
||||
validateRuntimeEnvTransport(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -379,47 +409,84 @@ export function validateRuntimeConfig(config: RuntimeConfig): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function renderRuntimeEnv(config: RuntimeConfig): string {
|
||||
const lines = [
|
||||
"# HY2XS runtime config (editable)",
|
||||
`HY2XS_CONFIG_SCHEMA_VERSION=${config.configSchemaVersion}`,
|
||||
`HY2XS_IPV6_ENABLED=${config.ipv6Enabled}`,
|
||||
`HY2XS_DOMAIN=${config.domain}`,
|
||||
`HY2XS_DNS_AAAA_POLICY=${config.dnsAaaaPolicy}`,
|
||||
`HY2XS_PUBLIC_ENDPOINT_POLICY=${config.publicEndpointPolicy}`,
|
||||
`HY2XS_PUBLIC_HOST=${config.publicHost}`,
|
||||
`HY2XS_PUBLIC_PORT=${config.publicPort}`,
|
||||
`HY2XS_SSH_PORT=${config.sshPort}`,
|
||||
`HY2XS_FIREWALL_MODE=${config.firewallMode}`,
|
||||
`HY2XS_FIREWALL_STAGED_APPLY=${config.firewallStagedApply}`,
|
||||
`HY2XS_UI_BIND_HOST=${config.uiBindHost}`,
|
||||
`HY2XS_UI_PUBLIC_ACCESS=${config.uiPublicAccess}`,
|
||||
`HY2XS_UI_PORT=${config.uiPort}`,
|
||||
`HY2XS_ADMIN_USER=${config.adminUser}`,
|
||||
`HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`,
|
||||
`HY2XS_ADMIN_CON_PASS=${config.adminConPass}`,
|
||||
`HY2XS_FORCE_PASSWORD_CHANGE=${config.forcePasswordChange}`,
|
||||
`HY2XS_ALLOW_SELF_SIGNED_DEV=${config.allowSelfSignedDev}`,
|
||||
`HY2XS_TLS_MODE=${config.tlsMode}`,
|
||||
`HY2XS_ACME_TYPE=${config.acmeType}`,
|
||||
`HY2XS_ACME_EMAIL=${config.acmeEmail}`,
|
||||
`HY2XS_TLS_CERT_PATH=${config.tlsCertPath}`,
|
||||
`HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`,
|
||||
`HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`,
|
||||
`HY2XS_HYSTERIA_PORT=${config.hysteriaPort}`,
|
||||
`HY2XS_HYSTERIA_AUTH_MODE=${config.hysteriaAuthMode}`,
|
||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=${config.hysteriaTrafficStatsHost}`,
|
||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`,
|
||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${config.hysteriaTrafficStatsSecret}`,
|
||||
`HY2XS_HYSTERIA_OBFS_TYPE=${config.hysteriaObfsType}`,
|
||||
`HY2XS_HYSTERIA_OBFS_PASSWORD=${config.hysteriaObfsPassword}`,
|
||||
`HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`,
|
||||
`HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`,
|
||||
`HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=${config.hysteriaIgnoreClientBandwidth}`,
|
||||
`HY2XS_HYSTERIA_CONFIG_PATH=${config.hysteriaConfigPath}`,
|
||||
`HY2XS_INSTALL_DIR=${config.installDir}`,
|
||||
`HY2XS_DATA_DIR=${config.dataDir}`,
|
||||
`HY2XS_LOG_DIR=${config.logDir}`
|
||||
/**
|
||||
* Пары `KEY=VALUE`, которые уезжают в /etc/hy2xs/hy2xs.env.
|
||||
*
|
||||
* Вынесены из renderRuntimeEnv, потому что у списка ДВА потребителя: запись
|
||||
* файла и проверка транспорта, выполняемая задолго до неё. Пока список
|
||||
* существовал только внутри рендера, единственным способом узнать, что
|
||||
* конфигурация не запишется, было её записать.
|
||||
*/
|
||||
export function runtimeEnvEntries(config: RuntimeConfig): [string, string][] {
|
||||
return [
|
||||
["HY2XS_CONFIG_SCHEMA_VERSION", String(config.configSchemaVersion)],
|
||||
["HY2XS_IPV6_ENABLED", String(config.ipv6Enabled)],
|
||||
["HY2XS_DOMAIN", config.domain],
|
||||
["HY2XS_DNS_AAAA_POLICY", config.dnsAaaaPolicy],
|
||||
["HY2XS_PUBLIC_ENDPOINT_POLICY", config.publicEndpointPolicy],
|
||||
["HY2XS_PUBLIC_HOST", config.publicHost],
|
||||
["HY2XS_PUBLIC_PORT", String(config.publicPort)],
|
||||
["HY2XS_SSH_PORT", String(config.sshPort)],
|
||||
["HY2XS_FIREWALL_MODE", config.firewallMode],
|
||||
["HY2XS_FIREWALL_STAGED_APPLY", String(config.firewallStagedApply)],
|
||||
["HY2XS_UI_BIND_HOST", config.uiBindHost],
|
||||
["HY2XS_UI_PUBLIC_ACCESS", String(config.uiPublicAccess)],
|
||||
["HY2XS_UI_PORT", String(config.uiPort)],
|
||||
["HY2XS_ADMIN_USER", config.adminUser],
|
||||
["HY2XS_ADMIN_INITIAL_PASSWORD", config.adminInitialPassword],
|
||||
["HY2XS_ADMIN_CON_PASS", config.adminConPass],
|
||||
["HY2XS_FORCE_PASSWORD_CHANGE", String(config.forcePasswordChange)],
|
||||
["HY2XS_ALLOW_SELF_SIGNED_DEV", String(config.allowSelfSignedDev)],
|
||||
["HY2XS_TLS_MODE", config.tlsMode],
|
||||
["HY2XS_ACME_TYPE", config.acmeType],
|
||||
["HY2XS_ACME_EMAIL", config.acmeEmail],
|
||||
["HY2XS_TLS_CERT_PATH", config.tlsCertPath],
|
||||
["HY2XS_TLS_KEY_PATH", config.tlsKeyPath],
|
||||
["HY2XS_HYSTERIA_BIND_HOST", config.hysteriaBindHost],
|
||||
["HY2XS_HYSTERIA_PORT", String(config.hysteriaPort)],
|
||||
["HY2XS_HYSTERIA_AUTH_MODE", config.hysteriaAuthMode],
|
||||
["HY2XS_HYSTERIA_TRAFFIC_STATS_HOST", config.hysteriaTrafficStatsHost],
|
||||
["HY2XS_HYSTERIA_TRAFFIC_STATS_PORT", String(config.hysteriaTrafficStatsPort)],
|
||||
["HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", config.hysteriaTrafficStatsSecret],
|
||||
["HY2XS_HYSTERIA_OBFS_TYPE", config.hysteriaObfsType],
|
||||
["HY2XS_HYSTERIA_OBFS_PASSWORD", config.hysteriaObfsPassword],
|
||||
["HY2XS_HYSTERIA_BANDWIDTH_UP", config.hysteriaBandwidthUp],
|
||||
["HY2XS_HYSTERIA_BANDWIDTH_DOWN", config.hysteriaBandwidthDown],
|
||||
["HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH", String(config.hysteriaIgnoreClientBandwidth)],
|
||||
["HY2XS_HYSTERIA_CONFIG_PATH", config.hysteriaConfigPath],
|
||||
["HY2XS_INSTALL_DIR", config.installDir],
|
||||
["HY2XS_DATA_DIR", config.dataDir],
|
||||
["HY2XS_LOG_DIR", config.logDir]
|
||||
];
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отвергает конфигурацию, которую нельзя записать в файл окружения так, чтобы
|
||||
* systemd её прочитал.
|
||||
*
|
||||
* Проверяются ВСЕ значения, а не только пароль администратора. Ограничение
|
||||
* принадлежит формату, а не одному полю: `HY2XS_ADMIN_CON_PASS`,
|
||||
* `HY2XS_HYSTERIA_OBFS_PASSWORD` и любой будущий параметр сломали бы загрузку
|
||||
* юнита ровно тем же способом.
|
||||
*/
|
||||
export function validateRuntimeEnvTransport(config: RuntimeConfig): void {
|
||||
for (const [key, value] of runtimeEnvEntries(config)) {
|
||||
assertEnvTransportable(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Пишет /etc/hy2xs/hy2xs.env.
|
||||
*
|
||||
* Каждое значение проходит через formatEnvAssignment, а не подставляется в
|
||||
* строку интерполяцией. Раньше подставлялось, и файл поэтому был форматом
|
||||
* только для значений без пробелов по краям, кавычек и обратных слешей: пароль
|
||||
* администратора, у которого набор символов объявлен неограниченным, не
|
||||
* пережил бы обратного чтения — ни нашего, ни systemd'ного.
|
||||
*
|
||||
* Обычные значения (порты, пути, домены, `50 mbps`) кавычек не получают и
|
||||
* остаются побайтово прежними — см. UNQUOTED_SAFE_VALUE в lib/envFile.
|
||||
*/
|
||||
export function renderRuntimeEnv(config: RuntimeConfig): string {
|
||||
return `# HY2XS runtime config (editable)\n${renderEnvFile(runtimeEnvEntries(config))}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HysteriaObfsType, RuntimeConfig } from "../types/context";
|
||||
import { isEnvTransportable } from "../lib/envFile";
|
||||
|
||||
/**
|
||||
* HY2XS production profile: единственное место, где определены значения
|
||||
@@ -41,6 +42,151 @@ export const HY2XS_TARGET_ARCH = "amd64";
|
||||
export const ADMIN_API_BASE = "/api";
|
||||
export const HYSTERIA_MACHINE_AUTH_PATH = "/internal/hysteria/auth";
|
||||
|
||||
/**
|
||||
* Путь формы входа в панель. Смысл тот же, что у HYSTERIA_MACHINE_AUTH_PATH:
|
||||
* это runtime-контракт продукта, по которому smoke проверяет, что установка
|
||||
* оставила после себя РАБОТАЮЩУЮ панель, а не просто открытый порт.
|
||||
*/
|
||||
export const ADMIN_LOGIN_PATH = `${ADMIN_API_BASE}/auth/login`;
|
||||
|
||||
/**
|
||||
* Контракт учётных данных администратора.
|
||||
*
|
||||
* Зачем он здесь. Оркестратор задаёт имя и первый пароль администратора, а
|
||||
* принимает их панель — по правилам, которых оркестратор не знал вовсе.
|
||||
* Следствие было не теоретическим: значением по умолчанию здесь стояло
|
||||
* `admin` — пять символов при минимуме в шесть, — и такая установка
|
||||
* завершалась `INSTALL EXIT CODE: 0`, оставляя панель, в которую невозможно
|
||||
* войти. Проверять контракт обязана та сторона, которая значение ПОРОЖДАЕТ:
|
||||
* отказ установки чинится одной строкой в hy2xs.env, а неработающий вход на
|
||||
* готовом сервере — переустановкой.
|
||||
*
|
||||
* Значения обязаны совпадать с apps/credential/admin.go; сверка выполняется
|
||||
* тестом admin-credentials.test.ts, который читает Go-исходник.
|
||||
*/
|
||||
export const ADMIN_USERNAME_MIN_LENGTH = 6;
|
||||
export const ADMIN_USERNAME_MAX_LENGTH = 32;
|
||||
export const ADMIN_PASSWORD_MIN_LENGTH = 6;
|
||||
export const ADMIN_PASSWORD_MAX_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Предел bcrypt — в БАЙТАХ.
|
||||
*
|
||||
* Он существует одновременно с границей в символах и не выводится из неё: у 64
|
||||
* символов длина от 64 до 256 байт в зависимости от алфавита. Пока оркестратор
|
||||
* знал только границу в символах, он пропускал в hy2xs.env пароль вроде 64
|
||||
* кириллических букв (128 байт), установка проходила целиком, а первая учётная
|
||||
* запись администратора не создавалась вовсе — bcrypt отвечал
|
||||
* ErrPasswordTooLong уже внутри админки, при старте службы.
|
||||
*/
|
||||
export const ADMIN_PASSWORD_MAX_BYTES = 72;
|
||||
|
||||
/**
|
||||
* Набор символов логина в записи регекспа.
|
||||
*
|
||||
* Дефис ЭКРАНИРОВАН намеренно. В исходной записи `_+-=` он экранирован не был,
|
||||
* из-за чего `+-=` образовывал диапазон и молча впускал `, - . / 0-9 : ; < =`.
|
||||
* Здесь перечислено то же самое ФАКТИЧЕСКОЕ множество, но явно: сужать его в
|
||||
* одиночку нельзя — оно уже действует на установленных серверах.
|
||||
*/
|
||||
const ADMIN_USERNAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+,\\-./:;<=";
|
||||
|
||||
export const ADMIN_USERNAME_PATTERN = new RegExp(
|
||||
`^[${ADMIN_USERNAME_CHARACTER_CLASS}]{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}}$`
|
||||
);
|
||||
|
||||
/** Тот же набор в том виде, в каком его показывают оператору. */
|
||||
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
|
||||
|
||||
/**
|
||||
* Проверка логина администратора против контракта панели.
|
||||
*
|
||||
* Возвращает значение, а не булево: вызывающий обязан использовать именно
|
||||
* проверенное — с обрезанными краями, — иначе пробел из hy2xs.env уедет в базу
|
||||
* и вход снова перестанет работать по причине, которую негде увидеть.
|
||||
*/
|
||||
export function assertValidAdminUsername(name: string, value: string): string {
|
||||
const username = value.trim();
|
||||
if (!ADMIN_USERNAME_PATTERN.test(username)) {
|
||||
throw new Error(
|
||||
`invalid ${name}: панель принимает от ${ADMIN_USERNAME_MIN_LENGTH} до ${ADMIN_USERNAME_MAX_LENGTH} ` +
|
||||
`символов из набора ${ADMIN_USERNAME_CHARSET}. ` +
|
||||
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
|
||||
);
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Единственное правило пароля администратора на стороне оркестратора.
|
||||
*
|
||||
* Копия Go-контракта, и сверяется с ним по исходнику (admin-credentials.test.ts):
|
||||
* оркестратор собирается отдельно от Go-бинарника и импортировать его константы
|
||||
* не может.
|
||||
*
|
||||
* Правило состоит из трёх частей, и каждая закрывает свой класс отказа.
|
||||
*
|
||||
* Длина в CODE POINTS, а не в `String.length`. `String.length` считает единицы
|
||||
* UTF-16, и пароль из трёх эмодзи имел бы здесь длину 6 — прошёл бы минимум и
|
||||
* получил отказ на форме входа, где сервер видит три руны.
|
||||
*
|
||||
* Длина в БАЙТАХ — предел bcrypt. Пока его не было, пароль из 64 кириллических
|
||||
* букв проходил установку целиком, а учётная запись администратора не
|
||||
* создавалась: bcrypt отвечал ErrPasswordTooLong при старте админки, и оператор
|
||||
* получал не отказ установки, а сервер без администратора.
|
||||
*
|
||||
* Управляющие символы формат EnvironmentFile способен нести; их отвергает
|
||||
* политика HY2XS, потому что невидимое значение нельзя надёжно повторить в
|
||||
* однострочной форме входа. Транспортный домен (включая U+FEFF) проверяется
|
||||
* отдельно в lib/envFile.ts.
|
||||
*
|
||||
* Набор символов сверх этого НЕ ограничивается: сервер его не ограничивает ни
|
||||
* при установке, ни при смене пароля, и оркестратор не имеет права быть строже.
|
||||
* Пробелы по краям — часть пароля и не снимаются.
|
||||
*/
|
||||
export function isValidAdminPassword(value: string): boolean {
|
||||
// Домен транспорта проверяется ПЕРВЫМ: значение вне него не доедет до админки
|
||||
// вовсе — systemd откажется загружать /etc/hy2xs/hy2xs.env, и юнит не
|
||||
// стартует. Это отказ более грубого рода, чем нарушение границ длины, и
|
||||
// раньше контракт про него не знал: `abcde` плюс U+FDD0 — шесть символов,
|
||||
// восемь байт, ни одного управляющего — проходило здесь, записывалось в файл
|
||||
// и оставляло сервер без работающей панели.
|
||||
if (!isEnvTransportable(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let characters = 0;
|
||||
for (const character of value) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
// Продуктовая политика: управляющие символы Unicode целиком (Cc — C0,
|
||||
// DEL, C1). U+FEFF уже отвергнут транспортным доменом выше.
|
||||
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
|
||||
return false;
|
||||
}
|
||||
characters += 1;
|
||||
}
|
||||
|
||||
if (characters < ADMIN_PASSWORD_MIN_LENGTH || characters > ADMIN_PASSWORD_MAX_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Buffer.byteLength(value, "utf8") <= ADMIN_PASSWORD_MAX_BYTES;
|
||||
}
|
||||
|
||||
export function assertValidAdminPassword(name: string, value: string): string {
|
||||
if (!isValidAdminPassword(value)) {
|
||||
throw new Error(
|
||||
`invalid ${name}: панель принимает пароль длиной от ${ADMIN_PASSWORD_MIN_LENGTH} ` +
|
||||
`до ${ADMIN_PASSWORD_MAX_LENGTH} символов Unicode, не длиннее ${ADMIN_PASSWORD_MAX_BYTES} байт ` +
|
||||
`в UTF-8, без управляющих символов и U+FEFF; получено ${[...value].length} символов ` +
|
||||
`и ${Buffer.byteLength(value, "utf8")} байт. ` +
|
||||
`Набор символов не ограничен, пробелы по краям являются частью пароля. ` +
|
||||
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Где оркестратор живёт на установленном хосте.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
export class InvalidUtf8Error extends Error {
|
||||
readonly path: string;
|
||||
|
||||
constructor(path: string, cause: unknown) {
|
||||
super(`файл ${path} содержит некорректную последовательность UTF-8`, { cause });
|
||||
this.name = "InvalidUtf8Error";
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
/** Явное replacement-декодирование только для диагностического вывода команд. */
|
||||
export function decodeUtf8Lossy(bytes: Uint8Array): string {
|
||||
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает текстовые файлы без молчаливой подмены повреждённых байтов на U+FFFD.
|
||||
*
|
||||
* `Blob.text()` выполняет обычное декодирование UTF-8 с replacement mode. Для
|
||||
* конфигурации и секретов это недопустимо: другое байтовое значение не должно
|
||||
* незаметно превратиться в корректную строку. `ignoreBOM: true` намеренно
|
||||
* сохраняет начальный BOM как U+FEFF, чтобы транспортный валидатор увидел и
|
||||
* отверг его по публичному контракту EnvironmentFile.
|
||||
*/
|
||||
export async function readText(path: string): Promise<string> {
|
||||
return await Bun.file(path).text();
|
||||
const bytes = await Bun.file(path).bytes();
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
||||
} catch (error) {
|
||||
throw new InvalidUtf8Error(path, error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeText(path: string, data: string, mode?: number): Promise<void> {
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import { readText } from "./fs";
|
||||
import { InvalidUtf8Error, readText } from "./fs";
|
||||
import { runReadOnly } from "./process";
|
||||
|
||||
export function isMissingPackageMetadataError(error: unknown): boolean {
|
||||
return (
|
||||
!(error instanceof InvalidUtf8Error) &&
|
||||
error !== null &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
export async function readPackageValue(packageDir: string, file: string, fallback: string): Promise<string> {
|
||||
try {
|
||||
return (await readText(`${packageDir}/metadata/${file}`)).trim();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Только отсутствие необязательного файла означает fallback. Ошибки прав,
|
||||
// ввода-вывода, неверного типа и повреждённый UTF-8 описывают битый либо
|
||||
// недоступный пакет и не имеют права маскироваться запасным значением.
|
||||
if (isMissingPackageMetadataError(error)) {
|
||||
return fallback;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readInstalledHysteriaVersion(): Promise<string> {
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
*/
|
||||
|
||||
import { assertMutationAllowed } from "./guard";
|
||||
import { decodeUtf8Lossy } from "./fs";
|
||||
import { info } from "./log";
|
||||
|
||||
function shellQuote(value: unknown): string {
|
||||
@@ -190,8 +191,77 @@ export type MutationResult = {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
stdoutTruncated?: boolean;
|
||||
stderrTruncated?: boolean;
|
||||
};
|
||||
|
||||
async function readDiagnosticPipe(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
maxBytes: number
|
||||
): Promise<{ text: string; truncated: boolean }> {
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let captured = 0;
|
||||
let truncated = false;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
const remaining = maxBytes - captured;
|
||||
if (remaining > 0) {
|
||||
// slice, а не subarray: иначе маленький сохранённый view удерживает в
|
||||
// памяти весь большой backing buffer и формально обходит лимит.
|
||||
const chunk = value.byteLength <= remaining ? value : value.slice(0, remaining);
|
||||
chunks.push(chunk);
|
||||
captured += chunk.byteLength;
|
||||
}
|
||||
if (value.byteLength > remaining) {
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(captured);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return { text: decodeUtf8Lossy(bytes), truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Наблюдение готовым argv с сохранением ненулевого кода возврата.
|
||||
* Диагностический вывод декодируется lossy намеренно; конфигурационные файлы
|
||||
* всегда проходят через строгий readText.
|
||||
*/
|
||||
export async function runReadOnlyArgvStatus(
|
||||
argv: string[],
|
||||
maxOutputBytes = 8 * 1024 * 1024
|
||||
): Promise<MutationResult> {
|
||||
assertArgv(argv, "runReadOnlyArgvStatus");
|
||||
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1) {
|
||||
throw new Error("runReadOnlyArgvStatus: maxOutputBytes must be a positive integer");
|
||||
}
|
||||
const subprocess = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" });
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
readDiagnosticPipe(subprocess.stdout, maxOutputBytes),
|
||||
readDiagnosticPipe(subprocess.stderr, maxOutputBytes),
|
||||
subprocess.exited
|
||||
]);
|
||||
return {
|
||||
exitCode,
|
||||
stdout: stdout.text,
|
||||
stderr: stderr.text,
|
||||
stdoutTruncated: stdout.truncated,
|
||||
stderrTruncated: stderr.truncated
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Мутация, которая ВОЗВРАЩАЕТ код возврата вместо броска.
|
||||
*
|
||||
|
||||
@@ -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 { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs";
|
||||
import { renderEnvFile } from "../lib/envFile";
|
||||
import { runMutatingVisible } from "../lib/process";
|
||||
import {
|
||||
BBR_PROFILE,
|
||||
@@ -56,14 +57,23 @@ export async function writePostInstallEnv(context: RuntimeContext): Promise<void
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Пишет /etc/hy2xs/bootstrap-admin.secret.
|
||||
*
|
||||
* Тот же формат и тот же кодировщик, что и у hy2xs.env, и по той же причине: в
|
||||
* файле лежит пароль администратора, набор символов которого продукт объявил
|
||||
* неограниченным. Пока строки собирались интерполяцией, пароль с пробелом по
|
||||
* краям, кавычкой или обратным слешем нельзя было прочитать обратно — а читает
|
||||
* этот файл smoke, чтобы ДОКАЗАТЬ, что в панель можно войти.
|
||||
*/
|
||||
export async function writeBootstrapAdminSecret(context: RuntimeContext): Promise<void> {
|
||||
await writeTextAtomic(
|
||||
context.config.bootstrapAdminSecretPath,
|
||||
[
|
||||
`ADMIN_USER=${context.config.adminUser}`,
|
||||
`ADMIN_INITIAL_PASSWORD=${context.config.adminInitialPassword}`,
|
||||
`ADMIN_CON_PASS=${context.config.adminConPass}`
|
||||
].join("\n") + "\n",
|
||||
renderEnvFile([
|
||||
["ADMIN_USER", context.config.adminUser],
|
||||
["ADMIN_INITIAL_PASSWORD", context.config.adminInitialPassword],
|
||||
["ADMIN_CON_PASS", context.config.adminConPass]
|
||||
]),
|
||||
{
|
||||
mode: 0o600,
|
||||
owner: "root",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InstallContext } from "../types/context";
|
||||
import { runMutating } from "../lib/process";
|
||||
import { ensureDiagnosticsStorageRoot } from "../lib/diagnosticsStorage";
|
||||
|
||||
async function userExists(user: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -58,5 +59,6 @@ export async function prepareFilesystem(context: InstallContext): Promise<void>
|
||||
await ensureDir(context.config.installDir, "0755", "root:root");
|
||||
await ensureDir("/usr/local/lib/hy2xs", "0755", "root:root");
|
||||
await ensureDir("/etc/nftables.d", "0755", "root:root");
|
||||
await ensureDiagnosticsStorageRoot();
|
||||
await runMutating`chmod -R go-w ${context.config.installDir}`;
|
||||
}
|
||||
|
||||
+409
-32
@@ -1,8 +1,18 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { RuntimeContext } from "../types/context";
|
||||
import { info } from "../lib/log";
|
||||
import { readText } from "../lib/fs";
|
||||
import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process";
|
||||
import { HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile";
|
||||
import { parseEnvFile } from "../lib/envFile";
|
||||
import {
|
||||
runReadOnly,
|
||||
runReadOnlySecret,
|
||||
runMutatingVisible,
|
||||
} from "../lib/process";
|
||||
import {
|
||||
ADMIN_LOGIN_PATH,
|
||||
HYSTERIA_MACHINE_AUTH_PATH,
|
||||
hysteriaMachineAuthUrl,
|
||||
} from "../config/profile";
|
||||
import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
|
||||
import { assertEffectiveFirewallIsOurs } from "./firewall";
|
||||
|
||||
@@ -80,7 +90,8 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
||||
1000,
|
||||
async () => runReadOnlySecret`systemctl is-active hysteria-server || true`,
|
||||
(state) => state.trim() === "active",
|
||||
(state, error) => new Error(`hysteria-server is not active: ${state ?? String(error)}`),
|
||||
(state, error) =>
|
||||
new Error(`hysteria-server is not active: ${state ?? String(error)}`),
|
||||
);
|
||||
await retry(
|
||||
"systemd hy2xs-admin active",
|
||||
@@ -88,7 +99,8 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
||||
1000,
|
||||
async () => runReadOnlySecret`systemctl is-active hy2xs-admin || true`,
|
||||
(state) => state.trim() === "active",
|
||||
(state, error) => new Error(`hy2xs-admin is not active: ${state ?? String(error)}`),
|
||||
(state, error) =>
|
||||
new Error(`hy2xs-admin is not active: ${state ?? String(error)}`),
|
||||
);
|
||||
|
||||
if (context.options.skipSmoke) {
|
||||
@@ -101,24 +113,38 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
||||
15,
|
||||
1000,
|
||||
async () => runReadOnlySecret`ss -H -ltn`,
|
||||
(lines) => hasTcpListener(lines, context.config.uiBindHost, context.config.uiPort),
|
||||
(lines, error) => new Error(`ui listener not ready on ${context.config.uiBindHost}:${context.config.uiPort}: ${lines ?? String(error)}`),
|
||||
(lines) =>
|
||||
hasTcpListener(lines, context.config.uiBindHost, context.config.uiPort),
|
||||
(lines, error) =>
|
||||
new Error(
|
||||
`ui listener not ready on ${context.config.uiBindHost}:${context.config.uiPort}: ${lines ?? String(error)}`,
|
||||
),
|
||||
);
|
||||
await retry(
|
||||
"hysteria udp listener readiness",
|
||||
15,
|
||||
1000,
|
||||
async () => runReadOnlySecret`ss -H -lun`,
|
||||
(lines) => hasUdpListener(lines, context.config.hysteriaBindHost, context.config.hysteriaPort),
|
||||
(lines, error) => new Error(`hysteria udp listener not ready on 0.0.0.0:${context.config.hysteriaPort}: ${lines ?? String(error)}`),
|
||||
(lines) =>
|
||||
hasUdpListener(
|
||||
lines,
|
||||
context.config.hysteriaBindHost,
|
||||
context.config.hysteriaPort,
|
||||
),
|
||||
(lines, error) =>
|
||||
new Error(
|
||||
`hysteria udp listener not ready on 0.0.0.0:${context.config.hysteriaPort}: ${lines ?? String(error)}`,
|
||||
),
|
||||
);
|
||||
await retry(
|
||||
"admin healthz readiness",
|
||||
15,
|
||||
1000,
|
||||
async () => runReadOnlySecret`curl -sS --max-time 5 http://127.0.0.1:${context.config.uiPort}/healthz`,
|
||||
async () =>
|
||||
runReadOnlySecret`curl -sS --max-time 5 http://127.0.0.1:${context.config.uiPort}/healthz`,
|
||||
(response) => /"ok"\s*:\s*true/.test(response),
|
||||
(response, error) => new Error(`admin healthz is not ready: ${response ?? String(error)}`),
|
||||
(response, error) =>
|
||||
new Error(`admin healthz is not ready: ${response ?? String(error)}`),
|
||||
);
|
||||
|
||||
// Всё, что ниже, — НАБЛЮДЕНИЕ, и оно выполняется read-only раннерами.
|
||||
@@ -129,7 +155,9 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
||||
// не меняет диагностируемую систему» невозможно было включить guard'ом — он
|
||||
// отказал бы на первой же читающей команде. Классификация здесь — часть
|
||||
// контракта, а не стиль.
|
||||
info(`installed Hysteria: ${await runReadOnly`/usr/local/bin/hysteria version`}`);
|
||||
info(
|
||||
`installed Hysteria: ${await runReadOnly`/usr/local/bin/hysteria version`}`,
|
||||
);
|
||||
await runReadOnly`test -s /etc/hysteria/config.yaml`;
|
||||
await runReadOnly`test -s /etc/hy2xs/hy2xs.env`;
|
||||
await runReadOnly`test -s /etc/hysteria/post-install.env`;
|
||||
@@ -154,7 +182,9 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
||||
if (context.config.uiBindHost === "127.0.0.1") {
|
||||
const tcp = await runReadOnlySecret`ss -H -ltn`;
|
||||
if (hasTcpListener(tcp, "0.0.0.0", context.config.uiPort)) {
|
||||
throw new Error(`ui listener must not be public on 0.0.0.0:${context.config.uiPort}`);
|
||||
throw new Error(
|
||||
`ui listener must not be public on 0.0.0.0:${context.config.uiPort}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await runReadOnly`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`;
|
||||
@@ -165,61 +195,90 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
||||
const machineAuthUrlNoToken = `http://127.0.0.1:${context.config.uiPort}${HYSTERIA_MACHINE_AUTH_PATH}`;
|
||||
const machineAuthUrl = hysteriaMachineAuthUrl(
|
||||
context.config.uiPort,
|
||||
context.config.hysteriaTrafficStatsSecret
|
||||
context.config.hysteriaTrafficStatsSecret,
|
||||
);
|
||||
|
||||
const missingTokenAuthCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrlNoToken}`;
|
||||
const missingTokenAuthCode =
|
||||
await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrlNoToken}`;
|
||||
if (missingTokenAuthCode.trim() !== "403") {
|
||||
throw new Error(`unexpected auth status without machine token: ${missingTokenAuthCode}`);
|
||||
throw new Error(
|
||||
`unexpected auth status without machine token: ${missingTokenAuthCode}`,
|
||||
);
|
||||
}
|
||||
const invalidAuthResponse = await retry(
|
||||
"auth invalid credentials",
|
||||
5,
|
||||
1000,
|
||||
async () => runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`,
|
||||
async () =>
|
||||
runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`,
|
||||
(response) => /"ok"\s*:\s*false/.test(response),
|
||||
(response, error) => new Error(`unexpected auth response for invalid credentials: ${response ?? String(error)}`),
|
||||
(response, error) =>
|
||||
new Error(
|
||||
`unexpected auth response for invalid credentials: ${response ?? String(error)}`,
|
||||
),
|
||||
);
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const response = await runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`;
|
||||
const response =
|
||||
await runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`;
|
||||
if (!/"ok"\s*:\s*false/.test(response)) {
|
||||
throw new Error(`unexpected auth response during rate-limit smoke: ${response}`);
|
||||
throw new Error(
|
||||
`unexpected auth response during rate-limit smoke: ${response}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const invalidTypeAuthCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' ${machineAuthUrl}`;
|
||||
const invalidTypeAuthCode =
|
||||
await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' ${machineAuthUrl}`;
|
||||
if (invalidTypeAuthCode.trim() !== "400") {
|
||||
throw new Error(`unexpected auth status for tx as string: ${invalidTypeAuthCode}`);
|
||||
throw new Error(
|
||||
`unexpected auth status for tx as string: ${invalidTypeAuthCode}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mode === "install") {
|
||||
const adminConPass = (await runReadOnlySecret`grep '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-`).trim();
|
||||
const adminConPass =
|
||||
(await readBootstrapAdminSecret(context)).ADMIN_CON_PASS ?? "";
|
||||
if (!adminConPass) {
|
||||
throw new Error("admin connection password is empty in bootstrap secret file");
|
||||
throw new Error(
|
||||
"admin connection password is empty in bootstrap secret file",
|
||||
);
|
||||
}
|
||||
|
||||
await retry(
|
||||
"auth valid credentials",
|
||||
10,
|
||||
1000,
|
||||
async () => runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"${adminConPass}","tx":0}' ${machineAuthUrl}`,
|
||||
async () =>
|
||||
runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"${adminConPass}","tx":0}' ${machineAuthUrl}`,
|
||||
(response) => /"ok"\s*:\s*true/.test(response),
|
||||
(response, error) => new Error(`unexpected auth response for valid credentials: ${response ?? String(error)}`),
|
||||
(response, error) =>
|
||||
new Error(
|
||||
`unexpected auth response for valid credentials: ${response ?? String(error)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await assertAdminLoginWorks(context);
|
||||
|
||||
await retry(
|
||||
"trafficStats valid secret",
|
||||
10,
|
||||
1000,
|
||||
async () => runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: ${context.config.hysteriaTrafficStatsSecret}' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`,
|
||||
async () =>
|
||||
runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: ${context.config.hysteriaTrafficStatsSecret}' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`,
|
||||
(code) => /^2\d\d$/.test(code.trim()),
|
||||
(code, error) => new Error(`unexpected trafficStats status for valid secret: ${code ?? String(error)}`),
|
||||
(code, error) =>
|
||||
new Error(
|
||||
`unexpected trafficStats status for valid secret: ${code ?? String(error)}`,
|
||||
),
|
||||
);
|
||||
const deniedCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: invalid-hy2xs-secret' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`;
|
||||
const deniedCode =
|
||||
await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: invalid-hy2xs-secret' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`;
|
||||
if (!/(401|403)/.test(deniedCode)) {
|
||||
throw new Error(`unexpected trafficStats status for invalid secret: ${deniedCode}`);
|
||||
throw new Error(
|
||||
`unexpected trafficStats status for invalid secret: ${deniedCode}`,
|
||||
);
|
||||
}
|
||||
|
||||
// `nft -c` только разбирает файл и правил не применяет — это проверка
|
||||
@@ -233,23 +292,341 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
||||
// синтаксически валидный — ruleset, проверка проходила зелёной, и операция
|
||||
// объявляла успешной установку, работающую на firewall, который она же
|
||||
// только что заменила.
|
||||
info("verifying that the effective firewall is the one generated for this configuration");
|
||||
info(
|
||||
"verifying that the effective firewall is the one generated for this configuration",
|
||||
);
|
||||
await assertEffectiveFirewallIsOurs(context);
|
||||
|
||||
// Семантическая проверка установленного конфига: разбираем YAML и сверяем
|
||||
// с production-профилем, а не ищем подстроки.
|
||||
info("verifying effective Hysteria config against HY2XS production profile");
|
||||
assertHysteriaConfigMatchesProfile(await readText("/etc/hysteria/config.yaml"), context.config);
|
||||
assertHysteriaConfigMatchesProfile(
|
||||
await readText("/etc/hysteria/config.yaml"),
|
||||
context.config,
|
||||
);
|
||||
|
||||
await assertEffectiveHysteriaVersion(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Панель обязана ВПУСКАТЬ, а не просто слушать порт.
|
||||
*
|
||||
* Почему эта проверка появилась. До неё установка отвечала на вопрос «работает
|
||||
* ли панель» тремя фактами: юнит активен, `127.0.0.1:8080` в LISTEN, `/healthz`
|
||||
* отвечает `ok:true`. RC2 доказал, что все три могут быть истинными
|
||||
* одновременно с полностью недоступной панелью: на поле логина стоял тег
|
||||
* несуществующего правила валидации, `POST /api/auth/login` паниковал ещё до
|
||||
* проверки учётных данных, gin.Recovery превращал панику в HTTP 500 — и
|
||||
* установка завершалась `INSTALL EXIT CODE: 0`.
|
||||
*
|
||||
* Разница между «порт открыт» и «оператор может войти» — это весь продукт,
|
||||
* поэтому smoke выполняет НАСТОЯЩИЙ вход теми учётными данными, которые создал
|
||||
* установщик.
|
||||
*
|
||||
* Что здесь важно по деталям:
|
||||
*
|
||||
* - тело собирается JSON.stringify, а не интерполяцией в строку. Пароль
|
||||
* задаёт оператор, и кавычка или обратный слеш в нём иначе сломали бы не
|
||||
* панель, а сам запрос — и проверка объявила бы рабочую установку сломанной;
|
||||
* - обе команды идут через runReadOnlySecret: этот раннер не кладёт команду в
|
||||
* текст ошибки, а команда несёт пароль администратора. Ошибка проверки
|
||||
* уезжает в журнал и в diagnostics-бандл;
|
||||
* - положительная проба выполняется только на install. На reconfigure пароль в
|
||||
* bootstrap-admin.secret устаревает в тот момент, когда оператор сменил его
|
||||
* в панели, и требовать по нему вход значило бы ронять законную операцию;
|
||||
* - отрицательная проба выполняется ВСЕГДА и от актуальности пароля не
|
||||
* зависит. Именно она воспроизводит дефект RC2: заведомо неверные учётные
|
||||
* данные обязаны получить осмысленный отказ, а не 500.
|
||||
*/
|
||||
async function assertAdminLoginWorks(context: RuntimeContext): Promise<void> {
|
||||
const loginUrl = `http://127.0.0.1:${context.config.uiPort}${ADMIN_LOGIN_PATH}`;
|
||||
|
||||
// Отрицательная проба доказывает ОТКАЗ, а не отсутствие HTTP 500.
|
||||
//
|
||||
// Здесь стояли жёстко записанный пароль `definitely-not-the-admin-password` и
|
||||
// критерий `code.trim() === "200"`. У этой пары было два изъяна. Пароль был
|
||||
// литералом, то есть теоретически мог оказаться настоящим — и тогда проверка
|
||||
// «неверные данные отвергаются» отчиталась бы об успешном ВХОДЕ. А критерий
|
||||
// «HTTP 200» не отличал отказ от успеха вовсе: админка отвечает 200 и на то,
|
||||
// и на другое, причина живёт в конверте. Проверка доказывала ровно одно —
|
||||
// что запрос не закончился пятисоткой.
|
||||
//
|
||||
// Теперь пароль генерируется в момент пробы, а результат сверяется с
|
||||
// конвертом: код 50000, причина `invalid_credentials` и ОТСУТСТВИЕ токена
|
||||
// доступа. Логин берётся настоящий — тот же, что у администратора, — чтобы
|
||||
// запрос шёл тем же путём, что и реальный вход, и доходил до проверки пароля.
|
||||
const rejectedPassword = randomBytes(24).toString("base64url");
|
||||
const rejectedBody = await retry(
|
||||
"admin login rejects wrong credentials",
|
||||
10,
|
||||
1000,
|
||||
async () =>
|
||||
requestAdminLogin(loginUrl, context.config.adminUser, rejectedPassword),
|
||||
(body) => isRejectedLogin(body),
|
||||
(body, error) =>
|
||||
new Error(
|
||||
`admin login did not reject invalid credentials: ${describeRejectionFailure(body, error)}\n` +
|
||||
`Панель обязана отвечать конвертом отказа с причиной ${INVALID_CREDENTIALS_CODE}. ` +
|
||||
`Отсутствие такого конверта означает, что запрос не доживает до проверки учётных данных.`,
|
||||
),
|
||||
);
|
||||
info(
|
||||
`admin login rejects wrong credentials: ${describeRejection(rejectedBody)}`,
|
||||
);
|
||||
|
||||
if (context.mode !== "install") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Файл читается ЦЕЛИКОМ и разбирается тем же парсером, которым он написан.
|
||||
//
|
||||
// Здесь стояло `grep … | cut -d= -f2-` с последующим `.trim()`, и это был
|
||||
// третий по счёту слой, срезающий пробелы у пароля, набор символов которого
|
||||
// объявлен неограниченным. Даже с починенными env-транспортом и админкой
|
||||
// проба брала бы не тот пароль и объявляла бы рабочую установку сломанной.
|
||||
// Побочно исчез и прогон секрета через shell-конвейер.
|
||||
const bootstrap = await readBootstrapAdminSecret(context);
|
||||
const adminUser = bootstrap.ADMIN_USER ?? "";
|
||||
const adminPassword = bootstrap.ADMIN_INITIAL_PASSWORD ?? "";
|
||||
if (!adminUser) {
|
||||
throw new Error("admin username is empty in bootstrap secret file");
|
||||
}
|
||||
if (!adminPassword) {
|
||||
throw new Error("admin initial password is empty in bootstrap secret file");
|
||||
}
|
||||
|
||||
// Отрицательная проба обязана быть отрицательной. Совпадение случайного
|
||||
// пароля с настоящим невероятно, но проверить это здесь можно точно, и тогда
|
||||
// «отвергнуто» гарантированно означает «отвергнуто», а не «не совпало».
|
||||
if (rejectedPassword === adminPassword) {
|
||||
throw new Error(
|
||||
"negative login probe accidentally used the real admin password",
|
||||
);
|
||||
}
|
||||
|
||||
const response = await retry(
|
||||
"admin login with bootstrap credentials",
|
||||
10,
|
||||
1000,
|
||||
async () => requestAdminLogin(loginUrl, adminUser, adminPassword),
|
||||
(body) => isSuccessfulLogin(body),
|
||||
(body, error) =>
|
||||
new Error(
|
||||
`admin panel refused the bootstrap login it created itself: ${describeLoginFailure(body, error)}\n` +
|
||||
`Порт открыт и /healthz отвечает, но войти в панель нельзя — установка не считается выполненной.`,
|
||||
),
|
||||
);
|
||||
|
||||
info(`admin login accepted: ${describeIssuedToken(response)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* UA login-smoke называется своим именем и не маскируется под браузер.
|
||||
*
|
||||
* curl по умолчанию отправляет `curl/<version>`, а production middleware
|
||||
* осознанно отклоняет scanner-like UA до разбора DTO. Без явного значения
|
||||
* установщик проверял не вход, а собственную несовместимость с middleware.
|
||||
*/
|
||||
export const ADMIN_LOGIN_SMOKE_USER_AGENT = "HY2XS-Installer/1.0";
|
||||
|
||||
export type AdminLoginRequest = {
|
||||
contentType: "application/json";
|
||||
userAgent: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
/** Единственный wire-контракт обеих login-проб. */
|
||||
export function buildAdminLoginRequest(
|
||||
username: string,
|
||||
pass: string,
|
||||
): AdminLoginRequest {
|
||||
return {
|
||||
contentType: "application/json",
|
||||
userAgent: ADMIN_LOGIN_SMOKE_USER_AGENT,
|
||||
body: JSON.stringify({ username, pass }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет login-пробу без утечки тела в текст ошибки.
|
||||
*
|
||||
* Positive и negative smoke намеренно проходят через одну функцию: заголовки,
|
||||
* имя wire-поля и настройки транспорта не могут разойтись между пробами.
|
||||
*/
|
||||
async function requestAdminLogin(
|
||||
loginUrl: string,
|
||||
username: string,
|
||||
pass: string,
|
||||
): Promise<string> {
|
||||
const request = buildAdminLoginRequest(username, pass);
|
||||
return runReadOnlySecret`curl -sS --max-time 5 --request POST --header ${`Content-Type: ${request.contentType}`} --user-agent ${request.userAgent} --data ${request.body} ${loginUrl}`;
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
function objectOrNull(value: unknown): JsonObject | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as JsonObject)
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseLoginEnvelope(body: string): JsonObject | null {
|
||||
try {
|
||||
return objectOrNull(JSON.parse(body));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function accessTokenOf(envelope: JsonObject | null): string | null {
|
||||
const data = objectOrNull(envelope?.data);
|
||||
return typeof data?.accessToken === "string" && data.accessToken.length > 0
|
||||
? data.accessToken
|
||||
: null;
|
||||
}
|
||||
|
||||
function carriesAccessToken(envelope: JsonObject | null): boolean {
|
||||
const data = objectOrNull(envelope?.data);
|
||||
return data !== null && Object.hasOwn(data, "accessToken") && data.accessToken !== null;
|
||||
}
|
||||
|
||||
function rejectionCodesOf(envelope: JsonObject | null): string[] {
|
||||
if (!Array.isArray(envelope?.errors)) {
|
||||
return [];
|
||||
}
|
||||
return envelope.errors.flatMap((error) => {
|
||||
const item = objectOrNull(error);
|
||||
return typeof item?.code === "string" ? [item.code] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Успех определяется по КОНВЕРТУ, а не по коду HTTP.
|
||||
*
|
||||
* Админка отвечает `200 OK` и на отказ тоже: причина живёт в поле `code`
|
||||
* ответа. Проверка «HTTP 200» приняла бы за успешный вход любой отказ — то есть
|
||||
* ровно ничего бы не проверяла.
|
||||
*
|
||||
* Выданный токен требуется отдельно: `code: 20000` без `accessToken` означал бы
|
||||
* панель, которая пускает и не выдаёт сессию.
|
||||
*/
|
||||
export function isSuccessfulLogin(body: string): boolean {
|
||||
const envelope = parseLoginEnvelope(body);
|
||||
return envelope?.code === 20000 && accessTokenOf(envelope) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Код причины отказа по учётным данным — тот же литерал, что и
|
||||
* constant.ErrCodeInvalidCredentials в админке.
|
||||
*
|
||||
* Он часть публичного контракта API: панель выбирает по нему локализованную
|
||||
* фразу, и его значение не меняется вместе с формулировками сообщений.
|
||||
*/
|
||||
const INVALID_CREDENTIALS_CODE = "invalid_credentials";
|
||||
|
||||
/**
|
||||
* Отказ определяется по КОНВЕРТУ целиком, а не по отсутствию HTTP 500.
|
||||
*
|
||||
* Требуются три признака сразу, и каждый закрывает свой способ ошибиться:
|
||||
*
|
||||
* код ответа 50000 — это отказ уровня операции, а не успех (20000) и
|
||||
* не отказ валидации (50001), который означал бы,
|
||||
* что проба отправила негодный запрос и до проверки
|
||||
* пароля он не дошёл;
|
||||
* причина invalid_credentials — отказ именно по учётным данным, а не,
|
||||
* скажем, по недоступной базе;
|
||||
* отсутствие accessToken — панель не выдала сессию.
|
||||
*/
|
||||
export function isRejectedLogin(body: string): boolean {
|
||||
const envelope = parseLoginEnvelope(body);
|
||||
return (
|
||||
envelope?.code === 50000 &&
|
||||
rejectionCodesOf(envelope).includes(INVALID_CREDENTIALS_CODE) &&
|
||||
!carriesAccessToken(envelope)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Причина, по которой отрицательная проба не признана отказом, — БЕЗ тела
|
||||
* ответа.
|
||||
*
|
||||
* Тело сюда попасть не может по той же причине, что и в describeLoginFailure:
|
||||
* если панель по недоразумению впустила, в ответе лежит токен доступа, а этот
|
||||
* текст уезжает в журнал установки и в diagnostics-бандл.
|
||||
*/
|
||||
function describeRejectionFailure(
|
||||
body: string | undefined,
|
||||
error: unknown,
|
||||
): string {
|
||||
if (body === undefined) {
|
||||
return `запрос не выполнен: ${String(error)}`;
|
||||
}
|
||||
if (carriesAccessToken(parseLoginEnvelope(body))) {
|
||||
return "панель ВЫДАЛА токен доступа на заведомо неверные учётные данные";
|
||||
}
|
||||
return describeRejection(body);
|
||||
}
|
||||
|
||||
/** Как выглядит отказ: код конверта и код причины, без тела. */
|
||||
function describeRejection(body: string): string {
|
||||
const envelope = parseLoginEnvelope(body);
|
||||
const reason = rejectionCodesOf(envelope)[0];
|
||||
return `code=${typeof envelope?.code === "number" ? envelope.code : "нет"}, причина=${reason ?? "нет"}, токен не выдан`;
|
||||
}
|
||||
|
||||
// Читает /etc/hy2xs/bootstrap-admin.secret тем же парсером, которым он написан.
|
||||
//
|
||||
// Файл несёт пароль администратора, у которого набор символов не ограничен, а
|
||||
// пробелы по краям являются частью значения. Прежнее чтение —
|
||||
// `grep … | cut -d= -f2-` с `.trim()` — не умело ни того, ни другого, и вдобавок
|
||||
// прогоняло секрет через shell-конвейер.
|
||||
//
|
||||
// Комментарий записан строчными `//`, а не блоком: скан релизных гейтов
|
||||
// отбрасывает только их, и объяснение, называющее убранную конструкцию по
|
||||
// имени, иначе роняет проверку «этой конструкции здесь больше нет».
|
||||
async function readBootstrapAdminSecret(
|
||||
context: RuntimeContext,
|
||||
): Promise<Record<string, string>> {
|
||||
return parseEnvFile(await readText(context.config.bootstrapAdminSecretPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Причина отказа БЕЗ тела ответа.
|
||||
*
|
||||
* Тело сюда попасть не может: в ответе успешного входа лежит токен доступа, а
|
||||
* текст этой ошибки уезжает в журнал установки и в diagnostics-бандл, который
|
||||
* операторы пересылают в переписке. Поэтому наружу выдаётся только код ответа.
|
||||
*/
|
||||
function describeLoginFailure(
|
||||
body: string | undefined,
|
||||
error: unknown,
|
||||
): string {
|
||||
if (body === undefined) {
|
||||
return `запрос не выполнен: ${String(error)}`;
|
||||
}
|
||||
const envelope = parseLoginEnvelope(body);
|
||||
if (typeof envelope?.code === "number") {
|
||||
return `ответ с code=${envelope.code} и без токена доступа`;
|
||||
}
|
||||
return "ответ не является конвертом API админки";
|
||||
}
|
||||
|
||||
/** Подтверждение выдачи токена без самого токена. */
|
||||
function describeIssuedToken(body: string): string {
|
||||
const data = objectOrNull(parseLoginEnvelope(body)?.data);
|
||||
return typeof data?.tokenType === "string" && data.tokenType.length > 0
|
||||
? `выдан токен типа ${data.tokenType}`
|
||||
: "выдан токен доступа";
|
||||
}
|
||||
|
||||
/**
|
||||
* Установленный бинарник обязан совпадать с версией, замороженной в metadata
|
||||
* пакета. На reconfigure metadata может относиться к другому пакету, поэтому
|
||||
* расхождение там — предупреждение, а не отказ.
|
||||
*/
|
||||
async function assertEffectiveHysteriaVersion(context: RuntimeContext): Promise<void> {
|
||||
async function assertEffectiveHysteriaVersion(
|
||||
context: RuntimeContext,
|
||||
): Promise<void> {
|
||||
const packagedVersion = context.hysteriaVersion.trim();
|
||||
if (!packagedVersion || packagedVersion === "unknown") {
|
||||
return;
|
||||
|
||||
@@ -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,48 @@
|
||||
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("повреждённый 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();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
HYSTERIA_HASHES_ASSET,
|
||||
HYSTERIA_LINUX_AMD64_ASSET,
|
||||
selectLatestStableRelease,
|
||||
type GithubRelease
|
||||
type GithubRelease,
|
||||
} from "../src/build/hysteriaRelease";
|
||||
|
||||
function assetUrl(tag: string, name: string): string {
|
||||
@@ -21,13 +21,13 @@ function release(tag: string): GithubRelease {
|
||||
assets: [
|
||||
{
|
||||
name: HYSTERIA_LINUX_AMD64_ASSET,
|
||||
browser_download_url: assetUrl(tag, HYSTERIA_LINUX_AMD64_ASSET)
|
||||
browser_download_url: assetUrl(tag, HYSTERIA_LINUX_AMD64_ASSET),
|
||||
},
|
||||
{
|
||||
name: HYSTERIA_HASHES_ASSET,
|
||||
browser_download_url: assetUrl(tag, HYSTERIA_HASHES_ASSET)
|
||||
}
|
||||
]
|
||||
browser_download_url: assetUrl(tag, HYSTERIA_HASHES_ASSET),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("install-time никогда не резолвит latest", () => {
|
||||
["commands", "install.ts"],
|
||||
["steps", "hysteria.ts"],
|
||||
["lib", "packageMeta.ts"],
|
||||
["commands", "reconfigure.ts"]
|
||||
["commands", "reconfigure.ts"],
|
||||
];
|
||||
|
||||
test("install-time код не импортирует build-time резолвер", () => {
|
||||
@@ -83,13 +83,19 @@ describe("install-time никогда не резолвит latest", () => {
|
||||
|
||||
test("установка берёт версию, url и sha256 только из metadata пакета", () => {
|
||||
const install = srcFile("commands", "install.ts");
|
||||
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.version"');
|
||||
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.url"');
|
||||
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.sha256"');
|
||||
for (const key of ["hysteria.version", "hysteria.url", "hysteria.sha256"]) {
|
||||
expect(install).toMatch(
|
||||
new RegExp(
|
||||
`readPackageValue\\(\\s*options\\.packageDir,\\s*"${key.replace(".", "\\.")}"`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("отсутствие любой части lock-метаданных прерывает установку", () => {
|
||||
expect(srcFile("commands", "install.ts")).toContain("missing Hysteria lock metadata in package");
|
||||
expect(srcFile("commands", "install.ts")).toContain(
|
||||
"missing Hysteria lock metadata in package",
|
||||
);
|
||||
});
|
||||
|
||||
test("установленный бинарник сверяется с закреплённой версией", () => {
|
||||
|
||||
@@ -25,7 +25,10 @@ import { persistFailureState, runRollbackStages } from "../src/lib/rollback";
|
||||
// Пути считаются от файла теста, а не от cwd: `bun test` запускается и из корня
|
||||
// репозитория (сборка), и из orchestrator/ (разработчик).
|
||||
function source(relativeToSrc: string): string {
|
||||
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
|
||||
return readFileSync(
|
||||
join(import.meta.dir, "..", "src", relativeToSrc),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe("стадии отката независимы", () => {
|
||||
@@ -33,7 +36,7 @@ describe("стадии отката независимы", () => {
|
||||
const executed: string[] = [];
|
||||
const failures = await runRollbackStages([
|
||||
{ name: "firewall", run: async () => void executed.push("firewall") },
|
||||
{ name: "services", run: async () => void executed.push("services") }
|
||||
{ name: "services", run: async () => void executed.push("services") },
|
||||
]);
|
||||
|
||||
expect(executed).toEqual(["firewall", "services"]);
|
||||
@@ -49,10 +52,13 @@ describe("стадии отката независимы", () => {
|
||||
run: async () => {
|
||||
executed.push("firewall");
|
||||
throw new Error("nft: command failed");
|
||||
}
|
||||
},
|
||||
{ name: "restore configuration", run: async () => void executed.push("restore") },
|
||||
{ name: "stop services", run: async () => void executed.push("stop") }
|
||||
},
|
||||
{
|
||||
name: "restore configuration",
|
||||
run: async () => void executed.push("restore"),
|
||||
},
|
||||
{ name: "stop services", run: async () => void executed.push("stop") },
|
||||
]);
|
||||
|
||||
expect(executed).toEqual(["firewall", "restore", "stop"]);
|
||||
@@ -67,15 +73,15 @@ describe("стадии отката независимы", () => {
|
||||
name: "firewall",
|
||||
run: async () => {
|
||||
throw new Error("first");
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "healthy", run: async () => undefined },
|
||||
{
|
||||
name: "restore configuration",
|
||||
run: async () => {
|
||||
throw new Error("second");
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(failures).toHaveLength(2);
|
||||
@@ -91,14 +97,14 @@ describe("стадии отката независимы", () => {
|
||||
name: "a",
|
||||
run: async () => {
|
||||
throw new Error("boom");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "b",
|
||||
run: async () => {
|
||||
throw new Error("boom");
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(failures).toHaveLength(2);
|
||||
});
|
||||
@@ -109,8 +115,8 @@ describe("стадии отката независимы", () => {
|
||||
name: "weird",
|
||||
run: async () => {
|
||||
throw "строковая ошибка";
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(failures[0]).toContain("строковая ошибка");
|
||||
});
|
||||
@@ -135,7 +141,7 @@ describe("состояние отказа пишется best effort", () => {
|
||||
await expect(
|
||||
persistFailureState(async () => {
|
||||
throw new Error("ENOSPC: no space left on device");
|
||||
})
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -173,7 +179,10 @@ describe("install: откат обязателен после операцион
|
||||
// Прямых await-вызовов отката в теле rollbackFailedInstall быть не должно:
|
||||
// именно они и образовывали отменяемую цепочку.
|
||||
const start = installSource.indexOf("async function rollbackFailedInstall");
|
||||
const body = installSource.slice(start, installSource.indexOf("export async function install"));
|
||||
const body = installSource.slice(
|
||||
start,
|
||||
installSource.indexOf("export async function install"),
|
||||
);
|
||||
expect(body).toContain("await rollbackFirewallNow(context)");
|
||||
// Вызов существует только внутри стадии.
|
||||
const firewallAt = body.indexOf("await rollbackFirewallNow(context)");
|
||||
@@ -182,14 +191,19 @@ describe("install: откат обязателен после операцион
|
||||
});
|
||||
|
||||
test("остановка сервисов остаётся отдельными стадиями", () => {
|
||||
for (const stage of ["stop services", "disable services", "reset failed services"]) {
|
||||
for (const stage of [
|
||||
"stop services",
|
||||
"disable services",
|
||||
"reset failed hysteria-server",
|
||||
"reset failed hy2xs-admin",
|
||||
]) {
|
||||
expect(installSource).toContain(`name: "${stage}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test("чужие сервисы по-прежнему не трогаются", () => {
|
||||
expect(installSource).toContain(
|
||||
"systemd units were not deployed by this operation, leaving services untouched"
|
||||
"systemd units were not deployed by this operation, leaving services untouched",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -202,7 +216,7 @@ describe("reconfigure: откат обязателен после операци
|
||||
// выполняться ВНУТРИ persistFailureState, в какую бы строку его ни перенёс
|
||||
// редактор.
|
||||
const wrapped = reconfigureSource.match(
|
||||
/await persistFailureState\(\(\) =>\s*markPhase\(context,\s*classifyReconfigureFailure\(ownership, error\), message\)\s*\)/
|
||||
/await persistFailureState\(\(\) =>\s*markPhase\(context,\s*classifyReconfigureFailure\(ownership, error\), message\)\s*\)/,
|
||||
);
|
||||
expect(wrapped).not.toBeNull();
|
||||
});
|
||||
@@ -222,8 +236,12 @@ describe("reconfigure: откат обязателен после операци
|
||||
|
||||
// Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком.
|
||||
test("порядок сохранён: сначала firewall, затем конфиги", () => {
|
||||
const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
|
||||
const restore = reconfigureSource.indexOf("await rollbackCurrentState(context)");
|
||||
const firewall = reconfigureSource.indexOf(
|
||||
"await rollbackFirewallNow(context)",
|
||||
);
|
||||
const restore = reconfigureSource.indexOf(
|
||||
"await rollbackCurrentState(context)",
|
||||
);
|
||||
const stages = reconfigureSource.indexOf("await runRollbackStages(stages)");
|
||||
expect(firewall).toBeGreaterThan(-1);
|
||||
expect(firewall).toBeLessThan(restore);
|
||||
@@ -242,14 +260,19 @@ describe("reconfigure: откат обязателен после операци
|
||||
const start = reconfigureSource.indexOf("function restoreStages(");
|
||||
const body = reconfigureSource.slice(
|
||||
start,
|
||||
reconfigureSource.indexOf("async function rollbackCurrentState")
|
||||
reconfigureSource.indexOf("async function rollbackCurrentState"),
|
||||
);
|
||||
const muted = body
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes("runMutatingVisible`"))
|
||||
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
|
||||
.filter(
|
||||
(line) => line.includes("|| true") || line.includes("2>/dev/null"),
|
||||
);
|
||||
|
||||
expect(muted, `восстановление снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
|
||||
expect(
|
||||
muted,
|
||||
`восстановление снова скрывает ошибки: ${muted.join("; ")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("восстановление разбито на независимые стадии", () => {
|
||||
@@ -260,7 +283,7 @@ describe("reconfigure: откат обязателен после операци
|
||||
"restore nftables files",
|
||||
"apply restored ruleset",
|
||||
"reload systemd units",
|
||||
"restart services"
|
||||
"restart services",
|
||||
]) {
|
||||
expect(reconfigureSource).toContain(`"${stage}"`);
|
||||
}
|
||||
@@ -269,7 +292,9 @@ describe("reconfigure: откат обязателен после операци
|
||||
// Неполное восстановление обязано доходить до итоговой сводки как отказ.
|
||||
test("частичное восстановление не выдаётся за успешное", () => {
|
||||
expect(reconfigureSource).toContain("restoreFailures.length > 0");
|
||||
expect(reconfigureSource).toContain("восстановление состояния выполнено не полностью");
|
||||
expect(reconfigureSource).toContain(
|
||||
"восстановление состояния выполнено не полностью",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -282,12 +307,18 @@ describe("install: команды отката не глушат собстве
|
||||
// них не выполнялось никогда.
|
||||
test("стадии остановки сервисов не подавляют код возврата", () => {
|
||||
const start = installSource.indexOf("async function rollbackFailedInstall");
|
||||
const body = installSource.slice(start, installSource.indexOf("export async function install"));
|
||||
const body = installSource.slice(
|
||||
start,
|
||||
installSource.indexOf("export async function install"),
|
||||
);
|
||||
const muted = body
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes("runMutatingVisible`"))
|
||||
.filter((line) => line.includes("|| true"));
|
||||
|
||||
expect(muted, `стадии отката снова скрывают ошибки: ${muted.join("; ")}`).toEqual([]);
|
||||
expect(
|
||||
muted,
|
||||
`стадии отката снова скрывают ошибки: ${muted.join("; ")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { InvalidUtf8Error, readText } from "../src/lib/fs";
|
||||
import { disableReadOnlyGuard } from "../src/lib/guard";
|
||||
import { preflightInstall } from "../src/commands/preflight-install";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function fixture(bytes: Uint8Array): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "hy2xs-utf8-"));
|
||||
directories.push(directory);
|
||||
const path = join(directory, "config.env");
|
||||
await writeFile(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe("строгое чтение текстовых файлов", () => {
|
||||
for (const [name, bytes] of [
|
||||
["байт FF", new Uint8Array([0x61, 0xff, 0x62])],
|
||||
["оборванная последовательность C3", new Uint8Array([0x61, 0xc3])],
|
||||
["UTF-8-кодирование суррогата", new Uint8Array([0x61, 0xed, 0xa0, 0x80])]
|
||||
] as const) {
|
||||
test(`отвергает ${name} без U+FFFD`, async () => {
|
||||
const path = await fixture(bytes);
|
||||
await expect(readText(path)).rejects.toBeInstanceOf(InvalidUtf8Error);
|
||||
});
|
||||
}
|
||||
|
||||
test("сохраняет начальный BOM как U+FEFF для транспортной проверки", async () => {
|
||||
const path = await fixture(new Uint8Array([0xef, 0xbb, 0xbf, 0x41, 0x3d, 0x31, 0x0a]));
|
||||
expect(await readText(path)).toBe("\uFEFFA=1\n");
|
||||
});
|
||||
|
||||
test("не путает настоящий U+FFFD с ошибкой декодирования", async () => {
|
||||
const text = "A=abcde�\n";
|
||||
const path = await fixture(new TextEncoder().encode(text));
|
||||
expect(await readText(path)).toBe(text);
|
||||
});
|
||||
|
||||
test("byte-level отказ preflight не изменяет каталог пакета", async () => {
|
||||
for (const bytes of [
|
||||
new Uint8Array([0xff]),
|
||||
new Uint8Array([0xc3]),
|
||||
new Uint8Array([0xed, 0xa0, 0x80])
|
||||
]) {
|
||||
const path = await fixture(bytes);
|
||||
const packageDir = join(path, "..");
|
||||
const before = await readdir(packageDir);
|
||||
try {
|
||||
await expect(
|
||||
preflightInstall({
|
||||
packageDir,
|
||||
sourceConfigPath: path,
|
||||
runtimeConfigPath: path,
|
||||
nonInteractive: true,
|
||||
skipFirewall: false,
|
||||
skipServiceStart: false,
|
||||
skipSmoke: false
|
||||
})
|
||||
).rejects.toBeInstanceOf(InvalidUtf8Error);
|
||||
} finally {
|
||||
disableReadOnlyGuard();
|
||||
}
|
||||
expect(await readdir(packageDir)).toEqual(before);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 { readText } from "../src/lib/fs";
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(`[hy2xs-build] ERROR: ${message}`);
|
||||
@@ -47,7 +48,7 @@ function parseArgs(argv: string[]): { file: string; asset: string } {
|
||||
const { file, asset } = parseArgs(Bun.argv.slice(2));
|
||||
|
||||
try {
|
||||
const text = await Bun.file(file).text();
|
||||
const text = await readText(file);
|
||||
process.stdout.write(`${parseUpstreamHashes(text, asset)}\n`);
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { parseRuntimeEnv } from "../src/config/env";
|
||||
import { HYSTERIA_OBFS_TYPES } from "../src/config/profile";
|
||||
import { readText } from "../src/lib/fs";
|
||||
import { hysteriaConfigTemplatePath, renderHysteriaConfig } from "../src/steps/config";
|
||||
import type { RuntimeContext } from "../src/types/context";
|
||||
|
||||
@@ -138,7 +139,7 @@ async function main(): Promise<void> {
|
||||
overrides.HY2XS_HYSTERIA_TRAFFIC_STATS_PORT = options.trafficStatsPort;
|
||||
}
|
||||
|
||||
const sourceEnv = await Bun.file(options.configPath).text();
|
||||
const sourceEnv = await readText(options.configPath);
|
||||
const config = parseRuntimeEnv(overrideEnv(sourceEnv, overrides));
|
||||
|
||||
const context = {
|
||||
@@ -160,7 +161,7 @@ async function main(): Promise<void> {
|
||||
hysteriaResolution: "compat-gate"
|
||||
} satisfies RuntimeContext;
|
||||
|
||||
const template = await Bun.file(options.templatePath).text();
|
||||
const template = await readText(options.templatePath);
|
||||
await Bun.write(options.outPath, renderHysteriaConfig(context, template));
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
./install.sh --config /root/custom-hy2xs.env --non-interactive
|
||||
```
|
||||
|
||||
Файл конфигурации обязан быть корректным UTF-8 без BOM/U+FEFF, NUL,
|
||||
суррогатов и Unicode noncharacters. Установщик читает исходные байты строго и
|
||||
отвергает повреждение на read-only preflight: молчаливой замены на U+FFFD и
|
||||
изменений хоста при таком отказе нет.
|
||||
|
||||
В baseline нет target-side JavaScript, TypeScript, frontend или Go build step.
|
||||
|
||||
## Установка выполняется в две фазы
|
||||
|
||||
@@ -71,6 +71,17 @@ code_without_comments() {
|
||||
# bash отключает errexit на весь вызов, поэтому неудачное чтение прошло бы
|
||||
# незамеченным и снова дало бы ложный PASS. `fail` выполняется в самой функции,
|
||||
# а не в подоболочке подстановки, поэтому его `exit` останавливает сборку.
|
||||
# fenced_code_of отдаёт КОМАНДЫ документа — строки внутри ```-блоков.
|
||||
#
|
||||
# Нужен по той же причине, что и code_without_comments: комментарий или абзац,
|
||||
# объясняющий, почему конструкции здесь больше нет, обязан называть её по имени,
|
||||
# иначе он бесполезен. Гейт «такой команды в документации нет» без этого падал бы
|
||||
# ровно на том объяснении, которое описывает выполненную им же работу.
|
||||
fenced_code_of() {
|
||||
[ -f "$1" ] || fail "fenced_code_of: файл не найден: $1"
|
||||
awk '/^```/ { inside = !inside; next } inside { print }' "$1"
|
||||
}
|
||||
|
||||
code_has() {
|
||||
local file="$1"
|
||||
shift
|
||||
@@ -172,11 +183,23 @@ run_fix20_acceptance_subset() {
|
||||
|| fail "acceptance: the default obfs type must not be re-declared in: $default_declarations"
|
||||
|
||||
log_step "Acceptance: runtime env is derived from config, not literals"
|
||||
! grep -Eq 'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' orchestrator/src/config/env.ts \
|
||||
|| fail "acceptance: renderRuntimeEnv must not print a hardcoded obfs type"
|
||||
grep -q 'HY2XS_HYSTERIA_OBFS_TYPE=\${config.hysteriaObfsType}' orchestrator/src/config/env.ts \
|
||||
|| fail "acceptance: renderRuntimeEnv must derive the obfs type from the parsed config"
|
||||
! grep -Eq '\|\|\s*"(gecko|salamander)"' orchestrator/src/config/env.ts \
|
||||
# runtime env mappings live in runtimeEnvEntries(); renderRuntimeEnv only
|
||||
# serializes that shared list. Do not require the old inline interpolation.
|
||||
! grep -Eq \
|
||||
'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' \
|
||||
orchestrator/src/config/env.ts \
|
||||
|| fail "acceptance: runtime env must not contain a hardcoded obfs type"
|
||||
grep -Eq \
|
||||
'\["HY2XS_HYSTERIA_OBFS_TYPE",[[:space:]]*config\.hysteriaObfsType\]' \
|
||||
orchestrator/src/config/env.ts \
|
||||
|| fail "acceptance: runtimeEnvEntries must derive the obfs type from the parsed config"
|
||||
grep -Eq \
|
||||
'renderEnvFile\(runtimeEnvEntries\(config\)\)' \
|
||||
orchestrator/src/config/env.ts \
|
||||
|| fail "acceptance: renderRuntimeEnv must render the shared runtimeEnvEntries list"
|
||||
! grep -Eq \
|
||||
'\|\|[[:space:]]*"(gecko|salamander)"' \
|
||||
orchestrator/src/config/env.ts \
|
||||
|| fail "acceptance: env.ts must not carry its own obfs fallback default"
|
||||
|
||||
log_step "Acceptance: package metadata records how the Hysteria version was chosen"
|
||||
@@ -768,9 +791,353 @@ run_clean_install_acceptance() {
|
||||
run_legacy_account_acceptance
|
||||
run_scheduler_lifecycle_acceptance
|
||||
run_dead_config_acceptance
|
||||
run_admin_login_acceptance
|
||||
run_dependency_hygiene_acceptance "$package_dir"
|
||||
}
|
||||
|
||||
# Вход в панель: барьеры, без которых RC2 повторяется.
|
||||
#
|
||||
# Гейт существует по той же причине, что и гейт детектора гонок: проверки,
|
||||
# закрывающие класс дефекта, не имеют права тихо исчезнуть. Удалённый тест
|
||||
# оставляет сборку зелёной, а выпускаемый артефакт — неотличимым.
|
||||
#
|
||||
# Проверяется НАЛИЧИЕ барьеров, а не поведение: поведение доказывают сами
|
||||
# тесты, и они обязательны (run_admin_tests, run_orchestrator_tests,
|
||||
# run_frontend_tests). Здесь удерживается то, что их есть кому доказывать.
|
||||
run_admin_login_acceptance() {
|
||||
log_step "Acceptance: every validation tag is proven to exist before release"
|
||||
|
||||
# Дефект RC2 целиком: тег `validateStr` пережил переименование правила в
|
||||
# `credentialStr`, валидатор паниковал на КАЖДОМ входе, gin.Recovery отдавал
|
||||
# HTTP 500, установка при этом объявляла успех.
|
||||
code_has apps/controller/auth_test.go -F -- 'func TestEveryValidationTagIsRegistered' \
|
||||
|| fail "acceptance: барьер существования правил валидации удалён — stale-тег снова станет runtime-дефектом"
|
||||
code_has apps/controller/auth_test.go -F -- 'validate.Var(' \
|
||||
|| fail "acceptance: барьер обязан предъявлять тег валидатору, а не сравнивать строки"
|
||||
|
||||
# Наивная замена барьера не работает: цепочка правил поля обрывается на
|
||||
# первом несработавшем, поэтому нулевое DTO отказывает по `required` и до
|
||||
# испорченного тега не доходит. Барьер обязан ходить по ИСХОДНИКАМ.
|
||||
code_has apps/controller/auth_test.go -F -- 'filepath.WalkDir' \
|
||||
|| fail "acceptance: барьер перестал обходить исходники модели"
|
||||
|
||||
log_step "Acceptance: the admin credential contract has a single owner"
|
||||
# Контракт живёт в LEAF-пакете, а не в service. Это не вкусовщина: его зовут
|
||||
# util.HashPassword и dao (bootstrap), а `service` импортирует `util` —
|
||||
# обратный импорт был бы циклическим. Пока контракт лежал в service,
|
||||
# HashPassword завёл собственную проверку `len(TrimSpace(p)) < 6`, и она
|
||||
# разошлась с остальным продуктом.
|
||||
[ -f apps/credential/admin.go ] \
|
||||
|| fail "acceptance: контракт учётных данных администратора отсутствует"
|
||||
[ ! -f apps/service/admin_credentials.go ] \
|
||||
|| fail "acceptance: контракт вернулся в service, откуда его не может позвать util.HashPassword"
|
||||
code_has apps/controller/validator.go -F -- 'credential.IsValidAdminUsername' \
|
||||
|| fail "acceptance: правило логина снова несёт собственную копию набора символов"
|
||||
code_has apps/controller/validator.go -F -- 'credential.IsValidAdminPassword' \
|
||||
|| fail "acceptance: правило пароля снова несёт собственную копию границ"
|
||||
|
||||
# Длина логина живёт ВНУТРИ правила. Два правила длины на одном поле уже
|
||||
# приводили к отказу, который невозможно объяснить оператору.
|
||||
! code_has apps/model/dto/auth.go -E -- 'validate:"[^"]*(min|max)=[0-9]+[^"]*credentialStr' \
|
||||
|| fail "acceptance: на логине снова стоит отдельная граница длины рядом с правилом"
|
||||
|
||||
log_step "Acceptance: the password contract knows the bcrypt byte limit"
|
||||
# Границ у пароля ДВЕ и они в разных единицах. bcrypt читает 72 БАЙТА и
|
||||
# отвечает ErrPasswordTooLong; 64 символа — это от 64 до 256 байт. Пока
|
||||
# байтовой границы не было, пароль из 64 кириллических букв проходил
|
||||
# оркестратор, DTO и панель, а падал в хешировании — системной ошибкой на
|
||||
# штатной смене пароля и отсутствием администратора после установки.
|
||||
code_has apps/credential/admin.go -F -- 'AdminPasswordMaxBytes = 72' \
|
||||
|| fail "acceptance: предел bcrypt в байтах не объявлен в контракте"
|
||||
code_has orchestrator/src/config/profile.ts -F -- 'ADMIN_PASSWORD_MAX_BYTES = 72' \
|
||||
|| fail "acceptance: оркестратор снова не знает про предел bcrypt в байтах"
|
||||
code_has apps/frontend/src/constants/credentials.ts -F -- 'ADMIN_PASSWORD_MAX_BYTES = 72' \
|
||||
|| fail "acceptance: панель снова не знает про предел bcrypt в байтах"
|
||||
|
||||
# Границы пароля живут ВНУТРИ правила: тег валидатора умеет считать только
|
||||
# символы, а границу в байтах им не выразить вовсе.
|
||||
local password_dto
|
||||
for password_dto in apps/model/dto/auth.go apps/model/dto/admin_user.go; do
|
||||
code_has "$password_dto" -F -- 'adminPassword' \
|
||||
|| fail "acceptance: $password_dto больше не проверяет пароль общим правилом"
|
||||
! code_has "$password_dto" -E -- 'validate:"[^"]*(min|max)=[0-9]+[^"]*"' \
|
||||
|| fail "acceptance: в $password_dto вернулась граница длины рядом с правилом пароля"
|
||||
done
|
||||
|
||||
log_step "Acceptance: the password is never trimmed on its way to bcrypt"
|
||||
# Пробел по краям объявлен ЧАСТЬЮ пароля. Пока HashPassword триммил значение,
|
||||
# пароль `abcde ` принимали все двери продукта и не мог захешировать никто; а
|
||||
# пока bootstrap триммил HY2XS_ADMIN_INITIAL_PASSWORD, учётная запись
|
||||
# создавалась не с тем паролем, который оператор записал в hy2xs.env.
|
||||
code_has apps/util/encrypt.go -F -- 'credential.IsValidAdminPassword(password)' \
|
||||
|| fail "acceptance: HashPassword снова проверяет пароль собственным правилом"
|
||||
! code_has apps/util/encrypt.go -F -- 'strings.TrimSpace(password)' \
|
||||
|| fail "acceptance: в HashPassword вернулся TrimSpace — пароль с краевым пробелом снова нельзя захешировать"
|
||||
! code_has apps/dao/sqlite.go -F -- 'strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))' \
|
||||
|| fail "acceptance: bootstrap снова обрезает пароль первого администратора"
|
||||
code_has apps/dao/sqlite.go -F -- 'credential.IsValidAdminPassword(adminPassword)' \
|
||||
|| fail "acceptance: bootstrap снова не проверяет пароль против контракта"
|
||||
|
||||
log_step "Acceptance: hy2xs.env is a format, not a lucky interpolation"
|
||||
# У файла ДВА читателя, и один из них — systemd (EnvironmentFile=). Пока
|
||||
# значения писались интерполяцией, а читались split('=') с trim(), пароль с
|
||||
# краевым пробелом, кавычкой или обратным слешем терялся ещё до запуска
|
||||
# админки — при том, что продукт обещает неограниченный набор символов.
|
||||
[ -f orchestrator/src/lib/envFile.ts ] \
|
||||
|| fail "acceptance: модуль формата env-файлов отсутствует"
|
||||
code_has orchestrator/src/config/env.ts -F -- 'parseEnvFile(content)' \
|
||||
|| fail "acceptance: разбор hy2xs.env снова расходится с systemd"
|
||||
code_has orchestrator/src/config/env.ts -F -- 'renderEnvFile(runtimeEnvEntries(config))' \
|
||||
|| fail "acceptance: запись hy2xs.env снова идёт интерполяцией"
|
||||
code_has orchestrator/src/steps/env.ts -F -- 'renderEnvFile(' \
|
||||
|| fail "acceptance: bootstrap-admin.secret снова пишется интерполяцией"
|
||||
# Экранируются ТОЛЬКО те символы, которые systemd разворачивает обратно.
|
||||
code_has orchestrator/src/lib/envFile.ts -F -- 'SHELL_NEED_ESCAPE' \
|
||||
|| fail "acceptance: кодировщик env потерял ссылку на правила systemd"
|
||||
|
||||
log_step "Acceptance: the transport domain is systemd's, not ours"
|
||||
# systemd прогоняет ключ и значение через utf8_is_valid перед тем, как принять
|
||||
# пару (src/basic/env-file.c, check_utf8ness_and_warn), и отказ там - это
|
||||
# -EINVAL, то есть НЕзагруженный EnvironmentFile и юнит, который не стартует.
|
||||
# Пока контракт знал только про управляющие символы, пароль `abcde` плюс
|
||||
# U+FDD0 - шесть символов, восемь байт - проходил все двери продукта и
|
||||
# оставлял сервер без работающей панели.
|
||||
code_has apps/credential/admin.go -F -- 'func IsEnvTransportableText' \
|
||||
|| fail "acceptance: контракт снова не знает домена значений systemd"
|
||||
code_has apps/credential/admin.go -F -- 'IsEnvTransportableText(password)' \
|
||||
|| fail "acceptance: правило пароля перестало проверять домен транспорта"
|
||||
code_has orchestrator/src/lib/envFile.ts -F -- 'export function isEnvTransportable' \
|
||||
|| fail "acceptance: оркестратор снова не знает домена значений systemd"
|
||||
code_has orchestrator/src/config/profile.ts -F -- 'isEnvTransportable(value)' \
|
||||
|| fail "acceptance: правило пароля оркестратора перестало проверять домен транспорта"
|
||||
|
||||
# Границы взяты из unichar_is_valid: U+FDD0..U+FDEF и (cp & 0xFFFE) == 0xFFFE.
|
||||
local unicode_domain_owner
|
||||
for unicode_domain_owner in apps/credential/admin.go \
|
||||
orchestrator/src/lib/envFile.ts \
|
||||
apps/frontend/src/constants/credentials.ts; do
|
||||
code_has "$unicode_domain_owner" -E -- '0[xX][fF][dD][dD]0' \
|
||||
|| fail "acceptance: $unicode_domain_owner не знает диапазон noncharacters U+FDD0..U+FDEF"
|
||||
code_has "$unicode_domain_owner" -E -- '0[xX][fF][fF][fF][eE]' \
|
||||
|| fail "acceptance: $unicode_domain_owner не знает noncharacters вида *FFFE/*FFFF"
|
||||
code_has "$unicode_domain_owner" -E -- '0[xX][fF][eE][fF][fF]' \
|
||||
|| fail "acceptance: $unicode_domain_owner не отвергает документированный U+FEFF"
|
||||
done
|
||||
# Одиночный суррогат в JS-строке TextEncoder молча заменяет на U+FFFD: без
|
||||
# проверки это была бы не ошибка, а тихая подмена секрета.
|
||||
local surrogate_owner
|
||||
for surrogate_owner in orchestrator/src/lib/envFile.ts \
|
||||
apps/frontend/src/constants/credentials.ts; do
|
||||
code_has "$surrogate_owner" -E -- '0[xX][dD]800' \
|
||||
|| fail "acceptance: $surrogate_owner не отвергает одиночные суррогаты"
|
||||
done
|
||||
|
||||
log_step "Acceptance: text files use fatal UTF-8 decoding"
|
||||
code_has orchestrator/src/lib/fs.ts -F -- '.bytes()' \
|
||||
|| fail "acceptance: readText снова не читает исходные байты"
|
||||
code_has orchestrator/src/lib/fs.ts -F -- 'fatal: true' \
|
||||
|| fail "acceptance: readText снова допускает replacement decoding"
|
||||
code_has orchestrator/src/lib/fs.ts -F -- 'ignoreBOM: true' \
|
||||
|| fail "acceptance: reader снова съедает BOM до проверки U+FEFF"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8");
|
||||
const start = source.indexOf("export async function readText");
|
||||
const end = source.indexOf("\nexport async function writeText", start);
|
||||
const body = source.slice(start, end);
|
||||
if (body.includes(".text()")) throw new Error("readText использует replacement decoding");
|
||||
' || fail "acceptance: строгий файловый reader заменён на Blob.text()"
|
||||
! grep -R -F --include='*.ts' 'Bun.file(' orchestrator/tools \
|
||||
| grep -F '.text()' >/dev/null \
|
||||
|| fail "acceptance: build-time tool обходит строгий файловый reader"
|
||||
|
||||
# Порт автомата обязан повторять upstream буквально: в состоянии
|
||||
# DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не проверку на любой
|
||||
# перевод строки, - иначе `\<CR>` мы прочитали бы иначе, чем systemd.
|
||||
code_has orchestrator/src/lib/envFile.ts -F -- 'c !== "\n"' \
|
||||
|| fail "acceptance: escape в двойных кавычках разошёлся с автоматом systemd"
|
||||
|
||||
log_step "Acceptance: unusable configuration is rejected before the first mutation"
|
||||
# Проверка транспорта жила только внутри renderRuntimeEnv, то есть срабатывала
|
||||
# на шаге записи файла - уже после bootstrap оркестратора, apt и раскладки
|
||||
# файловой системы, а read-only preflight-install про неё говорил PASS.
|
||||
code_has orchestrator/src/config/env.ts -F -- 'validateRuntimeEnvTransport(config);' \
|
||||
|| fail "acceptance: конфигурация снова проверяется на транспорт только при записи"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("orchestrator/src/config/env.ts", "utf8");
|
||||
const start = source.indexOf("export function parseRuntimeEnv");
|
||||
if (start < 0) throw new Error("parseRuntimeEnv отсутствует");
|
||||
const body = source.slice(start, source.indexOf("\nexport function validateRuntimeConfig"));
|
||||
const rawValidation = body.indexOf("assertEnvTransportable");
|
||||
const parsing = body.indexOf("parseEnv(content)");
|
||||
if (rawValidation < 0 || parsing < 0 || rawValidation > parsing) {
|
||||
throw new Error("исходный EnvironmentFile не проверяется до разбора");
|
||||
}
|
||||
if (!body.includes("validateRuntimeEnvTransport(config)")) {
|
||||
throw new Error("parseRuntimeEnv не проверяет транспорт: отказ придёт уже после мутаций хоста");
|
||||
}
|
||||
' || fail "acceptance: проверка транспорта не входит в разбор конфигурации"
|
||||
|
||||
log_step "Acceptance: JSON is strict before encoding/json"
|
||||
code_has apps/controller/json_body.go -F -- 'utf8.Valid(raw)' \
|
||||
|| fail "acceptance: HTTP JSON снова допускает повреждённый UTF-8"
|
||||
code_has apps/controller/json_body.go -F -- 'validateStrictJSONEncoding(raw)' \
|
||||
|| fail "acceptance: strict JSON binder не проверяет Unicode escapes"
|
||||
code_has apps/controller/validator.go -F -- 'strictBindJSON(c, &field)' \
|
||||
|| fail "acceptance: DTO снова разбираются напрямую через Gin/encoding/json"
|
||||
code_has apps/controller/hysteria2.go -F -- 'strictBindJSON(c, &req)' \
|
||||
|| fail "acceptance: Hysteria auth обходит строгую JSON-границу"
|
||||
code_has apps/controller/peer.go -F -- 'validateStrictJSONEncoding(content)' \
|
||||
|| fail "acceptance: импорт пиров обходит строгую JSON-границу"
|
||||
|
||||
log_step "Acceptance: diagnostics never stages raw secrets"
|
||||
! code_has orchestrator/src/commands/diagnostics.ts -E -- 'cp -a .*(hy2xs\.env|post-install\.env|config\.yaml)' \
|
||||
|| fail "acceptance: diagnostics снова копирует секретный конфиг в staging до редакции"
|
||||
code_has orchestrator/src/commands/diagnostics.ts -F -- 'prepareDiagnosticFile(source, sanitize)' \
|
||||
|| fail "acceptance: diagnostics не проводит файл через строгую подготовку до записи"
|
||||
! code_has orchestrator/src/commands/diagnostics.ts -F -- 'Bun.write' \
|
||||
|| fail "acceptance: diagnostics снова пишет сырой промежуточный файл напрямую"
|
||||
|
||||
log_step "Acceptance: env files are read as data, never executed"
|
||||
# Файл научился нести произвольные значения, и это сделало `source` опасным:
|
||||
# для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(...)"` - буквальное значение
|
||||
# (подстановок в EnvironmentFile нет), а bash внутри двойных кавычек выполнил
|
||||
# бы подстановку команд - от root, прямо в рецепте восстановления доступа.
|
||||
#
|
||||
# Проверяются КОМАНДЫ: объяснение, почему такой строки здесь больше нет,
|
||||
# обязано называть её по имени, поэтому текст вне ```-блоков не считается.
|
||||
local env_doc fenced_code
|
||||
while IFS= read -r env_doc; do
|
||||
[ -n "$env_doc" ] || continue
|
||||
# fenced_code_of сначала обязан дочитать документ целиком. `grep -q` в
|
||||
# pipeline под pipefail закрывал канал после первого совпадения и мог
|
||||
# превратить найденную запрещённую команду в ложный PASS через SIGPIPE 141.
|
||||
fenced_code="$(fenced_code_of "$env_doc")" \
|
||||
|| fail "acceptance: не удалось прочитать fenced code из $env_doc"
|
||||
if grep -qE \
|
||||
'(^|[;&|[:space:]])(source|\.)[[:space:]]+[^[:space:]]*(/etc/hy2xs/|hy2xs\.env|bootstrap-admin\.secret)' \
|
||||
<<<"$fenced_code"; then
|
||||
fail "acceptance: $env_doc снова предлагает исполнить env-файл шеллом (source/.)"
|
||||
fi
|
||||
if grep -qE \
|
||||
'eval[[:space:]]+[^|]*(hy2xs\.env|bootstrap-admin\.secret)' \
|
||||
<<<"$fenced_code"; then
|
||||
fail "acceptance: $env_doc снова предлагает eval над env-файлом"
|
||||
fi
|
||||
done <<EOF
|
||||
$(find docs package/docs -name '*.md' 2>/dev/null; echo README.md)
|
||||
EOF
|
||||
|
||||
if grep -rqE '(^|[;&|[:space:]])(source|\.)[[:space:]]+[^[:space:]]*(/etc/hy2xs/|bootstrap-admin\.secret)' \
|
||||
--include='*.sh' tools package 2>/dev/null; then
|
||||
fail "acceptance: скрипт снова исполняет env-файл шеллом"
|
||||
fi
|
||||
|
||||
log_step "Acceptance: the panel is never stricter than the server"
|
||||
[ -f apps/frontend/src/constants/credentials.ts ] \
|
||||
|| fail "acceptance: контракт учётных данных панели отсутствует"
|
||||
local form
|
||||
for form in apps/frontend/src/views/login/index.vue \
|
||||
apps/frontend/src/views/admin/change-password/index.vue; do
|
||||
code_has "$form" -F -- '@/constants/credentials' \
|
||||
|| fail "acceptance: $form снова заводит собственное правило учётных данных"
|
||||
# Неэкранированный дефис превращает `+-=` в диапазон: набор совпадал с
|
||||
# серверным только потому, что обе стороны несли одну опечатку.
|
||||
! code_has "$form" -F -- '_+-=' \
|
||||
|| fail "acceptance: $form вернул класс символов, записанный диапазоном по опечатке"
|
||||
# Встроенные min/max Element Plus делегируются async-validator, а он считает
|
||||
# длину строки в единицах UTF-16: пароль из трёх эмодзи имел length 6,
|
||||
# проходил минимум формы и получал отказ сервера, который панель не могла
|
||||
# объяснить. Правило пароля обязано быть общим и считать code points.
|
||||
code_has "$form" -F -- 'adminPasswordFormRule(' \
|
||||
|| fail "acceptance: $form снова проверяет пароль встроенными min/max async-validator"
|
||||
done
|
||||
|
||||
log_step "Acceptance: install proves that the panel lets an operator in"
|
||||
# `LISTEN` и `/healthz` не отвечают на вопрос, ради которого проверяются:
|
||||
# в RC2 оба были истинны при полностью недоступной панели.
|
||||
code_has orchestrator/src/steps/smoke.ts -F -- 'assertAdminLoginWorks' \
|
||||
|| fail "acceptance: smoke больше не выполняет настоящий вход в панель"
|
||||
code_has orchestrator/src/steps/smoke.ts -F -- 'ADMIN_LOGIN_PATH' \
|
||||
|| fail "acceptance: адрес формы входа перестал браться из профиля"
|
||||
# Успех определяется разобранным КОНВЕРТОМ: админка отвечает 200 OK и на
|
||||
# отказ тоже, а regex по сырому тексту способен принять подстроку в message.
|
||||
code_has orchestrator/src/steps/smoke.ts -F -- 'envelope?.code === 20000' \
|
||||
|| fail "acceptance: успех входа снова определяется кодом HTTP, а не конвертом ответа"
|
||||
# Positive и negative probe обязаны пользоваться одной транспортной функцией:
|
||||
# две независимые curl-команды и разошлись в RC3 с production middleware.
|
||||
local login_body login_request_body
|
||||
login_body="$(sed -n '/^async function assertAdminLoginWorks/,/^}/p' orchestrator/src/steps/smoke.ts)" \
|
||||
|| fail "acceptance: не удалось прочитать проверку входа в панель"
|
||||
[ -n "$login_body" ] \
|
||||
|| fail "acceptance: функция проверки входа в панель не найдена"
|
||||
[ "$(grep -c 'requestAdminLogin(' <<<"$login_body")" -eq 2 ] \
|
||||
|| fail "acceptance: positive и negative login probe не используют один request helper"
|
||||
! grep -q 'curl ' <<<"$login_body" \
|
||||
|| fail "acceptance: assertAdminLoginWorks снова содержит отдельную curl-команду"
|
||||
|
||||
login_request_body="$(sed -n '/^async function requestAdminLogin/,/^}/p' orchestrator/src/steps/smoke.ts)" \
|
||||
|| fail "acceptance: не удалось прочитать transport helper входа"
|
||||
[ -n "$login_request_body" ] \
|
||||
|| fail "acceptance: общий transport helper входа не найден"
|
||||
grep -q 'runReadOnlySecret`curl' <<<"$login_request_body" \
|
||||
|| fail "acceptance: запрос входа не выполняется секретным раннером"
|
||||
# `runReadOnly`curl` совпадает только с ГОЛОЙ формой: у секретной после
|
||||
# `runReadOnly` идёт `Secret`, а не обратная кавычка.
|
||||
! grep -q 'runReadOnly`curl' <<<"$login_request_body" \
|
||||
|| fail "acceptance: запрос входа выполняется раннером, кладущим команду в текст ошибки"
|
||||
grep -q -- '--user-agent' <<<"$login_request_body" \
|
||||
|| fail "acceptance: login smoke снова наследует блокируемый curl User-Agent"
|
||||
|
||||
# Wire-contract проверяется значением, а не поиском форматирования в исходнике.
|
||||
"$BUN_BIN" -e '
|
||||
const smoke = await import("./orchestrator/src/steps/smoke.ts");
|
||||
const request = smoke.buildAdminLoginRequest("hy2xsadmin", "quote\" slash\\ tail ");
|
||||
const body = JSON.parse(request.body);
|
||||
if (body.username !== "hy2xsadmin" || body.pass !== "quote\" slash\\ tail ") {
|
||||
throw new Error("login helper искажает username/pass");
|
||||
}
|
||||
if (Object.hasOwn(body, "password")) throw new Error("login helper отправляет alias password");
|
||||
if (!request.userAgent || /fofa|shodan|curl|wget/i.test(request.userAgent)) {
|
||||
throw new Error("login helper отправляет scanner-like User-Agent");
|
||||
}
|
||||
' || fail "acceptance: общий login helper нарушает wire-контракт или scanner middleware"
|
||||
|
||||
log_step "Acceptance: the negative login probe proves a rejection"
|
||||
# Проверка `code.trim() === "200"` доказывала ровно одно: что запрос не
|
||||
# закончился пятисоткой. Админка отвечает HTTP 200 и на успешный вход тоже,
|
||||
# поэтому «неверные учётные данные отвергнуты» из неё не следовало.
|
||||
code_has orchestrator/src/steps/smoke.ts -F -- 'isRejectedLogin' \
|
||||
|| fail "acceptance: отрицательная проба снова определяет отказ по коду HTTP"
|
||||
code_has orchestrator/src/steps/smoke.ts -F -- 'JSON.parse(body)' \
|
||||
|| fail "acceptance: login envelope снова проверяется regex по сырому тексту"
|
||||
code_has orchestrator/src/steps/smoke.ts -F -- 'INVALID_CREDENTIALS_CODE = "invalid_credentials"' \
|
||||
|| fail "acceptance: отрицательная проба не сверяет доменный код отказа"
|
||||
# Литерал в исходнике теоретически может оказаться настоящим паролем — тогда
|
||||
# проверка «данные отвергнуты» отчиталась бы об успешном ВХОДЕ.
|
||||
! code_has orchestrator/src/steps/smoke.ts -F -- 'definitely-not-the-admin-password' \
|
||||
|| fail "acceptance: пароль отрицательной пробы снова записан литералом"
|
||||
# Bootstrap-секрет читается парсером формата: `cut -d= -f2-` с `trim()` был
|
||||
# третьим слоем, срезающим краевые пробелы у пароля.
|
||||
! code_has orchestrator/src/steps/smoke.ts -F -- 'cut -d= -f2-' \
|
||||
|| fail "acceptance: bootstrap-секрет снова извлекается шелл-конвейером"
|
||||
code_has orchestrator/src/steps/smoke.ts -F -- 'readBootstrapAdminSecret' \
|
||||
|| fail "acceptance: bootstrap-секрет читается не парсером собственного формата"
|
||||
|
||||
log_step "Acceptance: the orchestrator validates what it generates"
|
||||
code_has orchestrator/src/config/env.ts -F -- 'assertValidAdminUsername(' \
|
||||
|| fail "acceptance: HY2XS_ADMIN_USER снова не проверяется против контракта панели"
|
||||
code_has orchestrator/src/config/env.ts -F -- 'assertValidAdminPassword(' \
|
||||
|| fail "acceptance: HY2XS_ADMIN_INITIAL_PASSWORD снова не проверяется против контракта панели"
|
||||
# Умолчание про одно и то же имя обязано быть одним. Их было три, и одно из
|
||||
# них — `admin` — короче минимума панели.
|
||||
code_has orchestrator/src/config/env.ts -F -- '"hy2xsadmin"' \
|
||||
|| fail "acceptance: умолчание логина администратора разошлось с админкой и packaged env"
|
||||
code_has apps/dao/sqlite.go -F -- 'adminUser = "hy2xsadmin"' \
|
||||
|| fail "acceptance: запасное значение логина в админке разошлось с оркестратором"
|
||||
grep -q '^HY2XS_ADMIN_USER=hy2xsadmin$' package/config/hy2xs.env \
|
||||
|| fail "acceptance: packaged env разошёлся с умолчанием оркестратора"
|
||||
}
|
||||
|
||||
# Планировщик принадлежит процессу и не перезапускает HTTP-сервер.
|
||||
run_scheduler_lifecycle_acceptance() {
|
||||
log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server"
|
||||
@@ -1192,6 +1559,39 @@ run_single_owner_acceptance() {
|
||||
}
|
||||
' || fail "acceptance: a diagnostics failure must never cancel the rollback"
|
||||
|
||||
log_step "Acceptance: diagnostics writes only into a trusted root namespace"
|
||||
grep -qF 'export const DIAGNOSTICS_ROOT = `${INSTALL_STATE_DIR}/diagnostics`' \
|
||||
orchestrator/src/lib/diagnosticsStorage.ts \
|
||||
|| fail "acceptance: diagnostics root must live below /var/lib/hy2xs"
|
||||
! grep -qF '/var/log/hy2xs/diagnostics' orchestrator/src/commands/diagnostics.ts \
|
||||
|| fail "acceptance: root diagnostics must not use the service-writable log namespace"
|
||||
grep -q 'ensureDiagnosticsStorageRoot' orchestrator/src/steps/filesystem.ts \
|
||||
|| fail "acceptance: install must prepare the root-only diagnostics directory"
|
||||
"$BUN_BIN" -e '
|
||||
const fs = require("node:fs");
|
||||
const storage = fs.readFileSync("orchestrator/src/lib/diagnosticsStorage.ts", "utf8");
|
||||
const diagnostics = fs.readFileSync("orchestrator/src/commands/diagnostics.ts", "utf8");
|
||||
for (const marker of ["lstat(", "isSymbolicLink()", "mkdtemp(", "open(archive, \"wx\"", "exactMode: DIAGNOSTICS_MODE"]) {
|
||||
if (!storage.includes(marker)) throw new Error("missing diagnostics trust-boundary marker: " + marker);
|
||||
}
|
||||
if (!diagnostics.includes("createDiagnosticsWorkspace()")) {
|
||||
throw new Error("diagnostics does not enter the trusted storage boundary");
|
||||
}
|
||||
if (!diagnostics.includes("runMutatingArgv([\"tar\"")) {
|
||||
throw new Error("diagnostics archive creation must not use a shell string");
|
||||
}
|
||||
' || fail "acceptance: diagnostics storage is not fail-closed against symlink substitution"
|
||||
|
||||
log_step "Acceptance: metadata fallback is ENOENT-only"
|
||||
"$BUN_BIN" -e '
|
||||
const fs = require("node:fs");
|
||||
const source = fs.readFileSync("orchestrator/src/lib/packageMeta.ts", "utf8");
|
||||
if (!source.includes(".code === \"ENOENT\"")) throw new Error("missing explicit ENOENT branch");
|
||||
if (!source.includes("throw error")) throw new Error("non-ENOENT metadata errors are not rethrown");
|
||||
const tests = fs.readFileSync("orchestrator/test/package-meta-utf8.test.ts", "utf8");
|
||||
if (!tests.includes("каталог вместо metadata-файла")) throw new Error("missing non-ENOENT regression test");
|
||||
' || fail "acceptance: package metadata errors may still be masked by fallback"
|
||||
|
||||
log_step "Acceptance: persisting the failure state never blocks the rollback"
|
||||
# Тот же класс, что и «диагностика не отменяет откат», но уровнем раньше.
|
||||
# Запись маркера отказа — это mkdir/write/chown в /var/lib/hy2xs, то есть она
|
||||
@@ -1205,6 +1605,17 @@ run_single_owner_acceptance() {
|
||||
grep -q 'persistFailureState(' "$rollback_command" \
|
||||
|| fail "acceptance: запись состояния отказа в $rollback_command не помечена как best effort"
|
||||
done
|
||||
# reset-failed — cleanup с допустимым исходом «юнит уже выгружен». Ненулевой
|
||||
# код нельзя ни превращать в ложный manual-recovery warning, ни глушить:
|
||||
# решение принимается после наблюдения ActiveState каждого юнита.
|
||||
grep -q 'resetFailedUnit("hysteria-server")' orchestrator/src/commands/install.ts \
|
||||
|| fail "acceptance: rollback install не проверяет postcondition reset-failed для hysteria-server"
|
||||
grep -q 'resetFailedUnit("hy2xs-admin")' orchestrator/src/commands/install.ts \
|
||||
|| fail "acceptance: rollback install не проверяет postcondition reset-failed для hy2xs-admin"
|
||||
code_has orchestrator/src/lib/systemd.ts -F -- 'runMutatingStatus`systemctl reset-failed' \
|
||||
|| fail "acceptance: reset-failed снова бросает до проверки допустимого not-loaded состояния"
|
||||
code_has orchestrator/src/lib/systemd.ts -F -- 'properties.get("ActiveState")' \
|
||||
|| fail "acceptance: успех reset-failed не доказывается наблюдаемым ActiveState"
|
||||
"$BUN_BIN" -e '
|
||||
const fs = require("node:fs");
|
||||
for (const [file, write] of [
|
||||
|
||||
@@ -7,6 +7,16 @@ import {
|
||||
FLAMY_NAME,
|
||||
FLAMY_URL,
|
||||
} from "../../apps/frontend/src/constants/branding";
|
||||
import {
|
||||
ADMIN_PASSWORD_MAX_BYTES,
|
||||
ADMIN_PASSWORD_MAX_LENGTH,
|
||||
ADMIN_PASSWORD_MIN_LENGTH,
|
||||
ADMIN_USERNAME_CHARSET,
|
||||
ADMIN_USERNAME_MAX_LENGTH,
|
||||
ADMIN_USERNAME_MIN_LENGTH,
|
||||
ADMIN_USERNAME_PATTERN,
|
||||
isValidAdminPassword,
|
||||
} from "../../apps/frontend/src/constants/credentials";
|
||||
import ru from "../../apps/frontend/src/lang/package/ru";
|
||||
import en from "../../apps/frontend/src/lang/package/en";
|
||||
|
||||
@@ -678,6 +688,283 @@ 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("подсказки формы пира", () => {
|
||||
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
|
||||
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
|
||||
|
||||
Reference in New Issue
Block a user