Production hardening: host-aware install, ownership contracts, firewall safety
This commit is contained in:
@@ -3,6 +3,7 @@ import type { InstallContext, InstallOptions } from "../types/context";
|
||||
import { exists, readText, writeText } from "../lib/fs";
|
||||
import { runVisible } from "../lib/process";
|
||||
import { step } from "../lib/log";
|
||||
import { readPackageValue } from "../lib/packageMeta";
|
||||
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
|
||||
import { preflight } from "../steps/preflight";
|
||||
import { installDeps } from "../steps/deps";
|
||||
@@ -15,14 +16,6 @@ import { applyFirewall } from "../steps/firewall";
|
||||
import { writePostInstallEnv } from "../steps/env";
|
||||
import { smoke } from "../steps/smoke";
|
||||
|
||||
async function readPackageValue(packageDir: string, file: string, fallback: string): Promise<string> {
|
||||
try {
|
||||
return (await readText(`${packageDir}/metadata/${file}`)).trim();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function secret(): string {
|
||||
return randomBytes(24).toString("base64url");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { applyFirewall } from "../steps/firewall";
|
||||
import { writePostInstallEnv } from "../steps/env";
|
||||
import { smoke } from "../steps/smoke";
|
||||
import { runVisible } from "../lib/process";
|
||||
import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta";
|
||||
|
||||
async function backupCurrentState(): Promise<void> {
|
||||
await runVisible`mkdir -p /etc/hy2xs/backups`;
|
||||
@@ -48,11 +49,11 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
|
||||
const context: ReconfigureContext & { packageVersion: string; packageBuildId: string; installDate: string; hysteriaAuthPassword: string; hysteriaVersion: string } = {
|
||||
options,
|
||||
config,
|
||||
packageVersion: "reconfigure",
|
||||
packageBuildId: "reconfigure",
|
||||
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
|
||||
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
|
||||
installDate: new Date().toISOString(),
|
||||
hysteriaAuthPassword: "managed-by-ui-auth",
|
||||
hysteriaVersion: "unknown"
|
||||
hysteriaVersion: await readInstalledHysteriaVersion()
|
||||
};
|
||||
|
||||
step("preflight");
|
||||
|
||||
@@ -115,7 +115,9 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
|
||||
sshPort: parsePort("HY2XS_SSH_PORT", env.HY2XS_SSH_PORT, 22),
|
||||
firewallEnabled: parseBool("HY2XS_FIREWALL_ENABLED", env.HY2XS_FIREWALL_ENABLED, true),
|
||||
firewallStagedApply: parseBool("HY2XS_FIREWALL_STAGED_APPLY", env.HY2XS_FIREWALL_STAGED_APPLY, true),
|
||||
firewallAllowTakeover: parseBool("HY2XS_FIREWALL_ALLOW_TAKEOVER", env.HY2XS_FIREWALL_ALLOW_TAKEOVER, false),
|
||||
uiBindHost,
|
||||
uiPublicAccess: parseBool("HY2XS_UI_PUBLIC_ACCESS", env.HY2XS_UI_PUBLIC_ACCESS, false),
|
||||
uiPort,
|
||||
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"),
|
||||
adminInitialPassword: valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD),
|
||||
@@ -146,7 +148,8 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
|
||||
false
|
||||
),
|
||||
hysteriaConfigPath: env.HY2XS_HYSTERIA_CONFIG_PATH || "/etc/hysteria/config.yaml",
|
||||
hysteriaVersionPolicy: env.HY2XS_HYSTERIA_VERSION || "latest",
|
||||
allowLatestHysteria: parseBool("HY2XS_ALLOW_LATEST_HYSTERIA", env.HY2XS_ALLOW_LATEST_HYSTERIA, false),
|
||||
hysteriaVersionPolicy: env.HY2XS_HYSTERIA_VERSION || "v2.6.0",
|
||||
installDir: env.HY2XS_INSTALL_DIR || "/opt/hy2xs-admin",
|
||||
dataDir: env.HY2XS_DATA_DIR || "/var/lib/hy2xs-admin",
|
||||
logDir: env.HY2XS_LOG_DIR || "/var/log/hy2xs",
|
||||
@@ -170,6 +173,12 @@ export function validateRuntimeConfig(config: RuntimeConfig): void {
|
||||
if (config.hysteriaBindHost !== "0.0.0.0") {
|
||||
throw new Error("HY2XS_HYSTERIA_BIND_HOST must be 0.0.0.0 in production profile");
|
||||
}
|
||||
if (!config.uiPublicAccess && config.uiBindHost !== "127.0.0.1") {
|
||||
throw new Error("HY2XS UI must bind to 127.0.0.1 in production baseline");
|
||||
}
|
||||
if (config.hysteriaConfigPath !== "/etc/hysteria/config.yaml") {
|
||||
throw new Error("HY2XS_HYSTERIA_CONFIG_PATH is fixed in production baseline: /etc/hysteria/config.yaml");
|
||||
}
|
||||
|
||||
if (config.tlsMode === "acme") {
|
||||
if (!config.domain) {
|
||||
@@ -205,7 +214,9 @@ export function renderRuntimeEnv(config: RuntimeConfig): string {
|
||||
`HY2XS_SSH_PORT=${config.sshPort}`,
|
||||
`HY2XS_FIREWALL_ENABLED=${config.firewallEnabled}`,
|
||||
`HY2XS_FIREWALL_STAGED_APPLY=${config.firewallStagedApply}`,
|
||||
`HY2XS_FIREWALL_ALLOW_TAKEOVER=${config.firewallAllowTakeover}`,
|
||||
`HY2XS_UI_BIND_HOST=${config.uiBindHost}`,
|
||||
`HY2XS_UI_PUBLIC_ACCESS=${config.uiPublicAccess}`,
|
||||
`HY2XS_UI_PORT=${config.uiPort}`,
|
||||
`HY2XS_ADMIN_USER=${config.adminUser}`,
|
||||
`HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`,
|
||||
@@ -229,6 +240,7 @@ export function renderRuntimeEnv(config: RuntimeConfig): string {
|
||||
`HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`,
|
||||
`HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=${config.hysteriaIgnoreClientBandwidth}`,
|
||||
`HY2XS_HYSTERIA_CONFIG_PATH=${config.hysteriaConfigPath}`,
|
||||
`HY2XS_ALLOW_LATEST_HYSTERIA=${config.allowLatestHysteria}`,
|
||||
`HY2XS_HYSTERIA_VERSION=${config.hysteriaVersionPolicy}`,
|
||||
`HY2XS_INSTALL_DIR=${config.installDir}`,
|
||||
`HY2XS_DATA_DIR=${config.dataDir}`,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readText } from "./fs";
|
||||
import { run } from "./process";
|
||||
|
||||
export async function readPackageValue(packageDir: string, file: string, fallback: string): Promise<string> {
|
||||
try {
|
||||
return (await readText(`${packageDir}/metadata/${file}`)).trim();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readInstalledHysteriaVersion(): Promise<string> {
|
||||
try {
|
||||
const raw = await run`/usr/local/bin/hysteria version`;
|
||||
const match = raw.match(/v\d+\.\d+\.\d+/);
|
||||
return match ? match[0] : raw.trim();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -28,15 +28,21 @@ export async function generateConfig(context: InstallContext): Promise<void> {
|
||||
AUTH_INSECURE: context.config.tlsMode === "self_signed_dev" ? "true" : "false"
|
||||
});
|
||||
|
||||
await writeText("/etc/hysteria/config.yaml.tmp", rendered, 0o600);
|
||||
await runVisible`mv /etc/hysteria/config.yaml.tmp /etc/hysteria/config.yaml`;
|
||||
const configPath = context.config.hysteriaConfigPath;
|
||||
const tmpPath = `${configPath}.tmp`;
|
||||
await runVisible`mkdir -p /etc/hysteria`;
|
||||
await writeText(tmpPath, rendered, 0o600);
|
||||
await runVisible`chown hysteria:hy2xs-admin ${tmpPath}`;
|
||||
await runVisible`chmod 0640 ${tmpPath}`;
|
||||
await runVisible`mv ${tmpPath} ${configPath}`;
|
||||
|
||||
if (context.config.tlsMode === "self_signed_dev") {
|
||||
await runVisible`openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj /CN=${context.config.domain || "hy2xs.local"} -keyout ${context.config.tlsKeyPath} -out ${context.config.tlsCertPath}`;
|
||||
await runVisible`chmod 600 ${context.config.tlsKeyPath} ${context.config.tlsCertPath}`;
|
||||
}
|
||||
|
||||
await runVisible`chown hysteria:hysteria /etc/hysteria/config.yaml`;
|
||||
await runVisible`chown hysteria:hy2xs-admin ${configPath}`;
|
||||
await runVisible`chmod 0640 ${configPath}`;
|
||||
if (context.config.tlsMode !== "acme") {
|
||||
await runVisible`chown hysteria:hysteria ${context.config.tlsKeyPath} ${context.config.tlsCertPath}`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export async function writePostInstallEnv(context: InstallContext): Promise<void
|
||||
const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/env/post-install.env.tpl`), {
|
||||
PACKAGE_VERSION: context.packageVersion,
|
||||
PACKAGE_BUILD_ID: context.packageBuildId,
|
||||
INSTALL_DATE: context.installDate,
|
||||
LAST_APPLY_DATE: context.installDate,
|
||||
DOMAIN: context.config.domain,
|
||||
PUBLIC_HOST: context.config.publicHost,
|
||||
PUBLIC_PORT: context.config.publicPort,
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
import type { InstallContext } from "../types/context";
|
||||
import { readText, renderTemplate, writeText } from "../lib/fs";
|
||||
import { info } from "../lib/log";
|
||||
import { exists, readText, renderTemplate, writeText } from "../lib/fs";
|
||||
import { fail, info } from "../lib/log";
|
||||
import { runVisible } from "../lib/process";
|
||||
|
||||
function stripNftComments(content: string): string {
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/#.*/, "").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function isSafeNftablesEntrypoint(content: string): boolean {
|
||||
if (content.includes("HY2XS-MANAGED")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const effective = stripNftComments(content)
|
||||
.replace(/^#!\/usr\/sbin\/nft\s+-f\s*/m, "")
|
||||
.trim();
|
||||
|
||||
if (!effective) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return effective === "flush ruleset";
|
||||
}
|
||||
|
||||
export async function applyFirewall(context: InstallContext): Promise<void> {
|
||||
if (context.options.skipFirewall || !context.config.firewallEnabled) {
|
||||
info("firewall skipped by flag");
|
||||
@@ -17,11 +41,17 @@ export async function applyFirewall(context: InstallContext): Promise<void> {
|
||||
const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/nftables/hy2xs.nft.tpl`), {
|
||||
SSH_PORT: context.config.sshPort,
|
||||
HYSTERIA_PORT: context.config.hysteriaPort,
|
||||
UI_PORT: context.config.uiPort,
|
||||
UI_BIND_HOST: context.config.uiBindHost,
|
||||
ACME_RULE: acmeRule
|
||||
});
|
||||
|
||||
const existing = await exists("/etc/nftables.conf")
|
||||
? await readText("/etc/nftables.conf")
|
||||
: "";
|
||||
|
||||
if (existing && !isSafeNftablesEntrypoint(existing) && !context.config.firewallAllowTakeover) {
|
||||
fail("existing non-HY2XS nftables.conf found; set HY2XS_FIREWALL_ALLOW_TAKEOVER=true or HY2XS_FIREWALL_ENABLED=false");
|
||||
}
|
||||
|
||||
await runVisible`cp -a /etc/nftables.conf /etc/nftables.conf.hy2xs.bak 2>/dev/null || true`;
|
||||
await runVisible`cp -a /etc/nftables.d/hy2xs.nft /etc/nftables.d/hy2xs.nft.bak 2>/dev/null || true`;
|
||||
await runVisible`test -f /etc/nftables.d/hy2xs.nft && echo 1 > /etc/nftables.d/hy2xs.nft.existed || rm -f /etc/nftables.d/hy2xs.nft.existed`;
|
||||
@@ -30,6 +60,9 @@ export async function applyFirewall(context: InstallContext): Promise<void> {
|
||||
await runVisible`nft -c -f /etc/nftables.d/hy2xs.nft.candidate`;
|
||||
|
||||
const nftablesConf = `#!/usr/sbin/nft -f
|
||||
# HY2XS-MANAGED: root nftables entrypoint
|
||||
# Generated by hy2xs-orchestrator. Do not edit manually; edit /etc/hy2xs/hy2xs.env and run reconfigure.
|
||||
|
||||
flush ruleset
|
||||
|
||||
include "/etc/nftables.d/hy2xs.nft"
|
||||
|
||||
@@ -10,9 +10,6 @@ function normalizeInstalledVersion(raw: string): string {
|
||||
}
|
||||
|
||||
function validateVersionPolicy(value: string): void {
|
||||
if (value === "latest") {
|
||||
return;
|
||||
}
|
||||
if (/^v\d+\.\d+\.\d+$/.test(value)) {
|
||||
return;
|
||||
}
|
||||
@@ -21,6 +18,9 @@ function validateVersionPolicy(value: string): void {
|
||||
|
||||
export async function installHysteria(context: InstallContext): Promise<void> {
|
||||
const policy = context.config.hysteriaVersionPolicy;
|
||||
if (policy === "latest" && !context.config.allowLatestHysteria) {
|
||||
throw new Error("HY2XS_HYSTERIA_VERSION=latest is not allowed in production; pin vX.Y.Z or set HY2XS_ALLOW_LATEST_HYSTERIA=true");
|
||||
}
|
||||
validateVersionPolicy(policy);
|
||||
|
||||
const scriptPath = "/tmp/hy2xs-install-hysteria.sh";
|
||||
|
||||
@@ -19,10 +19,17 @@ export async function smoke(context: InstallContext): Promise<void> {
|
||||
await runVisible`grep -q '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath}`;
|
||||
await runVisible`grep -q '^ADMIN_INITIAL_PASSWORD=' ${context.config.bootstrapAdminSecretPath}`;
|
||||
await runVisible`grep -q '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath}`;
|
||||
await runVisible`test "$(stat -c '%a' /etc/hysteria/config.yaml)" = '600'`;
|
||||
await runVisible`test "$(stat -c '%a' /etc/hysteria/config.yaml)" = '640'`;
|
||||
await runVisible`test "$(stat -c '%U:%G' /etc/hysteria/config.yaml)" = 'hysteria:hy2xs-admin'`;
|
||||
await runVisible`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`;
|
||||
await runVisible`test "$(stat -c '%U:%G' /etc/hy2xs/hy2xs.env)" = 'root:root'`;
|
||||
await runVisible`test "$(stat -c '%a' /etc/hysteria/post-install.env)" = '600'`;
|
||||
await runVisible`test "$(stat -c '%U:%G' /etc/hysteria/post-install.env)" = 'root:root'`;
|
||||
await runVisible`test "$(stat -c '%a' ${context.config.bootstrapAdminSecretPath})" = '600'`;
|
||||
await runVisible`test "$(stat -c '%U:%G' ${context.config.bootstrapAdminSecretPath})" = 'root:root'`;
|
||||
await runVisible`sudo -u hysteria test -r /etc/hysteria/config.yaml`;
|
||||
await runVisible`sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml`;
|
||||
await runVisible`sudo -u hy2xs-admin test ! -w /etc/hysteria/config.yaml`;
|
||||
await runVisible`ss -H -ltn | grep -q '${context.config.uiBindHost}:${context.config.uiPort} '`;
|
||||
if (context.config.uiBindHost === "127.0.0.1") {
|
||||
await runVisible`! ss -H -ltn | grep -q '0.0.0.0:${context.config.uiPort} '`;
|
||||
|
||||
@@ -28,7 +28,9 @@ export type RuntimeConfig = {
|
||||
sshPort: number;
|
||||
firewallEnabled: boolean;
|
||||
firewallStagedApply: boolean;
|
||||
firewallAllowTakeover: boolean;
|
||||
uiBindHost: string;
|
||||
uiPublicAccess: boolean;
|
||||
uiPort: number;
|
||||
adminUser: string;
|
||||
adminInitialPassword: string;
|
||||
@@ -52,6 +54,7 @@ export type RuntimeConfig = {
|
||||
hysteriaBandwidthDown: string;
|
||||
hysteriaIgnoreClientBandwidth: boolean;
|
||||
hysteriaConfigPath: string;
|
||||
allowLatestHysteria: boolean;
|
||||
hysteriaVersionPolicy: string;
|
||||
installDir: string;
|
||||
dataDir: string;
|
||||
|
||||
Reference in New Issue
Block a user