fix(utf8): закрыть lossy-границы конфигурации и API

This commit is contained in:
2026-09-07 17:25:27 +05:00
parent ab788725cf
commit af9f476658
38 changed files with 759 additions and 159 deletions
+76 -54
View File
@@ -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}`);
}