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
+51 -13
View File
@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto";
import type { FirewallMode, PublicEndpointPolicy, RuntimeConfig, TlsMode } from "../types/context";
import { parseEnvFile, renderEnvFile } from "../lib/envFile";
import { assertEnvTransportable, parseEnvFile, renderEnvFile } from "../lib/envFile";
import {
GECKO_DEFAULT_MAX_PACKET_SIZE,
GECKO_DEFAULT_MIN_PACKET_SIZE,
@@ -309,6 +309,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;
}
@@ -393,19 +405,15 @@ export function validateRuntimeConfig(config: RuntimeConfig): void {
}
/**
* Пишет /etc/hy2xs/hy2xs.env.
* Пары `KEY=VALUE`, которые уезжают в /etc/hy2xs/hy2xs.env.
*
* Каждое значение проходит через formatEnvAssignment, а не подставляется в
* строку интерполяцией. Раньше подставлялось, и файл поэтому был форматом
* только для значений без пробелов по краям, кавычек и обратных слешей: пароль
* администратора, у которого набор символов объявлен неограниченным, не
* пережил бы обратного чтения — ни нашего, ни systemd'ного.
*
* Обычные значения (порты, пути, домены, `50 mbps`) кавычек не получают и
* остаются побайтово прежними — см. UNQUOTED_SAFE_VALUE в lib/envFile.
* Вынесены из renderRuntimeEnv, потому что у списка ДВА потребителя: запись
* файла и проверка транспорта, выполняемая задолго до неё. Пока список
* существовал только внутри рендера, единственным способом узнать, что
* конфигурация не запишется, было её записать.
*/
export function renderRuntimeEnv(config: RuntimeConfig): string {
const entries: [string, string][] = [
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],
@@ -445,5 +453,35 @@ export function renderRuntimeEnv(config: RuntimeConfig): string {
["HY2XS_DATA_DIR", config.dataDir],
["HY2XS_LOG_DIR", config.logDir]
];
return `# HY2XS runtime config (editable)\n${renderEnvFile(entries)}`;
}
/**
* Отвергает конфигурацию, которую нельзя записать в файл окружения так, чтобы
* 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))}`;
}
+15 -1
View File
@@ -1,4 +1,5 @@
import type { HysteriaObfsType, RuntimeConfig } from "../types/context";
import { isEnvTransportable } from "../lib/envFile";
/**
* HY2XS production profile: единственное место, где определены значения
@@ -144,10 +145,23 @@ export function assertValidAdminUsername(name: string, value: string): string {
* Пробелы по краям — часть пароля и не снимаются.
*/
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;
if (code < 0x20 || code === 0x7f) {
// Продуктовая политика: управляющие символы Unicode целиком (Cc — C0, DEL,
// C1) и U+FEFF. Формат их несёт; запрещает их HY2XS, потому что ни один из
// них невозможно ни увидеть в поле ввода, ни повторить при следующем входе.
if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0xfeff) {
return false;
}
characters += 1;
+88 -14
View File
@@ -73,11 +73,18 @@ type State =
/**
* Разбирает содержимое env-файла ровно так, как это делает systemd.
*
* Строка без `=` — ОШИБКА, а не пропуск. systemd такую строку молча
* отбрасывает, и здесь это единственное намеренное расхождение: молчаливая
* потеря строки из /etc/hy2xs/hy2xs.env означала бы установку с настройкой,
* которую оператор задал, а продукт не увидел. Расхождение в сторону отказа
* безопасно — оно останавливает установку там, где её можно починить.
* Расхождений с 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> = {};
@@ -210,9 +217,16 @@ export function parseEnvFile(content: string): Record<string, string> {
state = "DOUBLE_QUOTE_VALUE";
if (SHELL_NEED_ESCAPE.includes(c)) {
value += c;
} else if (!NEWLINE.includes(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;
@@ -273,23 +287,83 @@ export function parseEnvFile(content: string): Record<string, string> {
*/
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")}`;
}
/**
* Проверяет, что значение вообще представимо в этом формате.
* Домен значений, которые systemd СМОЖЕТ загрузить из EnvironmentFile.
*
* Управляющих символов формат не несёт: перевод строки — граница записи, а не
* данные. Отказ здесь громкий намеренно — молчаливая потеря части секрета
* Это чужое множество, а не наша политика, и оно проверяется отдельно именно
* поэтому: нарушение здесь — не «некрасивое значение», а НЕзагруженный файл
* окружения и, следовательно, юнит, который не стартует.
*
* Перед тем как принять пару, 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) {
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 (code < 0x20 || code === 0x7f) {
throw new Error(
`${name} contains a control character (U+${code.toString(16).toUpperCase().padStart(4, "0")}): ` +
`формат KEY=VALUE, который читают systemd и оркестратор, управляющих символов не несёт`
);
if (isEnvTransportable(character)) {
continue;
}
throw new Error(
`${name} contains ${describeCodePoint(code)}, which systemd refuses to load from an ` +
`EnvironmentFile: значение обязано быть валидным UTF-8 из Unicode scalar values, ` +
`без NUL, без суррогатов и без noncharacters (U+FDD0..U+FDEF и *FFFE/*FFFF). ` +
`Файл окружения с таким значением не загрузится, и юнит не стартует.`
);
}
return value;
}
+117 -7
View File
@@ -3,10 +3,16 @@ import { describe, expect, test } from "bun:test";
import {
assertEnvTransportable,
formatEnvAssignment,
isEnvTransportable,
parseEnvFile,
renderEnvFile
} from "../src/lib/envFile";
import { parseRuntimeEnv, renderRuntimeEnv } from "../src/config/env";
import {
parseRuntimeEnv,
renderRuntimeEnv,
runtimeEnvEntries,
validateRuntimeEnvTransport
} from "../src/config/env";
import { baselineConfig, envText } from "./fixtures";
/**
@@ -141,14 +147,63 @@ describe("запись обратима разбором", () => {
expect(formatEnvAssignment("K", "a$b`c")).toBe('K="a$b`c"');
});
test("управляющий символ — отказ записи, а не потеря части значения", () => {
expect(() => assertEnvTransportable("HY2XS_ADMIN_INITIAL_PASSWORD", "a\nb")).toThrow(
/control character/
);
expect(() => formatEnvAssignment("HY2XS_ADMIN_INITIAL_PASSWORD", "a\tb")).toThrow(
/control character/
test("непредставимое значение — отказ записи, а не потеря части секрета", () => {
// Отвергается то, что НЕ ЗАГРУЗИТ systemd, а не то, что нам не нравится.
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(/systemd refuses to load/);
expect(() =>
formatEnvAssignment("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`)
).toThrow(/systemd refuses to load/);
}
});
// Одиночный суррогат — единственный случай, где без проверки не было бы даже
// отказа: `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(
/systemd refuses to load/
);
});
// Домен транспорта — ЧУЖОЕ множество, и он не шире и не уже множества 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);
}
// U+FEFF формат тоже несёт: 0xFEFF & 0xFFFE === 0xFEFE, и unichar_is_valid
// его принимает. Комментарий `/* BOM */` в исходнике systemd относится к
// U+xFFFE и является его собственной неточностью.
expect(isEnvTransportable(String.fromCodePoint(0xfeff))).toBe(true);
});
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
// множество systemd, а не окрестность подозрительных значений.
test("соседи noncharacters принимаются", () => {
for (const accepted of [0xfdcf, 0xfdf0, 0xfffd, 0x10fffd, 0x1f600]) {
expect(isEnvTransportable(String.fromCodePoint(accepted))).toBe(true);
}
});
});
describe("пароль администратора доезжает до админки неизменным", () => {
@@ -189,3 +244,58 @@ describe("пароль администратора доезжает до адм
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();
});
// Ограничение принадлежит ФОРМАТУ, а не полю пароля: любой параметр сломал бы
// загрузку юнита тем же способом. 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(
/systemd refuses to load/
);
}
});
// Список пар — один на запись и на проверку. Пока он существовал только
// внутри рендера, единственным способом узнать, что конфигурация не
// запишется, было её записать.
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();
});
});