214 lines
9.1 KiB
TypeScript
214 lines
9.1 KiB
TypeScript
import type { RuntimeContext } from "../types/context";
|
|
import { info } from "../lib/log";
|
|
import { runHidden, runSecret, runVisible } from "../lib/process";
|
|
|
|
function parseLocalAddress(line: string): string {
|
|
const cols = line.trim().split(/\s+/);
|
|
return cols[3] ?? "";
|
|
}
|
|
|
|
function hasTcpListener(lines: string, host: string, port: number): boolean {
|
|
return lines
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.some((line) => {
|
|
const local = parseLocalAddress(line);
|
|
return local === `${host}:${port}`;
|
|
});
|
|
}
|
|
|
|
function hasUdpListener(lines: string, host: string, port: number): boolean {
|
|
return lines
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.some((line) => {
|
|
const local = parseLocalAddress(line);
|
|
return local === `${host}:${port}`;
|
|
});
|
|
}
|
|
|
|
async function retry<T>(
|
|
label: string,
|
|
attempts: number,
|
|
delayMs: number,
|
|
action: () => Promise<T>,
|
|
validate: (value: T) => boolean,
|
|
errorFactory: (value: T | undefined, error: unknown) => Error,
|
|
): Promise<T> {
|
|
let lastValue: T | undefined;
|
|
let lastError: unknown;
|
|
for (let i = 0; i < attempts; i += 1) {
|
|
try {
|
|
const value = await action();
|
|
lastValue = value;
|
|
if (validate(value)) {
|
|
return value;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
if (i < attempts - 1) {
|
|
info(`${label}: retry ${i + 1}/${attempts}`);
|
|
await runHidden`sleep ${Math.max(1, Math.ceil(delayMs / 1000))}`;
|
|
}
|
|
}
|
|
throw errorFactory(lastValue, lastError);
|
|
}
|
|
|
|
export async function smoke(context: RuntimeContext): Promise<void> {
|
|
if (context.options.skipServiceStart && context.options.skipSmoke) {
|
|
info("service start and smoke checks skipped by flags");
|
|
return;
|
|
}
|
|
|
|
if (!context.options.skipServiceStart) {
|
|
await runVisible`systemctl restart hysteria-server hy2xs-admin`;
|
|
} else {
|
|
info("service restart skipped by flag");
|
|
}
|
|
|
|
await retry(
|
|
"systemd hysteria-server active",
|
|
15,
|
|
1000,
|
|
async () => runSecret`systemctl is-active hysteria-server || true`,
|
|
(state) => state.trim() === "active",
|
|
(state, error) => new Error(`hysteria-server is not active: ${state ?? String(error)}`),
|
|
);
|
|
await retry(
|
|
"systemd hy2xs-admin active",
|
|
15,
|
|
1000,
|
|
async () => runSecret`systemctl is-active hy2xs-admin || true`,
|
|
(state) => state.trim() === "active",
|
|
(state, error) => new Error(`hy2xs-admin is not active: ${state ?? String(error)}`),
|
|
);
|
|
|
|
if (context.options.skipSmoke) {
|
|
info("smoke checks skipped by flag");
|
|
return;
|
|
}
|
|
|
|
await retry(
|
|
"ui tcp listener readiness",
|
|
15,
|
|
1000,
|
|
async () => runSecret`ss -H -ltn`,
|
|
(lines) => hasTcpListener(lines, context.config.uiBindHost, context.config.uiPort),
|
|
(lines, error) => new Error(`ui listener not ready on ${context.config.uiBindHost}:${context.config.uiPort}: ${lines ?? String(error)}`),
|
|
);
|
|
await retry(
|
|
"hysteria udp listener readiness",
|
|
15,
|
|
1000,
|
|
async () => runSecret`ss -H -lun`,
|
|
(lines) => hasUdpListener(lines, context.config.hysteriaBindHost, context.config.hysteriaPort),
|
|
(lines, error) => new Error(`hysteria udp listener not ready on 0.0.0.0:${context.config.hysteriaPort}: ${lines ?? String(error)}`),
|
|
);
|
|
await retry(
|
|
"admin healthz readiness",
|
|
15,
|
|
1000,
|
|
async () => runSecret`curl -sS --max-time 5 http://127.0.0.1:${context.config.uiPort}/healthz`,
|
|
(response) => /"ok"\s*:\s*true/.test(response),
|
|
(response, error) => new Error(`admin healthz is not ready: ${response ?? String(error)}`),
|
|
);
|
|
|
|
await runVisible`/usr/local/bin/hysteria version`;
|
|
await runVisible`test -s /etc/hysteria/config.yaml`;
|
|
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 '^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)" = '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`sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env`;
|
|
await runVisible`sudo -u hy2xs-admin test ! -r /etc/hy2xs/bootstrap-admin.secret`;
|
|
await runVisible`sudo -u hysteria test ! -r /etc/hy2xs/bootstrap-admin.secret`;
|
|
if (context.config.uiBindHost === "127.0.0.1") {
|
|
const tcp = await runSecret`ss -H -ltn`;
|
|
if (hasTcpListener(tcp, "0.0.0.0", context.config.uiPort)) {
|
|
throw new Error(`ui listener must not be public on 0.0.0.0:${context.config.uiPort}`);
|
|
}
|
|
}
|
|
await runVisible`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`;
|
|
await runVisible`! ss -H -lun | grep -q '\[::\]:${context.config.hysteriaPort} '`;
|
|
const invalidAuthResponse = await retry(
|
|
"auth invalid credentials",
|
|
5,
|
|
1000,
|
|
async () => 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`,
|
|
(response) => /"ok"\s*:\s*false/.test(response),
|
|
(response, error) => new Error(`unexpected auth response for invalid credentials: ${response ?? String(error)}`),
|
|
);
|
|
|
|
for (let i = 0; i < 10; i += 1) {
|
|
const response = 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(response)) {
|
|
throw new Error(`unexpected auth response during rate-limit smoke: ${response}`);
|
|
}
|
|
}
|
|
|
|
const invalidTypeAuthCode = await runSecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -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 (invalidTypeAuthCode.trim() !== "400") {
|
|
throw new Error(`unexpected auth status for tx as string: ${invalidTypeAuthCode}`);
|
|
}
|
|
|
|
if (context.mode === "install") {
|
|
const adminConPass = (await runSecret`grep '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-`).trim();
|
|
if (!adminConPass) {
|
|
throw new Error("admin connection password is empty in bootstrap secret file");
|
|
}
|
|
|
|
await retry(
|
|
"auth valid credentials",
|
|
10,
|
|
1000,
|
|
async () => runSecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"${adminConPass}","tx":0}' http://127.0.0.1:${context.config.uiPort}/hui/hysteria2/auth`,
|
|
(response) => /"ok"\s*:\s*true/.test(response),
|
|
(response, error) => new Error(`unexpected auth response for valid credentials: ${response ?? String(error)}`),
|
|
);
|
|
}
|
|
|
|
await retry(
|
|
"trafficStats valid secret",
|
|
10,
|
|
1000,
|
|
async () => runSecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: ${context.config.hysteriaTrafficStatsSecret}' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`,
|
|
(code) => /^2\d\d$/.test(code.trim()),
|
|
(code, error) => new Error(`unexpected trafficStats status for valid secret: ${code ?? String(error)}`),
|
|
);
|
|
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}`);
|
|
}
|
|
|
|
await runVisible`nft -c -f /etc/nftables.conf`;
|
|
|
|
if (context.config.tlsMode === "acme") {
|
|
await runVisible`grep -q '^acme:' /etc/hysteria/config.yaml`;
|
|
await runVisible`! grep -q '^tls:' /etc/hysteria/config.yaml`;
|
|
}
|
|
if (context.config.tlsMode === "file") {
|
|
await runVisible`grep -q '^tls:' /etc/hysteria/config.yaml`;
|
|
await runVisible`! grep -q '^acme:' /etc/hysteria/config.yaml`;
|
|
await runVisible`grep -q 'insecure: false' /etc/hysteria/config.yaml`;
|
|
}
|
|
if (context.config.tlsMode === "self_signed_dev") {
|
|
await runVisible`grep -q '^tls:' /etc/hysteria/config.yaml`;
|
|
await runVisible`! grep -q '^acme:' /etc/hysteria/config.yaml`;
|
|
await runVisible`grep -q 'insecure: true' /etc/hysteria/config.yaml`;
|
|
}
|
|
}
|