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

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

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

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

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

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

Что сделано:

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 18:38:04 +05:00

323 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Формат файлов `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.
*
* Строка без `=` — ОШИБКА, а не пропуск. systemd такую строку молча
* отбрасывает, и здесь это единственное намеренное расхождение: молчаливая
* потеря строки из /etc/hy2xs/hy2xs.env означала бы установку с настройкой,
* которую оператор задал, а продукт не увидел. Расхождение в сторону отказа
* безопасно — оно останавливает установку там, где её можно починить.
*/
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 (!NEWLINE.includes(c)) {
// Обратный слеш СОХРАНЯЕТСЯ вместе с символом — «как делает
// настоящий shell», по формулировке самого systemd.
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_\-.\/:@,=+%]+)*$/;
/**
* Проверяет, что значение вообще представимо в этом формате.
*
* Управляющих символов формат не несёт: перевод строки — граница записи, а не
* данные. Отказ здесь громкий намеренно — молчаливая потеря части секрета
* означала бы установку, после которой невозможно войти, и причину, которой
* негде увидеться.
*/
export function assertEnvTransportable(name: string, value: string): string {
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) {
throw new Error(
`${name} contains a control character (U+${code.toString(16).toUpperCase().padStart(4, "0")}): ` +
`формат KEY=VALUE, который читают systemd и оркестратор, управляющих символов не несёт`
);
}
}
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`;
}