fix(fix3): runtime env hardening and public endpoint source-of-truth

This commit is contained in:
2026-04-28 04:45:43 +05:00
parent 96d9bbcece
commit 12c65c8e31
23 changed files with 131 additions and 238 deletions
+6 -4
View File
@@ -21,7 +21,8 @@ function takeValue(args: string[], index: number, flag: string): string {
function parseInstallOptions(args: string[]): InstallOptions {
const options: InstallOptions = {
packageDir: "",
configPath: "/etc/hy2xs/hy2xs.env",
sourceConfigPath: "",
runtimeConfigPath: "/etc/hy2xs/hy2xs.env",
nonInteractive: false,
skipFirewall: false,
skipStart: false
@@ -44,7 +45,7 @@ function parseInstallOptions(args: string[]): InstallOptions {
options.skipStart = true;
break;
case "--config":
options.configPath = takeValue(args, i, arg);
options.sourceConfigPath = takeValue(args, i, arg);
i += 1;
break;
default:
@@ -64,7 +65,8 @@ function parseInstallOptions(args: string[]): InstallOptions {
function parseReconfigureOptions(args: string[]): ReconfigureOptions {
const options: ReconfigureOptions = {
packageDir: "",
configPath: "/etc/hy2xs/hy2xs.env",
sourceConfigPath: "/etc/hy2xs/hy2xs.env",
runtimeConfigPath: "/etc/hy2xs/hy2xs.env",
nonInteractive: false,
dryRun: false,
apply: false,
@@ -80,7 +82,7 @@ function parseReconfigureOptions(args: string[]): ReconfigureOptions {
i += 1;
break;
case "--config":
options.configPath = takeValue(args, i, arg);
options.sourceConfigPath = takeValue(args, i, arg);
i += 1;
break;
case "--dry-run":
+8 -7
View File
@@ -28,8 +28,11 @@ function secret(): string {
}
export async function install(options: InstallOptions): Promise<void> {
const hasConfig = await exists(options.configPath);
const sourceConfigPath = hasConfig ? options.configPath : `${options.packageDir}/config/hy2xs.env`;
const hasSourceConfig = options.sourceConfigPath ? await exists(options.sourceConfigPath) : false;
if (options.sourceConfigPath && !hasSourceConfig) {
throw new Error(`config source not found: ${options.sourceConfigPath}`);
}
const sourceConfigPath = hasSourceConfig ? options.sourceConfigPath : `${options.packageDir}/config/hy2xs.env`;
const sourceConfigRaw = await readText(sourceConfigPath);
const config = parseRuntimeEnv(sourceConfigRaw);
@@ -49,11 +52,9 @@ export async function install(options: InstallOptions): Promise<void> {
await installDeps(context);
step("filesystem");
await prepareFilesystem(context);
if (!hasConfig) {
step("write runtime env");
await runVisible`mkdir -p /etc/hy2xs`;
await writeText(options.configPath, renderRuntimeEnv(config), 0o600);
}
step("write runtime env");
await runVisible`mkdir -p /etc/hy2xs`;
await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600);
step("bundled UI");
await deployUi(context);
step("Hysteria2 upstream install");
+4 -3
View File
@@ -28,7 +28,7 @@ async function rollbackCurrentState(): Promise<void> {
}
export async function reconfigure(options: ReconfigureOptions): Promise<void> {
const configRaw = await readText(options.configPath);
const configRaw = await readText(options.sourceConfigPath);
const config = parseRuntimeEnv(configRaw);
const context: ReconfigureContext & { packageVersion: string; packageBuildId: string; installDate: string; hysteriaAuthPassword: string; hysteriaVersion: string } = {
@@ -46,7 +46,8 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
if (options.dryRun) {
info("reconfigure dry-run: validated config and execution graph");
info(`config file: ${options.configPath}`);
info(`config source: ${options.sourceConfigPath}`);
info(`runtime file: ${options.runtimeConfigPath}`);
info(`ui bind: ${config.uiBindHost}:${config.uiPort}`);
info(`hysteria bind: ${config.hysteriaBindHost}:${config.hysteriaPort}`);
info(`public endpoint: ${config.publicHost}:${config.publicPort}`);
@@ -64,7 +65,7 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
step("firewall");
await applyFirewall(context);
step("write env artifacts");
await writeText(options.configPath, renderRuntimeEnv(config), 0o600);
await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600);
await writePostInstallEnv(context);
step("smoke checks");
await smoke(context);
+10 -3
View File
@@ -7,6 +7,13 @@ function randomSecret(): string {
return randomBytes(24).toString("base64url");
}
function valueOrGenerate(value: string | undefined): string {
if (!value || value === "__GENERATE__") {
return randomSecret();
}
return value;
}
function parseEnv(content: string): EnvMap {
const result: EnvMap = {};
for (const rawLine of content.split(/\r?\n/)) {
@@ -111,7 +118,7 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
uiBindHost,
uiPort,
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"),
adminInitialPassword: env.HY2XS_ADMIN_INITIAL_PASSWORD || randomSecret(),
adminInitialPassword: valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD),
forcePasswordChange: parseBool("HY2XS_FORCE_PASSWORD_CHANGE", env.HY2XS_FORCE_PASSWORD_CHANGE, true),
tlsMode,
acmeType,
@@ -127,9 +134,9 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
env.HY2XS_HYSTERIA_TRAFFIC_STATS_HOST || "127.0.0.1"
),
hysteriaTrafficStatsPort: trafficStatsPort,
hysteriaTrafficStatsSecret: env.HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET || randomSecret(),
hysteriaTrafficStatsSecret: valueOrGenerate(env.HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET),
hysteriaObfsType: "salamander",
hysteriaObfsPassword: requireValue("HY2XS_HYSTERIA_OBFS_PASSWORD", env.HY2XS_HYSTERIA_OBFS_PASSWORD || randomSecret()),
hysteriaObfsPassword: requireValue("HY2XS_HYSTERIA_OBFS_PASSWORD", valueOrGenerate(env.HY2XS_HYSTERIA_OBFS_PASSWORD)),
hysteriaBandwidthUp: env.HY2XS_HYSTERIA_BANDWIDTH_UP || "50 mbps",
hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps",
hysteriaIgnoreClientBandwidth: parseBool(
+1 -1
View File
@@ -4,7 +4,7 @@ import { runVisible } from "../lib/process";
export async function generateConfig(context: InstallContext): Promise<void> {
const tlsAcmeBlock = context.config.tlsMode === "acme"
? `acme:\n domains:\n - ${context.config.domain}\n email: ${context.config.acmeEmail}\n ca: letsencrypt\n dir: /var/lib/hysteria/acme\n listenHost: 0.0.0.0`
? `acme:\n domains:\n - ${context.config.domain}\n email: ${context.config.acmeEmail}\n ca: letsencrypt\n dir: /var/lib/hysteria/acme\n listenHost: 0.0.0.0\n type: ${context.config.acmeType}`
: "";
const tlsFileBlock = context.config.tlsMode === "file" || context.config.tlsMode === "self_signed_dev"
? `tls:\n cert: ${context.config.tlsCertPath}\n key: ${context.config.tlsKeyPath}`
+26 -4
View File
@@ -12,6 +12,15 @@ async function isPortBusy(port: number): Promise<boolean> {
}
}
async function isUnitActive(unit: string): Promise<boolean> {
try {
await run`systemctl is-active --quiet ${unit}`;
return true;
} catch {
return false;
}
}
export async function preflight(context: InstallContext): Promise<void> {
const isReconfigure = context.packageVersion === "reconfigure";
@@ -76,11 +85,24 @@ export async function preflight(context: InstallContext): Promise<void> {
}
}
if (await isPortBusy(context.config.hysteriaPort)) {
fail(`Hysteria UDP/TCP port already appears to be in use: ${context.config.hysteriaPort}`);
const hysteriaPortBusy = await isPortBusy(context.config.hysteriaPort);
const uiPortBusy = await isPortBusy(context.config.uiPort);
if (!isReconfigure) {
if (hysteriaPortBusy) {
fail(`Hysteria UDP/TCP port already appears to be in use: ${context.config.hysteriaPort}`);
}
if (uiPortBusy) {
fail(`HY2XS admin port already appears to be in use: ${context.config.uiPort}`);
}
return;
}
if (await isPortBusy(context.config.uiPort)) {
fail(`HY2XS admin port already appears to be in use: ${context.config.uiPort}`);
if (hysteriaPortBusy && !(await isUnitActive("hysteria-server"))) {
fail(`Hysteria port ${context.config.hysteriaPort} is occupied by a non-HY2XS process`);
}
if (uiPortBusy && !(await isUnitActive("hy2xs-admin"))) {
fail(`HY2XS admin port ${context.config.uiPort} is occupied by a non-HY2XS process`);
}
}
+11 -2
View File
@@ -16,6 +16,7 @@ export async function smoke(context: InstallContext): Promise<void> {
await runVisible`test -s /etc/hy2xs/hy2xs.env`;
await runVisible`test -s /etc/hysteria/post-install.env`;
await runVisible`test -s ${context.config.bootstrapAdminSecretPath}`;
await runVisible`grep -q '^${context.config.adminUser}:' ${context.config.bootstrapAdminSecretPath}`;
await runVisible`test "$(stat -c '%a' /etc/hysteria/config.yaml)" = '600'`;
await runVisible`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`;
await runVisible`test "$(stat -c '%a' /etc/hysteria/post-install.env)" = '600'`;
@@ -26,10 +27,18 @@ export async function smoke(context: InstallContext): Promise<void> {
}
await runVisible`ss -H -lun | grep -q '0.0.0.0:${context.config.hysteriaPort} '`;
await runVisible`! ss -H -ltnu | grep -q '\[::\]'`;
await runHidden`curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' http://127.0.0.1:${context.config.uiPort}/hui/hysteria2/auth >/dev/null`;
const invalidAuthResponse = await runSecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' http://127.0.0.1:${context.config.uiPort}/hui/hysteria2/auth`;
if (!/"ok"\s*:\s*false/.test(invalidAuthResponse)) {
throw new Error(`unexpected auth response for invalid credentials: ${invalidAuthResponse}`);
}
const validAuthResponse = await runSecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"${context.config.adminUser}.${context.config.adminInitialPassword}","tx":"0"}' http://127.0.0.1:${context.config.uiPort}/hui/hysteria2/auth`;
if (!/"ok"\s*:\s*true/.test(validAuthResponse)) {
throw new Error(`unexpected auth response for valid credentials`);
}
await runHidden`curl -fsS --max-time 5 -H 'Authorization: ${context.config.hysteriaTrafficStatsSecret}' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online >/dev/null`;
const deniedCode = await runSecret`curl -fsS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: invalid-hy2xs-secret' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`;
const deniedCode = await runSecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: invalid-hy2xs-secret' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`;
if (!/(401|403)/.test(deniedCode)) {
throw new Error(`unexpected trafficStats status for invalid secret: ${deniedCode}`);
}
+4 -2
View File
@@ -1,6 +1,7 @@
export type InstallOptions = {
packageDir: string;
configPath: string;
sourceConfigPath: string;
runtimeConfigPath: string;
nonInteractive: boolean;
skipFirewall: boolean;
skipStart: boolean;
@@ -8,7 +9,8 @@ export type InstallOptions = {
export type ReconfigureOptions = {
packageDir: string;
configPath: string;
sourceConfigPath: string;
runtimeConfigPath: string;
nonInteractive: boolean;
dryRun: boolean;
apply: boolean;