fix(env): контракт был шире домена, который принимает systemd

Разбор предыдущего прохода со сверкой по исходникам systemd v257.13 — той самой
линии, что стоит на Debian 13. Тема та же и слоем глубже: контракт, объявленный
шире, чем его принимает чужая сторона. Прошлый проход сделал транспорт lossless
для значений, которые systemd принимает, но не спросил, какие значения он
принимает вообще.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 23:38:57 +05:00
parent 65042ee335
commit ab788725cf
16 changed files with 860 additions and 88 deletions
+37 -7
View File
@@ -278,7 +278,8 @@ control plane показывал всех пиров офлайн. Теперь
| Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` |
| Длина пароля | 6-64 символа Unicode | `AdminPasswordMinLength` / `AdminPasswordMaxLength` |
| Размер пароля | не более 72 байт в UTF-8 | `AdminPasswordMaxBytes` |
| Набор символов пароля | не ограничен, кроме управляющих | — |
| Домен пароля | значение, загружаемое systemd из `EnvironmentFile=` | `IsEnvTransportableText` |
| Набор символов пароля | не ограничен, кроме `Cc` и `U+FEFF` | `hasForbiddenRune` |
| Пробелы по краям пароля | часть значения, не снимаются | — |
Контракт живёт в отдельном **leaf-пакете**, а не в `service`, и это не
@@ -305,12 +306,41 @@ control plane показывал всех пиров офлайн. Теперь
bcrypt-хешем. Ограничение набора на форме не защищает ничего и умеет только
отвергнуть пароль, который сервер принял бы.
Единственное исключение — **управляющие символы** (C0 и DEL). Они запрещены не
формой, а транспортом: первый пароль администратора уезжает в
`/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и у
перевода строки там нет представления, переживающего запись и чтение. Такой
пароль всё равно невозможно ввести в однострочное поле формы входа, то есть он
умеет ровно одно — запереть оператора снаружи.
Исключения два, и они **разного происхождения**. Их важно не путать: одно
описывает чужое ограничение, другое — наше решение.
**Домен 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 и любую невалидную последовательность UTF-8.
Пока контракт этого не знал, пароль `abcde` + `U+FDD0` — шесть символов, восемь
байт, ни одного управляющего — проходил панель, оркестратор, DTO и хеширование,
записывался в `hy2xs.env`, и после этого админка не поднималась. Тот же класс
дефекта, ради уничтожения которого контракт и существует, только слоем ниже.
На стороне панели и оркестратора отдельно отвергаются **одиночные суррогаты**:
строка JavaScript вправе их содержать, а `TextEncoder` молча заменит непарный
суррогат на `U+FFFD`. Без этой проверки не было бы отказа — было бы тихое
изменение пароля по дороге в файл.
**Политика HY2XS — наше решение.** Сверх домена systemd запрещены управляющие
символы Unicode целиком (категория `Cc`: `U+0000..U+001F`, `U+007F`,
`U+0080..U+009F`) и `U+FEFF`. Формат их несёт — `0xFEFF & 0xFFFE` это `0xFEFE`,
и systemd такое значение принимает, — но ни один из них невозможно ни увидеть в
поле ввода, ни повторить при следующем входе: они умеют ровно одно — запереть
оператора снаружи.
### Почему границ у пароля две
@@ -600,13 +600,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 …)` повторно как код не исполняется — он
становится значением переменной, и это принципиальная разница.
Команда печатает новые логин и пароль в консоль и требует смены пароля при
первом входе. Работает поверх существующей установки; на машине без базы она
+44 -7
View File
@@ -577,14 +577,51 @@ production-профилем, а не ищет подстроки. Проверя
релизные гейты и инструкции оператора вида `grep '^HY2XS_UI_PORT=8080$'`
продолжают работать. Тем же кодировщиком пишется `bootstrap-admin.secret`.
Одно намеренное расхождение с systemd: строка без `=`**отказ**, а не
пропуск. systemd такую строку молча отбрасывает; молчаливая потеря строки из
`hy2xs.env` означала бы установку с настройкой, которую оператор задал, а
продукт не увидел. Расхождение в сторону отказа останавливает установку там, где
её можно починить.
Расхождений с 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.
`isEnvTransportable` в `lib/envFile.ts` повторяет ровно это множество и ничего
сверх него. Управляющие символы формат несёт — внутри двойных кавычек перевод
строки накапливается как обычный байт и переживает round-trip, — и запрещает их
контракт учётных данных, а не транспорт. Приписывать формату чужие запреты
нельзя: именно так проверка и пропустила noncharacters, о которых ничего не
знала.
Одиночные суррогаты проверяются отдельно и по своей причине: строка JavaScript
вправе их содержать, а `TextEncoder` молча заменит непарный суррогат на
`U+FFFD` — то есть без проверки в файл уехал бы **другой** секрет, а не отказ.
### Непригодная конфигурация отвергается до первой мутации
`validateRuntimeEnvTransport` вызывается из `parseRuntimeEnv`, а не при записи
файла, и проходит по **всем** парам `runtimeEnvEntries` — не только по паролю
администратора.
Раньше проверка жила только внутри `renderRuntimeEnv`, то есть срабатывала на
шаге «write runtime env» — уже после bootstrap оркестратора, установки пакетов и
раскладки файловой системы. Read-only `preflight-install` при этом говорил PASS:
он зовёт `parseRuntimeEnv` и ничего не рендерит. Детерминированно известная
ошибка конфигурации роняла операцию, оставив за собой изменённый хост, — что
прямо противоречит контракту PHASE 0.
## Smoke проверяет, что панель ВПУСКАЕТ
+18 -4
View File
@@ -153,7 +153,7 @@ anycast. Отсутствие A-записи фатально при любом
| Переменная | Требование | Значение по умолчанию |
| --- | --- | --- |
| `HY2XS_ADMIN_USER` | 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `hy2xsadmin` |
| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа Unicode **и** не более 72 байт в UTF-8; набор не ограничен, кроме управляющих символов | генерируется |
| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа Unicode **и** не более 72 байт в UTF-8; значение, загружаемое systemd из `EnvironmentFile=`; набор не ограничен, кроме `Cc` и `U+FEFF` | генерируется |
Значение вне контракта **роняет установку** с явным текстом, называющим границы
и набор. Так и должно быть: отказ, пришедший установщику, чинится одной строкой
@@ -190,9 +190,23 @@ HY2XS_ADMIN_INITIAL_PASSWORD="пароль с пробелом на конце "
слеш, закавычьте значение тем же способом. Обычные значения (порты, пути,
домены) кавычек не требуют и записываются как раньше.
Управляющие символы (перевод строки, табуляция) в пароле запрещены контрактом:
формат `KEY=VALUE` их не несёт, а ввести такой пароль в форму входа всё равно
нельзя.
Есть два ограничения набора символов, и они разного происхождения.
**Домен systemd.** Значение обязано быть загружаемым из `EnvironmentFile=`:
валидный UTF-8 из Unicode scalar values, без NUL, без суррогатов и без
noncharacters (`U+FDD0..U+FDEF` и все `*FFFE`/`*FFFF`). Это не наше правило —
systemd прогоняет значение через `utf8_is_valid` и отвечает `-EINVAL`, то есть
файл окружения **не загружается** и юнит не стартует. Оркестратор проверяет
домен на **каждом** значении файла, а не только на пароле: `HY2XS_ADMIN_CON_PASS`
или obfs-пароль сломали бы загрузку юнита ровно так же.
**Политика HY2XS.** Сверх этого запрещены управляющие символы Unicode
(категория `Cc`) и `U+FEFF`. Формат их несёт, но ввести такой пароль в
однострочное поле формы входа всё равно нельзя.
Отказ по любому из двух правил приходит **при разборе конфигурации**, то есть до
первой необратимой операции над хостом: `preflight-install` и `install` видят
его одинаково.
Значение по умолчанию совпадает в трёх местах и обязано совпадать:
`package/config/hy2xs.env`, `orchestrator/src/config/env.ts` и запасное
+36 -4
View File
@@ -105,15 +105,34 @@
| `KEY="a\nb"` | `a\nb``n` не входит в `SHELL_NEED_ESCAPE`, слеш сохраняется |
| `KEY="$HOME"` | `$HOME` — подстановок в env-файле нет |
| `KEY='a\b'` | `a\b` — в одинарных кавычках escape нет вовсе |
| строка без `=` | отказ (единственное намеренное расхождение: systemd её отбрасывает молча) |
| незакрытая кавычка | отказ |
| строка без `=` | отказ (намеренное расхождение: systemd её отбрасывает молча) |
| незакрытая кавычка на EOF | отказ (второе намеренное расхождение: systemd принял бы накопленное) |
И обратимость: любое значение — с краевыми пробелами, кавычками, обратными
слешами, `$`, `` ` ``, `#`, эмодзи — переживает `render -> parse` побайтово, а
обычные значения (`8080`, `/etc/hysteria/server.crt`, `50 mbps`) остаются без
кавычек, чтобы релизные гейты и инструкции оператора продолжали работать.
Управляющий символ в значенииотказ ЗАПИСИ, а не потеря части секрета.
Отдельно проверяется **домен** значенийчужое множество, а не наша политика:
| Вход | Ожидание |
| --- | --- |
| NUL, `U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`, `U+1FFFF`, `U+10FFFF` | отказ ЗАПИСИ: `utf8_is_valid` отвергает такое значение, и EnvironmentFile не загрузится |
| одиночный суррогат `U+D800` | отказ — иначе `TextEncoder` молча заменил бы его на `U+FFFD`, то есть подменил бы секрет |
| `U+FDCF`, `U+FDF0`, `U+FFFD`, `U+10FFFD`, `U+1F600` | принимаются: правило описывает диапазон, а не окрестность |
| `\n`, `\r`, `\t`, `U+007F`, `U+0085`, `U+FEFF` | формат их НЕСЁТ и 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`):
@@ -569,7 +588,20 @@ wildcard-маршрутом фронтенда или дублирующая р
последняя строка — исходный дефект: прежний тест требовал ПРИЁМА этого
значения, то есть закреплял как ожидаемое ровно то, на чём продукт ломался;
- пробел по краям — часть пароля, шесть пробелов являются корректным паролем;
- управляющие символы (`\n`, `\r`, `\t`, `NUL`, `DEL`) отвергаются.
- управляющие символы Unicode целиком, то есть `Cc`: `\n`, `\r`, `\t`, `NUL`,
`DEL` и C1 (`U+0085`, `U+009F`). Раньше проверялись только C0 и DEL, а
документация обещала «без управляющих символов» — то есть была шире кода;
- значения вне домена systemd (`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` отвергается **политикой продукта**, а не форматом:
`0xFEFF & 0xFFFE == 0xFEFE`, и systemd такое значение принимает. Отдельный
тест `TestEnvTransportDomainMatchesSystemd` закрепляет, что домен транспорта
не шире и не уже множества systemd, а
`TestProductPolicyIsWiderThanTransportDomain` — что политика и домен остаются
различимы.
`apps/controller/validator_test.go` — ПРОВОДКА, а не контракт: теги
`credentialStr` и `adminPassword` прогоняются через production-валидатор и
+1
View File
@@ -55,6 +55,7 @@
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+FDD0`, `U+FFFF`, невалидный UTF-8) **роняет `preflight-install`** — то есть отказ приходит до первой мутации хоста, а не после установки пакетов; сервер остаётся нетронутым
38. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки
## C0. Панель обязана впускать, а не слушать порт