fix(utf8): закрыть lossy-границы конфигурации и API
This commit is contained in:
@@ -1,74 +1,96 @@
|
||||
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 { runMutating, runReadOnlyArgvStatus } from "../lib/process";
|
||||
import { redactEnv, redactLogText, redactYaml } from "../lib/redaction";
|
||||
|
||||
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`install -d -m 0700 ${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`}`;
|
||||
await collectCommand(outDir, "systemd-hysteria.txt", ["systemctl", "status", "hysteria-server"]);
|
||||
await collectCommand(outDir, "systemd-admin.txt", ["systemctl", "status", "hy2xs-admin"]);
|
||||
await collectCommand(outDir, "journal-hysteria.log", ["journalctl", "-u", "hysteria-server", "-n", "300", "--no-pager"]);
|
||||
await collectCommand(outDir, "journal-admin.log", ["journalctl", "-u", "hy2xs-admin", "-n", "300", "--no-pager"]);
|
||||
await collectCommand(outDir, "nftables.ruleset", ["nft", "list", "ruleset"]);
|
||||
await collectCommand(outDir, "uname.txt", ["uname", "-a"]);
|
||||
await collectCommand(outDir, "ss-ltnup.txt", ["ss", "-ltnup"]);
|
||||
|
||||
try {
|
||||
const envRaw = await Bun.file(`${outDir}/hy2xs.env`).text();
|
||||
await Bun.write(`${outDir}/hy2xs.env`, redactEnv(envRaw));
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
await collectFile(outDir, "os-release.txt", "/etc/os-release", redactLogText);
|
||||
await collectFile(outDir, "post-install.env", "/etc/hysteria/post-install.env", redactEnv);
|
||||
await collectFile(outDir, "hy2xs.env", "/etc/hy2xs/hy2xs.env", redactEnv);
|
||||
await collectFile(outDir, "hysteria-config.yaml", "/etc/hysteria/config.yaml", redactYaml);
|
||||
await collectFile(outDir, "install-state.json", "/var/lib/hy2xs/install-state.json", redactLogText);
|
||||
|
||||
try {
|
||||
const postInstallRaw = await Bun.file(`${outDir}/post-install.env`).text();
|
||||
await Bun.write(`${outDir}/post-install.env`, redactEnv(postInstallRaw));
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
try {
|
||||
const cfgRaw = await Bun.file(`${outDir}/hysteria-config.yaml`).text();
|
||||
await Bun.write(`${outDir}/hysteria-config.yaml`, redactYaml(cfgRaw));
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
// Журналы и вывод 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)}' .`}`;
|
||||
await runMutating`sh -c ${`umask 077; tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`;
|
||||
await runMutating`chmod 0600 ${archive}`;
|
||||
|
||||
info(`diagnostics bundle collected: ${archive}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -221,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);
|
||||
|
||||
@@ -135,10 +135,10 @@ export function assertValidAdminUsername(name: string, value: string): string {
|
||||
* создавалась: bcrypt отвечал ErrPasswordTooLong при старте админки, и оператор
|
||||
* получал не отказ установки, а сервер без администратора.
|
||||
*
|
||||
* Запрет управляющих символов — требование транспорта. Значение уезжает в
|
||||
* /etc/hy2xs/hy2xs.env, который systemd читает как EnvironmentFile; границей
|
||||
* строки там является байт `\n` или `\r`, и пароль с ними не имеет
|
||||
* представления, переживающего запись и чтение. См. lib/envFile.ts.
|
||||
* Управляющие символы формат EnvironmentFile способен нести; их отвергает
|
||||
* политика HY2XS, потому что невидимое значение нельзя надёжно повторить в
|
||||
* однострочной форме входа. Транспортный домен (включая U+FEFF) проверяется
|
||||
* отдельно в lib/envFile.ts.
|
||||
*
|
||||
* Набор символов сверх этого НЕ ограничивается: сервер его не ограничивает ни
|
||||
* при установке, ни при смене пароля, и оркестратор не имеет права быть строже.
|
||||
@@ -158,10 +158,9 @@ export function isValidAdminPassword(value: string): boolean {
|
||||
let characters = 0;
|
||||
for (const character of value) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
// Продуктовая политика: управляющие символы Unicode целиком (Cc — C0, DEL,
|
||||
// C1) и U+FEFF. Формат их несёт; запрещает их HY2XS, потому что ни один из
|
||||
// них невозможно ни увидеть в поле ввода, ни повторить при следующем входе.
|
||||
if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0xfeff) {
|
||||
// Продуктовая политика: управляющие символы Unicode целиком (Cc — C0,
|
||||
// DEL, C1). U+FEFF уже отвергнут транспортным доменом выше.
|
||||
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
|
||||
return false;
|
||||
}
|
||||
characters += 1;
|
||||
@@ -179,7 +178,7 @@ export function assertValidAdminPassword(name: string, value: string): string {
|
||||
throw new Error(
|
||||
`invalid ${name}: панель принимает пароль длиной от ${ADMIN_PASSWORD_MIN_LENGTH} ` +
|
||||
`до ${ADMIN_PASSWORD_MAX_LENGTH} символов Unicode, не длиннее ${ADMIN_PASSWORD_MAX_BYTES} байт ` +
|
||||
`в UTF-8 и без управляющих символов; получено ${[...value].length} символов ` +
|
||||
`в UTF-8, без управляющих символов и U+FEFF; получено ${[...value].length} символов ` +
|
||||
`и ${Buffer.byteLength(value, "utf8")} байт. ` +
|
||||
`Набор символов не ограничен, пробелы по краям являются частью пароля. ` +
|
||||
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
|
||||
|
||||
@@ -293,13 +293,16 @@ function describeCodePoint(code: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Домен значений, которые systemd СМОЖЕТ загрузить из EnvironmentFile.
|
||||
* Документированный домен значений EnvironmentFile.
|
||||
*
|
||||
* Это чужое множество, а не наша политика, и оно проверяется отдельно именно
|
||||
* поэтому: нарушение здесь — не «некрасивое значение», а НЕзагруженный файл
|
||||
* окружения и, следовательно, юнит, который не стартует.
|
||||
*
|
||||
* Перед тем как принять пару, systemd прогоняет ключ и значение через
|
||||
* Публичная документация 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) сверх
|
||||
@@ -321,7 +324,7 @@ function describeCodePoint(code: number): string {
|
||||
export function isEnvTransportable(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
if (code === 0) {
|
||||
if (code === 0 || code === 0xfeff) {
|
||||
return false;
|
||||
}
|
||||
if (code >= 0xd800 && code <= 0xdfff) {
|
||||
@@ -359,9 +362,9 @@ export function assertEnvTransportable(name: string, value: string): string {
|
||||
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). ` +
|
||||
`${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). ` +
|
||||
`Файл окружения с таким значением не загрузится, и юнит не стартует.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,10 +1,15 @@
|
||||
import { readText } from "./fs";
|
||||
import { InvalidUtf8Error, readText } from "./fs";
|
||||
import { runReadOnly } from "./process";
|
||||
|
||||
export async function readPackageValue(packageDir: string, file: string, fallback: string): Promise<string> {
|
||||
try {
|
||||
return (await readText(`${packageDir}/metadata/${file}`)).trim();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Отсутствующая metadata допускает fallback, повреждённая — нет: иначе
|
||||
// битый пакет выдаёт себя за пакет с корректным запасным значением.
|
||||
if (error instanceof InvalidUtf8Error) {
|
||||
throw error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Мутация, которая ВОЗВРАЩАЕТ код возврата вместо броска.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user