Продакшн-фиксы install/reconfigure: rollback, firewall lifecycle, preflight и runbook
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { install } from "./commands/install";
|
||||
import { reconfigure } from "./commands/reconfigure";
|
||||
import { doctor } from "./commands/doctor";
|
||||
import type { InstallOptions, ReconfigureOptions } from "./types/context";
|
||||
|
||||
function usage(): never {
|
||||
console.error("Usage:");
|
||||
console.error(" hy2xs-orchestrator install --package-dir <path> [--config <path>] [--skip-firewall] [--skip-start] [--non-interactive]");
|
||||
console.error(" hy2xs-orchestrator reconfigure --package-dir <path> [--config <path>] [--dry-run|--apply] [--skip-firewall] [--skip-start]");
|
||||
console.error(" hy2xs-orchestrator doctor --package-dir <path> [--config <path>] [--skip-firewall] [--skip-start]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
@@ -126,6 +128,11 @@ async function main(): Promise<void> {
|
||||
await reconfigure(parseReconfigureOptions(args));
|
||||
return;
|
||||
}
|
||||
if (command === "doctor") {
|
||||
const options = parseReconfigureOptions(["--dry-run", ...args]);
|
||||
await doctor(options);
|
||||
return;
|
||||
}
|
||||
usage();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReconfigureContext, ReconfigureOptions } from "../types/context";
|
||||
import { readText } from "../lib/fs";
|
||||
import { step } from "../lib/log";
|
||||
import { parseRuntimeEnv } from "../config/env";
|
||||
import { preflight } from "../steps/preflight";
|
||||
import { smoke } from "../steps/smoke";
|
||||
import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta";
|
||||
|
||||
export async function doctor(options: ReconfigureOptions): Promise<void> {
|
||||
const configRaw = await readText(options.sourceConfigPath);
|
||||
const config = parseRuntimeEnv(configRaw);
|
||||
|
||||
const context: ReconfigureContext = {
|
||||
mode: "reconfigure",
|
||||
options: { ...options, dryRun: true, apply: false },
|
||||
config,
|
||||
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
|
||||
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
|
||||
installDate: new Date().toISOString(),
|
||||
hysteriaVersion: await readInstalledHysteriaVersion()
|
||||
};
|
||||
|
||||
step("doctor preflight");
|
||||
await preflight(context);
|
||||
step("doctor smoke");
|
||||
await smoke(context);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,35 @@ import { deployUi } from "../steps/ui";
|
||||
import { installHysteria } from "../steps/hysteria";
|
||||
import { generateConfig } from "../steps/config";
|
||||
import { deploySystemd } from "../steps/systemd";
|
||||
import { applyFirewall } from "../steps/firewall";
|
||||
import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall";
|
||||
import { writeBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
|
||||
import { smoke } from "../steps/smoke";
|
||||
|
||||
const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json";
|
||||
|
||||
async function markInstallSuccessful(context: InstallContext): Promise<void> {
|
||||
await runVisible`install -d -m 0755 -o root -g root /var/lib/hy2xs`;
|
||||
const state = JSON.stringify(
|
||||
{
|
||||
installed: true,
|
||||
version: context.packageVersion,
|
||||
build_id: context.packageBuildId,
|
||||
installed_at: new Date().toISOString()
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
await writeText(INSTALL_STATE_PATH, `${state}\n`, 0o644);
|
||||
await runVisible`chown root:root ${INSTALL_STATE_PATH}`;
|
||||
}
|
||||
|
||||
async function rollbackFailedInstall(context: InstallContext): Promise<void> {
|
||||
await rollbackFirewallNow(context);
|
||||
await runVisible`systemctl stop hysteria-server hy2xs-admin || true`;
|
||||
await runVisible`systemctl disable hysteria-server hy2xs-admin || true`;
|
||||
await runVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`;
|
||||
}
|
||||
|
||||
export async function install(options: InstallOptions): Promise<void> {
|
||||
const hasSourceConfig = options.sourceConfigPath ? await exists(options.sourceConfigPath) : false;
|
||||
if (options.sourceConfigPath && !hasSourceConfig) {
|
||||
@@ -41,31 +66,40 @@ export async function install(options: InstallOptions): Promise<void> {
|
||||
throw new Error("missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256");
|
||||
}
|
||||
|
||||
step("preflight");
|
||||
await preflight(context);
|
||||
step("system dependencies");
|
||||
await installDeps(context);
|
||||
step("filesystem");
|
||||
await prepareFilesystem(context);
|
||||
step("write runtime env");
|
||||
await runVisible`mkdir -p /etc/hy2xs`;
|
||||
await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600);
|
||||
await runVisible`chown root:root ${options.runtimeConfigPath}`;
|
||||
await runVisible`chmod 0600 ${options.runtimeConfigPath}`;
|
||||
step("bundled UI");
|
||||
await deployUi(context);
|
||||
step("Hysteria2 upstream install");
|
||||
await installHysteria(context);
|
||||
step("config generation");
|
||||
await generateConfig(context);
|
||||
step("systemd units");
|
||||
await deploySystemd(context);
|
||||
step("firewall");
|
||||
await applyFirewall(context);
|
||||
step("post-install env");
|
||||
await writePostInstallEnv(context);
|
||||
step("bootstrap admin secret");
|
||||
await writeBootstrapAdminSecret(context);
|
||||
step("smoke checks");
|
||||
await smoke(context);
|
||||
try {
|
||||
step("preflight");
|
||||
await preflight(context);
|
||||
step("system dependencies");
|
||||
await installDeps(context);
|
||||
step("filesystem");
|
||||
await prepareFilesystem(context);
|
||||
step("write runtime env");
|
||||
await runVisible`mkdir -p /etc/hy2xs`;
|
||||
await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600);
|
||||
await runVisible`chown root:root ${options.runtimeConfigPath}`;
|
||||
await runVisible`chmod 0600 ${options.runtimeConfigPath}`;
|
||||
step("bundled UI");
|
||||
await deployUi(context);
|
||||
step("Hysteria2 upstream install");
|
||||
await installHysteria(context);
|
||||
step("config generation");
|
||||
await generateConfig(context);
|
||||
step("systemd units");
|
||||
await deploySystemd(context);
|
||||
step("firewall");
|
||||
await applyFirewall(context);
|
||||
step("post-install env");
|
||||
await writePostInstallEnv(context);
|
||||
step("bootstrap admin secret");
|
||||
await writeBootstrapAdminSecret(context);
|
||||
step("smoke checks");
|
||||
await smoke(context);
|
||||
step("finalize firewall rollback guard");
|
||||
await cancelFirewallRollback(context);
|
||||
step("mark install successful");
|
||||
await markInstallSuccessful(context);
|
||||
} catch (error) {
|
||||
await rollbackFailedInstall(context);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import type { ReconfigureContext, ReconfigureOptions } from "../types/context";
|
||||
import { readText, writeText } from "../lib/fs";
|
||||
import { exists, readText, writeText } from "../lib/fs";
|
||||
import { info, step } from "../lib/log";
|
||||
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
|
||||
import { preflight } from "../steps/preflight";
|
||||
import { generateConfig } from "../steps/config";
|
||||
import { deploySystemd } from "../steps/systemd";
|
||||
import { applyFirewall } from "../steps/firewall";
|
||||
import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall";
|
||||
import { writePostInstallEnv } from "../steps/env";
|
||||
import { smoke } from "../steps/smoke";
|
||||
import { runVisible } from "../lib/process";
|
||||
import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta";
|
||||
|
||||
const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json";
|
||||
|
||||
type InstallState = {
|
||||
installed?: boolean;
|
||||
};
|
||||
|
||||
async function backupCurrentState(): Promise<void> {
|
||||
await runVisible`mkdir -p /etc/hy2xs/backups`;
|
||||
await runVisible`cp -a /etc/hysteria/config.yaml /etc/hy2xs/backups/config.yaml.bak 2>/dev/null || true`;
|
||||
@@ -42,6 +48,36 @@ async function rollbackCurrentState(): Promise<void> {
|
||||
await runVisible`systemctl restart hysteria-server hy2xs-admin || true`;
|
||||
}
|
||||
|
||||
async function ensureInstallStateExists(): Promise<void> {
|
||||
if (!(await exists(INSTALL_STATE_PATH))) {
|
||||
throw new Error(`install state marker is missing: ${INSTALL_STATE_PATH}. Run install first.`);
|
||||
}
|
||||
|
||||
const raw = await readText(INSTALL_STATE_PATH);
|
||||
let parsed: InstallState;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as InstallState;
|
||||
} catch {
|
||||
throw new Error(`invalid install state marker format: ${INSTALL_STATE_PATH}`);
|
||||
}
|
||||
if (!parsed.installed) {
|
||||
throw new Error(`install state marker does not indicate successful installation: ${INSTALL_STATE_PATH}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function warnBootstrapDrift(nextConfigRaw: string): Promise<void> {
|
||||
if (!(await exists("/etc/hy2xs/hy2xs.env"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = parseRuntimeEnv(await readText("/etc/hy2xs/hy2xs.env"));
|
||||
const next = parseRuntimeEnv(nextConfigRaw);
|
||||
if (prev.adminInitialPassword !== next.adminInitialPassword || prev.adminConPass !== next.adminConPass) {
|
||||
info("warning: Admin bootstrap fields are install-only and will not rotate existing credentials.");
|
||||
info("warning: Use a dedicated password rotation flow in application/account layer.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconfigure(options: ReconfigureOptions): Promise<void> {
|
||||
const configRaw = await readText(options.sourceConfigPath);
|
||||
const config = parseRuntimeEnv(configRaw);
|
||||
@@ -58,6 +94,9 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
|
||||
|
||||
step("preflight");
|
||||
await preflight(context);
|
||||
step("install state marker");
|
||||
await ensureInstallStateExists();
|
||||
await warnBootstrapDrift(configRaw);
|
||||
|
||||
if (options.dryRun) {
|
||||
info("reconfigure dry-run: validated config and execution graph");
|
||||
@@ -86,8 +125,11 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
|
||||
await writePostInstallEnv(context);
|
||||
step("smoke checks");
|
||||
await smoke(context);
|
||||
step("finalize firewall rollback guard");
|
||||
await cancelFirewallRollback(context);
|
||||
} catch (error) {
|
||||
info("reconfigure failed, rollback in progress");
|
||||
await rollbackFirewallNow(context);
|
||||
await rollbackCurrentState();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ import { runVisible } from "../lib/process";
|
||||
|
||||
export async function installDeps(_context: InstallContext): Promise<void> {
|
||||
await runVisible`apt-get update`;
|
||||
await runVisible`apt-get install -y ca-certificates curl iproute2 tar openssl nftables systemd`;
|
||||
await runVisible`apt-get install -y sudo ca-certificates curl iproute2 tar openssl nftables systemd`;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
import type { InstallContext } from "../types/context";
|
||||
import { runVisible } from "../lib/process";
|
||||
|
||||
async function ensureRuntimeIdentity(user: string, expectedHome: string): Promise<void> {
|
||||
const checkCmd = `
|
||||
if id -u ${user} >/dev/null 2>&1; then
|
||||
shell="$(getent passwd ${user} | cut -d: -f7)"
|
||||
home="$(getent passwd ${user} | cut -d: -f6)"
|
||||
group="$(id -gn ${user})"
|
||||
if [ "$shell" != "/usr/sbin/nologin" ] && [ "$shell" != "/bin/false" ]; then
|
||||
echo "existing user '${user}' has unsupported shell: $shell" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$group" != "${user}" ]; then
|
||||
echo "existing user '${user}' must have primary group '${user}', got: $group" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$home" != "${expectedHome}" ]; then
|
||||
echo "existing user '${user}' has unexpected home: $home (expected ${expectedHome})" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
useradd --system --home ${expectedHome} --shell /usr/sbin/nologin ${user}
|
||||
fi
|
||||
`;
|
||||
await runVisible`${checkCmd}`;
|
||||
}
|
||||
|
||||
export async function prepareFilesystem(context: InstallContext): Promise<void> {
|
||||
await runVisible`id -u hysteria >/dev/null 2>&1 || useradd --system --home /var/lib/hysteria --shell /usr/sbin/nologin hysteria`;
|
||||
await runVisible`id -u hy2xs-admin >/dev/null 2>&1 || useradd --system --home ${context.config.dataDir} --shell /usr/sbin/nologin hy2xs-admin`;
|
||||
await ensureRuntimeIdentity("hysteria", "/var/lib/hysteria");
|
||||
await ensureRuntimeIdentity("hy2xs-admin", context.config.dataDir);
|
||||
await runVisible`install -d -m 0700 -o root -g root /etc/hy2xs`;
|
||||
await runVisible`install -d -m 0755 -o root -g root /etc/hysteria`;
|
||||
await runVisible`install -d -m 0750 -o hysteria -g hysteria /var/lib/hysteria`;
|
||||
|
||||
@@ -3,6 +3,16 @@ import { exists, readText, renderTemplate, writeText } from "../lib/fs";
|
||||
import { fail, info } from "../lib/log";
|
||||
import { runVisible } from "../lib/process";
|
||||
|
||||
const FW_BACKUP_FILES = [
|
||||
"/etc/nftables.conf.hy2xs.bak",
|
||||
"/etc/nftables.conf.candidate",
|
||||
"/etc/nftables.d/hy2xs.nft.bak",
|
||||
"/etc/nftables.d/hy2xs.nft.candidate",
|
||||
"/etc/nftables.d/hy2xs.nft.existed",
|
||||
"/etc/nftables.d/hy2xs.nft.include.existed",
|
||||
"/etc/nftables.d/nftables.conf.existed"
|
||||
].join(" ");
|
||||
|
||||
function stripNftComments(content: string): string {
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
@@ -93,10 +103,34 @@ include "/etc/nftables.d/hy2xs.nft"
|
||||
|
||||
await runVisible`ss -H -ltn | grep -q ':${context.config.sshPort} ' || (echo 'ssh port check failed' >&2; exit 1)`;
|
||||
|
||||
info("firewall applied with rollback guard; guard will be cancelled only after successful smoke checks");
|
||||
}
|
||||
|
||||
export async function cancelFirewallRollback(context: RuntimeContext): Promise<void> {
|
||||
if (!context.config.firewallEnabled || context.options.skipFirewall) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.config.firewallStagedApply) {
|
||||
await runVisible`systemctl stop hy2xs-fw-rollback || true`;
|
||||
await runVisible`systemctl reset-failed hy2xs-fw-rollback || true`;
|
||||
}
|
||||
|
||||
await runVisible`rm -f /etc/nftables.conf.hy2xs.bak /etc/nftables.conf.candidate /etc/nftables.d/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft.candidate /etc/nftables.d/hy2xs.nft.existed /etc/nftables.d/hy2xs.nft.include.existed /etc/nftables.d/nftables.conf.existed`;
|
||||
await runVisible`rm -f ${FW_BACKUP_FILES}`;
|
||||
}
|
||||
|
||||
export async function rollbackFirewallNow(context: RuntimeContext): Promise<void> {
|
||||
if (!context.config.firewallEnabled || context.options.skipFirewall) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.config.firewallStagedApply) {
|
||||
await runVisible`systemctl stop hy2xs-fw-rollback || true`;
|
||||
await runVisible`systemctl reset-failed hy2xs-fw-rollback || true`;
|
||||
}
|
||||
|
||||
await runVisible`if [ -f /etc/nftables.d/nftables.conf.existed ]; then cp -a /etc/nftables.conf.hy2xs.bak /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi`;
|
||||
await runVisible`if [ -f /etc/nftables.d/hy2xs.nft.existed ]; then cp -a /etc/nftables.d/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`;
|
||||
await runVisible`nft -f /etc/nftables.conf >/dev/null 2>&1 || true`;
|
||||
await runVisible`rm -f ${FW_BACKUP_FILES}`;
|
||||
}
|
||||
|
||||
@@ -19,13 +19,17 @@ function validatePinnedVersion(value: string): void {
|
||||
export async function installHysteria(context: InstallContext): Promise<void> {
|
||||
validatePinnedVersion(context.hysteriaTargetVersion);
|
||||
|
||||
const tmp = "/tmp/hy2xs-hysteria-linux-amd64";
|
||||
const tmpDir = await run`mktemp -d`;
|
||||
const tmp = `${tmpDir.trim()}/hysteria-linux-amd64`;
|
||||
|
||||
await runVisible`curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location ${context.hysteriaArtifactUrl} -o ${tmp}`;
|
||||
await runVisible`test -s ${tmp}`;
|
||||
await runVisible`printf '%s %s\n' ${context.hysteriaArtifactSha256} ${tmp} | sha256sum -c -`;
|
||||
await runVisible`install -m 0755 ${tmp} /usr/local/bin/hysteria`;
|
||||
await runVisible`rm -f ${tmp}`;
|
||||
try {
|
||||
await runVisible`curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location ${context.hysteriaArtifactUrl} -o ${tmp}`;
|
||||
await runVisible`test -s ${tmp}`;
|
||||
await runVisible`printf '%s %s\n' ${context.hysteriaArtifactSha256} ${tmp} | sha256sum -c -`;
|
||||
await runVisible`install -m 0755 -o root -g root ${tmp} /usr/local/bin/hysteria`;
|
||||
} finally {
|
||||
await runVisible`rm -rf ${tmpDir.trim()}`;
|
||||
}
|
||||
|
||||
await runVisible`test -x /usr/local/bin/hysteria`;
|
||||
const versionOutput = await run`/usr/local/bin/hysteria version`;
|
||||
|
||||
@@ -3,9 +3,18 @@ import { exists, readText } from "../lib/fs";
|
||||
import { fail, info } from "../lib/log";
|
||||
import { run } from "../lib/process";
|
||||
|
||||
async function isPortBusy(port: number): Promise<boolean> {
|
||||
async function isTcpPortListening(port: number): Promise<boolean> {
|
||||
try {
|
||||
const output = await run`ss -H -lntu`;
|
||||
const output = await run`ss -H -ltn`;
|
||||
return output.split("\n").some((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isUdpPortListening(port: number): Promise<boolean> {
|
||||
try {
|
||||
const output = await run`ss -H -lun`;
|
||||
return output.split("\n").some((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`));
|
||||
} catch {
|
||||
return false;
|
||||
@@ -28,6 +37,12 @@ export async function preflight(context: RuntimeContext): Promise<void> {
|
||||
fail("installer must run as root");
|
||||
}
|
||||
|
||||
try {
|
||||
await run`command -v sudo >/dev/null 2>&1`;
|
||||
} catch {
|
||||
fail("sudo is required for installer smoke checks. Install it with: apt-get update && apt-get install -y sudo");
|
||||
}
|
||||
|
||||
const osRelease = await readText("/etc/os-release");
|
||||
if (!/^ID=debian$/m.test(osRelease) || !/^VERSION_ID="?12"?$/m.test(osRelease)) {
|
||||
fail("HY2XS baseline supports only clean Debian 12");
|
||||
@@ -67,6 +82,10 @@ export async function preflight(context: RuntimeContext): Promise<void> {
|
||||
fail("HY2XS UI bind host must be IPv4-only");
|
||||
}
|
||||
|
||||
if (context.config.ipv6Enabled) {
|
||||
fail("HY2XS is IPv4-only: disable IPv6 in config (HY2XS_IPV6_ENABLED=false)");
|
||||
}
|
||||
|
||||
if (context.config.hysteriaBindHost !== "0.0.0.0") {
|
||||
fail("HY2XS_HYSTERIA_BIND_HOST must be 0.0.0.0 for production profile");
|
||||
}
|
||||
@@ -77,7 +96,7 @@ export async function preflight(context: RuntimeContext): Promise<void> {
|
||||
|
||||
if (!isReconfigure && context.config.tlsMode === "acme") {
|
||||
const acmeChallengePort = context.config.acmeType === "http" ? 80 : 443;
|
||||
if (await isPortBusy(acmeChallengePort)) {
|
||||
if (await isTcpPortListening(acmeChallengePort)) {
|
||||
fail(`ACME ${context.config.acmeType}-challenge port is already in use: ${acmeChallengePort}`);
|
||||
}
|
||||
}
|
||||
@@ -101,24 +120,24 @@ export async function preflight(context: RuntimeContext): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const hysteriaPortBusy = await isPortBusy(context.config.hysteriaPort);
|
||||
const uiPortBusy = await isPortBusy(context.config.uiPort);
|
||||
const hysteriaUdpBusy = await isUdpPortListening(context.config.hysteriaPort);
|
||||
const uiTcpBusy = await isTcpPortListening(context.config.uiPort);
|
||||
|
||||
if (!isReconfigure) {
|
||||
if (hysteriaPortBusy) {
|
||||
fail(`Hysteria UDP/TCP port already appears to be in use: ${context.config.hysteriaPort}`);
|
||||
if (hysteriaUdpBusy) {
|
||||
fail(`Hysteria UDP port already appears to be in use: ${context.config.hysteriaPort}`);
|
||||
}
|
||||
if (uiPortBusy) {
|
||||
if (uiTcpBusy) {
|
||||
fail(`HY2XS admin port already appears to be in use: ${context.config.uiPort}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hysteriaPortBusy && !(await isUnitActive("hysteria-server"))) {
|
||||
if (hysteriaUdpBusy && !(await isUnitActive("hysteria-server"))) {
|
||||
fail(`Hysteria port ${context.config.hysteriaPort} is occupied by a non-HY2XS process`);
|
||||
}
|
||||
|
||||
if (uiPortBusy && !(await isUnitActive("hy2xs-admin"))) {
|
||||
if (uiTcpBusy && !(await isUnitActive("hy2xs-admin"))) {
|
||||
fail(`HY2XS admin port ${context.config.uiPort} is occupied by a non-HY2XS process`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user