diff --git a/apps/controller/hysteria2.go b/apps/controller/hysteria2.go index a55dcf0..0373586 100644 --- a/apps/controller/hysteria2.go +++ b/apps/controller/hysteria2.go @@ -11,11 +11,16 @@ import ( ) func Hysteria2Auth(c *gin.Context) { - hysteria2AuthDto, err := validateField(c, dto.Hysteria2AuthDto{}) - if err != nil { + var req dto.Hysteria2AuthDto + if err := c.ShouldBindJSON(&req); err != nil { + vo.Hysteria2AuthBadRequest(c) return } - id, username, err := service.Hysteria2Auth(*hysteria2AuthDto.Auth) + if req.Addr == nil || req.Auth == nil || req.Tx == nil { + vo.Hysteria2AuthBadRequest(c) + return + } + id, username, err := service.Hysteria2Auth(*req.Auth) if err != nil || username == "" { vo.Hysteria2AuthFail("", c) return diff --git a/apps/model/vo/hysteria2.go b/apps/model/vo/hysteria2.go index e37e074..3cde22d 100644 --- a/apps/model/vo/hysteria2.go +++ b/apps/model/vo/hysteria2.go @@ -24,6 +24,13 @@ func Hysteria2AuthFail(id string, c *gin.Context) { }) } +func Hysteria2AuthBadRequest(c *gin.Context) { + c.JSON(http.StatusBadRequest, hysteria2Result{ + Ok: false, + Id: "", + }) +} + type Hysteria2SubscribeVo struct { Url string `json:"url"` QrCode []byte `json:"qrCode"` diff --git a/docs/13-production-runbook.md b/docs/13-production-runbook.md index 8e27acd..00a2919 100644 --- a/docs/13-production-runbook.md +++ b/docs/13-production-runbook.md @@ -91,3 +91,18 @@ hy2xs-orchestrator doctor --package-dir /usr/local/lib/hy2xs/package --config /e Команда выполняет preflight + smoke как post-install/post-reboot validation. +## 13. Secret-safe config sharing + +Для передачи конфигов в тикеты/чаты используйте встроенную redaction-команду: + +```bash +hy2xs-orchestrator redact-config --config /etc/hy2xs/hy2xs.env --out /root/hy2xs.redacted.env +hy2xs-orchestrator redact-config --config /etc/hysteria/post-install.env --out /root/post-install.redacted.env +hy2xs-orchestrator redact-config --config /etc/hysteria/config.yaml --out /root/hysteria-config.redacted.yaml --format yaml +``` + +Инварианты: +- команда не выводит исходные секреты в stdout; +- требуется выбрать ровно один режим: `--in-place` или `--out `; +- `--format auto` пытается определить формат по имени файла, при неоднозначности используйте `--format env|yaml`. + diff --git a/orchestrator/src/cli.ts b/orchestrator/src/cli.ts index f071df4..0423d91 100644 --- a/orchestrator/src/cli.ts +++ b/orchestrator/src/cli.ts @@ -3,6 +3,7 @@ import { reconfigure, repair } from "./commands/reconfigure"; import { doctor } from "./commands/doctor"; import { status } from "./commands/status"; import { diagnosticsCollect } from "./commands/diagnostics"; +import { redactConfig, type RedactConfigFormat } from "./commands/redact-config"; import type { InstallOptions, ReconfigureOptions } from "./types/context"; function usage(): never { @@ -13,10 +14,65 @@ function usage(): never { console.error(" hy2xs-orchestrator doctor --package-dir [--config ] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); console.error(" hy2xs-orchestrator status --package-dir [--config ] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); console.error(" hy2xs-orchestrator diagnostics collect --package-dir [--config ] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); + console.error(" hy2xs-orchestrator redact-config --config [--in-place | --out ] [--format auto|env|yaml]"); console.error(" note: --skip-start is deprecated alias for --skip-service-start --skip-smoke"); process.exit(2); } +function parseRedactConfigOptions(args: string[]): { + configPath: string; + outPath: string; + inPlace: boolean; + format: RedactConfigFormat; +} { + let configPath = ""; + let outPath = ""; + let inPlace = false; + let format: RedactConfigFormat = "auto"; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + switch (arg) { + case "--config": + configPath = takeValue(args, i, arg); + i += 1; + break; + case "--out": + outPath = takeValue(args, i, arg); + i += 1; + break; + case "--in-place": + inPlace = true; + break; + case "--format": { + const value = takeValue(args, i, arg); + if (value !== "auto" && value !== "env" && value !== "yaml") { + console.error(`invalid --format value: ${value}`); + usage(); + } + format = value; + i += 1; + break; + } + default: + console.error(`Unknown argument: ${arg}`); + usage(); + } + } + + if (!configPath) { + console.error("Missing --config"); + usage(); + } + + if (inPlace === (outPath !== "")) { + console.error("Specify exactly one of --in-place or --out "); + usage(); + } + + return { configPath, outPath, inPlace, format }; +} + function takeValue(args: string[], index: number, flag: string): string { const value = args[index + 1]; if (!value || value.startsWith("--")) { @@ -76,6 +132,11 @@ function parseInstallOptions(args: string[]): InstallOptions { usage(); } + if (options.skipSmoke && process.env.HY2XS_BREAK_GLASS !== "1") { + console.error("--skip-smoke is break-glass only. Set HY2XS_BREAK_GLASS=1 to continue."); + usage(); + } + return options; } @@ -139,6 +200,11 @@ function parseReconfigureOptions(args: string[]): ReconfigureOptions { usage(); } + if (options.skipSmoke && process.env.HY2XS_BREAK_GLASS !== "1") { + console.error("--skip-smoke is break-glass only. Set HY2XS_BREAK_GLASS=1 to continue."); + usage(); + } + return options; } @@ -178,6 +244,10 @@ async function main(): Promise { await diagnosticsCollect(parseCommonOptions(rest)); return; } + if (command === "redact-config") { + await redactConfig(parseRedactConfigOptions(args)); + return; + } usage(); } diff --git a/orchestrator/src/commands/diagnostics.ts b/orchestrator/src/commands/diagnostics.ts index 2e91544..9e34ece 100644 --- a/orchestrator/src/commands/diagnostics.ts +++ b/orchestrator/src/commands/diagnostics.ts @@ -1,26 +1,12 @@ import type { CommonOptions } from "../types/context"; import { info, setOperationContext } from "../lib/log"; import { run } from "../lib/process"; +import { redactEnv, redactYaml } from "../lib/redaction"; function shellEscapeSingleQuotes(value: string): string { return value.replaceAll("'", "'\\''"); } -function redactEnv(content: string): string { - return content - .replace(/^(HY2XS_ADMIN_INITIAL_PASSWORD=).*$/gm, "$1") - .replace(/^(HY2XS_ADMIN_CON_PASS=).*$/gm, "$1") - .replace(/^(HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=).*$/gm, "$1") - .replace(/^(HY2XS_HYSTERIA_OBFS_PASSWORD=).*$/gm, "$1"); -} - -function redactYaml(content: string): string { - return content - .replace(/(password:\s*).*/gi, "$1") - .replace(/(secret:\s*).*/gi, "$1") - .replace(/(auth:\s*).*/gi, "$1"); -} - export async function diagnosticsCollect(_options: CommonOptions): Promise { const opId = `diag-${Date.now().toString(36)}`; setOperationContext(opId); @@ -49,6 +35,13 @@ export async function diagnosticsCollect(_options: CommonOptions): Promise // noop } + 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)); diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts index 740b721..f20c083 100644 --- a/orchestrator/src/commands/install.ts +++ b/orchestrator/src/commands/install.ts @@ -12,7 +12,7 @@ import { installHysteria } from "../steps/hysteria"; import { generateConfig } from "../steps/config"; import { deploySystemd } from "../steps/systemd"; import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall"; -import { writeBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; +import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; import { smoke } from "../steps/smoke"; import { diagnosticsCollect } from "./diagnostics"; @@ -32,6 +32,7 @@ type InstallPhase = | "postinstall_env_written" | "bootstrap_secret_written" | "services_started" + | "smoke_running" | "smoke_failed" | "failed" | "installed"; @@ -232,15 +233,15 @@ export async function install(options: InstallOptions): Promise { await advanceInstallState(context, "postinstall_env_written"); state.lastPhase = "postinstall_env_written"; step("bootstrap admin secret"); - await writeBootstrapAdminSecret(context); + await ensureBootstrapAdminSecret(context); stepDone("bootstrap admin secret"); await advanceInstallState(context, "bootstrap_secret_written"); state.lastPhase = "bootstrap_secret_written"; step("smoke checks"); await advanceInstallState(context, "services_started"); state.lastPhase = "services_started"; - await advanceInstallState(context, "smoke_failed"); - state.lastPhase = "smoke_failed"; + await advanceInstallState(context, "smoke_running"); + state.lastPhase = "smoke_running"; await smoke(context); stepDone("smoke checks"); step("finalize firewall rollback guard"); diff --git a/orchestrator/src/commands/reconfigure.ts b/orchestrator/src/commands/reconfigure.ts index 4016dbb..2bbeeb1 100644 --- a/orchestrator/src/commands/reconfigure.ts +++ b/orchestrator/src/commands/reconfigure.ts @@ -6,7 +6,7 @@ import { preflight } from "../steps/preflight"; import { generateConfig } from "../steps/config"; import { deploySystemd } from "../steps/systemd"; import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall"; -import { writePostInstallEnv } from "../steps/env"; +import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; import { smoke } from "../steps/smoke"; import { runVisible } from "../lib/process"; import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta"; @@ -219,6 +219,7 @@ export async function reconfigure(options: ReconfigureOptions): Promise { await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600); await runVisible`chown root:root ${options.runtimeConfigPath}`; await runVisible`chmod 0600 ${options.runtimeConfigPath}`; + await ensureBootstrapAdminSecret(context); await writePostInstallEnv(context); stepDone("write env artifacts"); await markPhase(context, "runtime_env_written"); diff --git a/orchestrator/src/commands/redact-config.ts b/orchestrator/src/commands/redact-config.ts new file mode 100644 index 0000000..b8a0999 --- /dev/null +++ b/orchestrator/src/commands/redact-config.ts @@ -0,0 +1,60 @@ +import { fileExists, readText, writeTextAtomic } from "../lib/fs"; +import { info, setOperationContext } from "../lib/log"; +import { redactEnv, redactYaml } from "../lib/redaction"; + +export type RedactConfigFormat = "auto" | "env" | "yaml"; + +export type RedactConfigOptions = { + configPath: string; + outPath: string; + inPlace: boolean; + format: RedactConfigFormat; +}; + +function detectFormat(path: string): Exclude | null { + const p = path.toLowerCase(); + if (p.endsWith(".env") || p.endsWith("hy2xs.env") || p.endsWith("post-install.env")) { + return "env"; + } + if (p.endsWith(".yaml") || p.endsWith(".yml")) { + return "yaml"; + } + return null; +} + +function resolveFormat(format: RedactConfigFormat, path: string): Exclude { + if (format !== "auto") { + return format; + } + const detected = detectFormat(path); + if (!detected) { + throw new Error(`unable to auto-detect format for ${path}; use --format env|yaml`); + } + return detected; +} + +export async function redactConfig(options: RedactConfigOptions): Promise { + setOperationContext(`redact-config-${Date.now().toString(36)}`); + + if (!(await fileExists(options.configPath))) { + throw new Error(`config file not found: ${options.configPath}`); + } + + const targetPath = options.inPlace ? options.configPath : options.outPath; + if (!targetPath) { + throw new Error("target path is empty"); + } + + const input = await readText(options.configPath); + const format = resolveFormat(options.format, options.configPath); + const output = format === "env" ? redactEnv(input) : redactYaml(input); + + await writeTextAtomic(targetPath, output, { + mode: 0o600, + owner: "root", + group: "root" + }); + + info(`redact-config done: source=${options.configPath} target=${targetPath} format=${format} in_place=${options.inPlace}`); +} + diff --git a/orchestrator/src/commands/status.ts b/orchestrator/src/commands/status.ts index 60e0907..89f7064 100644 --- a/orchestrator/src/commands/status.ts +++ b/orchestrator/src/commands/status.ts @@ -53,20 +53,41 @@ export async function status(_options: CommonOptions): Promise { const rollbackGuardUnits = (await run`sh -c 'systemctl list-units --all --no-legend "hy2xs-fw-rollback-*.timer" "hy2xs-fw-rollback-*.service" 2>/dev/null || true'`).trim(); + const hysteriaService = await unitState("hysteria-server"); + const adminService = await unitState("hy2xs-admin"); + const firewall = await firewallState(); + const installPhase = String(installState?.phase ?? "unknown"); + const installStateEffective = installState?.installed + ? "installed" + : (installPhase === "unknown" ? "failed" : installPhase); + const rollbackGuardActive = rollbackGuardUnits.length > 0; + const runtimeState = (hysteriaService === "active" && adminService === "active") + ? (installStateEffective === "installed" ? "running" : "partial") + : "stopped"; + const humanStatus = runtimeState === "partial" + ? "Runtime services are active, but installation is not finalized because smoke checks failed." + : (runtimeState === "running" + ? "Runtime services are active and installation is finalized." + : "Runtime services are not fully active."); + const result = { ts: new Date().toISOString(), platform, services: { - hysteria: await unitState("hysteria-server"), - admin: await unitState("hy2xs-admin") + hysteria: hysteriaService, + admin: adminService }, - firewall: await firewallState(), + firewall, firewall_entrypoint_kind: await detectFirewallEntrypointKind(), tls: await tlsState(), install_state_present: await fileExists(INSTALL_STATE_PATH), install_state: installState, - rollback_guard_active: rollbackGuardUnits.length > 0, - rollback_guard_units: rollbackGuardUnits ? rollbackGuardUnits.split("\n") : [] + rollback_guard_active: rollbackGuardActive, + rollback_guard_units: rollbackGuardUnits ? rollbackGuardUnits.split("\n") : [], + runtime_state: runtimeState, + install_state_effective: installStateEffective, + firewall_state: rollbackGuardActive ? "guard_active" : firewall, + human_status: humanStatus }; info(`status report: ${JSON.stringify(result)}`); } diff --git a/orchestrator/src/lib/redaction.ts b/orchestrator/src/lib/redaction.ts new file mode 100644 index 0000000..1dc3881 --- /dev/null +++ b/orchestrator/src/lib/redaction.ts @@ -0,0 +1,18 @@ +export function redactKeyValueSecrets(content: string): string { + return content.replace( + /^([A-Z0-9_]*(PASSWORD|PASS|SECRET|TOKEN)[A-Z0-9_]*=).*$/gm, + "$1" + ); +} + +export function redactEnv(content: string): string { + return redactKeyValueSecrets(content); +} + +export function redactYaml(content: string): string { + return content + .replace(/(password:\s*).*/gi, "$1") + .replace(/(secret:\s*).*/gi, "$1") + .replace(/(auth:\s*).*/gi, "$1"); +} + diff --git a/orchestrator/src/steps/env.ts b/orchestrator/src/steps/env.ts index 86772c9..9e0aeb6 100644 --- a/orchestrator/src/steps/env.ts +++ b/orchestrator/src/steps/env.ts @@ -1,5 +1,6 @@ import type { RuntimeContext } from "../types/context"; -import { readText, renderTemplate, writeTextAtomic } from "../lib/fs"; +import { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs"; +import { runVisible } from "../lib/process"; export async function writePostInstallEnv(context: RuntimeContext): Promise { const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/env/post-install.env.tpl`), { @@ -54,3 +55,17 @@ export async function writeBootstrapAdminSecret(context: RuntimeContext): Promis } ); } + +export async function ensureBootstrapAdminSecret(context: RuntimeContext): Promise { + const path = context.config.bootstrapAdminSecretPath; + if (!(await fileExists(path))) { + await writeBootstrapAdminSecret(context); + return; + } + + await runVisible`test "$(stat -c '%U:%G' ${path})" = 'root:root'`; + await runVisible`test "$(stat -c '%a' ${path})" = '600'`; + await runVisible`grep -q '^ADMIN_USER=' ${path}`; + await runVisible`grep -q '^ADMIN_INITIAL_PASSWORD=' ${path}`; + await runVisible`grep -q '^ADMIN_CON_PASS=' ${path}`; +} diff --git a/orchestrator/src/steps/firewall.ts b/orchestrator/src/steps/firewall.ts index 3d40067..9e0ca1f 100644 --- a/orchestrator/src/steps/firewall.ts +++ b/orchestrator/src/steps/firewall.ts @@ -100,7 +100,7 @@ export async function applyFirewall(context: RuntimeContext): Promise { const acmeChallengePort = context.config.acmeType === "tls" ? 443 : 80; const acmeRule = context.config.tlsMode === "acme" - ? `tcp dport ${acmeChallengePort} accept` + ? `meta nfproto ipv4 tcp dport ${acmeChallengePort} accept` : "# acme challenge port disabled"; const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/nftables/hy2xs.nft.tpl`), { diff --git a/orchestrator/src/steps/preflight.ts b/orchestrator/src/steps/preflight.ts index 748f96f..7fa8f22 100644 --- a/orchestrator/src/steps/preflight.ts +++ b/orchestrator/src/steps/preflight.ts @@ -1,9 +1,19 @@ import type { RuntimeContext } from "../types/context"; +import { resolve4, resolve6 } from "node:dns/promises"; import { dirExists, fileExists } from "../lib/fs"; import { fail, info } from "../lib/log"; import { run } from "../lib/process"; import { assertPlatform } from "../platform/assert"; +function isNoDnsRecords(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (((error as { code?: string }).code === "ENODATA") || ((error as { code?: string }).code === "ENOTFOUND")) + ); +} + async function isTcpPortListening(port: number): Promise { try { const output = await run`ss -H -ltn`; @@ -114,23 +124,28 @@ export async function preflight(context: RuntimeContext): Promise { } if (context.config.domain) { + let a: string[] = []; try { - const a = await run`getent ahostsv4 ${context.config.domain}`; - if (!a.trim()) { - fail(`domain has no A-record: ${context.config.domain}`); - } + a = await resolve4(context.config.domain); } catch { fail(`domain has no A-record: ${context.config.domain}`); } + if (a.length === 0) { + fail(`domain has no A-record: ${context.config.domain}`); + } + + let aaaa: string[] = []; try { - const aaaa = await run`getent ahostsv6 ${context.config.domain}`; - if (aaaa.trim()) { - fail( - `domain ${context.config.domain} has AAAA record while HY2XS profile is IPv4-only; remove AAAA record before install` - ); + aaaa = await resolve6(context.config.domain); + } catch (error) { + if (!isNoDnsRecords(error)) { + fail(`DNS AAAA lookup failed for ${context.config.domain}: ${String(error)}`); } - } catch { - // no AAAA is acceptable + } + if (aaaa.length > 0) { + fail( + `domain ${context.config.domain} has DNS AAAA record while HY2XS profile is IPv4-only; remove AAAA record before install` + ); } } diff --git a/package/config/hy2xs.env b/package/config/hy2xs.env index 1c93bbb..3fd0f8c 100644 --- a/package/config/hy2xs.env +++ b/package/config/hy2xs.env @@ -4,7 +4,7 @@ HY2XS_DOMAIN=uk.api.withen.pro HY2XS_PUBLIC_HOST=uk.api.withen.pro HY2XS_PUBLIC_PORT=443 HY2XS_SSH_PORT=22 -HY2XS_FIREWALL_MODE=managed +HY2XS_FIREWALL_MODE=takeover HY2XS_FIREWALL_STAGED_APPLY=true HY2XS_UI_BIND_HOST=127.0.0.1 HY2XS_UI_PUBLIC_ACCESS=false @@ -12,7 +12,7 @@ HY2XS_UI_PORT=8080 HY2XS_ADMIN_USER=hy2xsadmin HY2XS_ADMIN_INITIAL_PASSWORD=__GENERATE__ HY2XS_ADMIN_CON_PASS=__GENERATE__ -HY2XS_FORCE_PASSWORD_CHANGE=false +HY2XS_FORCE_PASSWORD_CHANGE=true HY2XS_ALLOW_SELF_SIGNED_DEV=false HY2XS_TLS_MODE=acme HY2XS_ACME_TYPE=http diff --git a/package/templates/nftables/hy2xs.nft.tpl b/package/templates/nftables/hy2xs.nft.tpl index 7e18fe2..c263d73 100644 --- a/package/templates/nftables/hy2xs.nft.tpl +++ b/package/templates/nftables/hy2xs.nft.tpl @@ -1,12 +1,12 @@ -table ip hy2xs { +table inet hy2xs { chain input { type filter hook input priority 0; policy drop; iif lo accept ct state established,related accept - tcp dport {{SSH_PORT}} accept + meta nfproto ipv4 tcp dport {{SSH_PORT}} accept {{ACME_RULE}} - udp dport {{HYSTERIA_PORT}} accept - icmp type echo-request accept + meta nfproto ipv4 udp dport {{HYSTERIA_PORT}} accept + meta nfproto ipv4 icmp type echo-request accept } }