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, 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 { 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 { 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 { await writeDiagnostic(outDir, name, await prepareDiagnosticFile(source, sanitize)); } /** Чистая граница «прочитать строго -> санитизировать -> вернуть текст». */ export async function prepareDiagnosticFile( source: string, sanitize: (content: string) => string ): Promise { 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 { 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`install -d -m 0700 ${outDir}`; 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"]); 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); await runMutating`sh -c ${`umask 077; tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`; await runMutating`chmod 0600 ${archive}`; info(`diagnostics bundle collected: ${archive}`); }