feat: 支持证书托管和节点自动安装
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { X509Certificate, createPrivateKey } from "node:crypto";
|
||||
import { ensureDb } from "@/lib/db";
|
||||
import { encryptText } from "@/lib/crypto";
|
||||
|
||||
export class ProvisionError extends Error {}
|
||||
export type ManagedCertificate = {
|
||||
id: number;
|
||||
name: string;
|
||||
certificate_pem: string;
|
||||
private_key_encrypted: string;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export function validateCertificate(pem: string, key: string, sni?: string) {
|
||||
if (pem.length > 128 * 1024 || key.length > 32 * 1024)
|
||||
throw new ProvisionError("证书或私钥文件过大。");
|
||||
let certificate: X509Certificate;
|
||||
try {
|
||||
certificate = new X509Certificate(pem);
|
||||
const privateKey = createPrivateKey(key);
|
||||
if (!certificate.checkPrivateKey(privateKey)) throw new Error();
|
||||
const chain = pem.match(
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g,
|
||||
);
|
||||
if (!chain?.length) throw new Error();
|
||||
chain.forEach((part) => new X509Certificate(part));
|
||||
} catch {
|
||||
throw new ProvisionError(
|
||||
"无法解析 PEM 证书/私钥,或私钥与证书不匹配。请上传未加密的私钥和完整证书链。",
|
||||
);
|
||||
}
|
||||
if (
|
||||
Date.parse(certificate.validFrom) > Date.now() ||
|
||||
Date.parse(certificate.validTo) <= Date.now()
|
||||
)
|
||||
throw new ProvisionError("证书尚未生效或已过期。");
|
||||
if (sni && !certificate.checkHost(sni, { subject: "never" }))
|
||||
throw new ProvisionError("SNI 域名与证书的 SAN 不匹配。");
|
||||
return certificate;
|
||||
}
|
||||
|
||||
export async function saveCertificate(name: string, pem: string, key: string) {
|
||||
if (!name.trim() || name.length > 100)
|
||||
throw new ProvisionError("请填写 1–100 字的证书名称。");
|
||||
const certificate = validateCertificate(pem, key);
|
||||
const db = await ensureDb();
|
||||
return db
|
||||
.prepare(
|
||||
`INSERT INTO managed_certificates
|
||||
(name,certificate_pem,private_key_encrypted,expires_at,created_at) VALUES (?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
name.trim(),
|
||||
pem,
|
||||
encryptText(key),
|
||||
new Date(certificate.validTo).toISOString(),
|
||||
new Date().toISOString(),
|
||||
).lastInsertRowid;
|
||||
}
|
||||
|
||||
export async function listCertificates() {
|
||||
const db = await ensureDb();
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT id,name,expires_at,created_at FROM managed_certificates ORDER BY id DESC",
|
||||
)
|
||||
.all() as Pick<
|
||||
ManagedCertificate,
|
||||
"id" | "name" | "expires_at" | "created_at"
|
||||
>[];
|
||||
}
|
||||
|
||||
export async function deleteCertificate(id: number) {
|
||||
const db = await ensureDb();
|
||||
if (
|
||||
db
|
||||
.prepare("SELECT 1 FROM node_installations WHERE certificate_id=?")
|
||||
.get(id)
|
||||
)
|
||||
throw new ProvisionError(
|
||||
"此证书仍关联安装记录,请先删除相关安装记录。已安装服务器的证书文件不会随之删除。",
|
||||
);
|
||||
db.prepare("DELETE FROM managed_certificates WHERE id=?").run(id);
|
||||
}
|
||||
@@ -172,6 +172,33 @@ const MIGRATIONS: (string | ((db: Database.Database) => void))[] = [
|
||||
ALTER TABLE hy2_nodes ADD COLUMN hy2_listen TEXT NOT NULL DEFAULT ':443';
|
||||
`,
|
||||
ensureCustomNodeColumns,
|
||||
`
|
||||
CREATE TABLE managed_certificates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
certificate_pem TEXT NOT NULL,
|
||||
private_key_encrypted TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE node_installations (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_encrypted TEXT NOT NULL,
|
||||
certificate_id INTEGER NOT NULL REFERENCES managed_certificates(id) ON DELETE RESTRICT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
server_host TEXT NOT NULL,
|
||||
sni TEXT NOT NULL,
|
||||
origin TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
completed_at TEXT,
|
||||
ports_json TEXT,
|
||||
node_id INTEGER REFERENCES hy2_nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
`,
|
||||
];
|
||||
|
||||
export function migrateDatabase(db: Database.Database) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { decryptText } from "@/lib/crypto";
|
||||
import type { Installation } from "@/lib/provisioning";
|
||||
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
export function buildInstallScript(job: Installation) {
|
||||
const token = decryptText(job.token_encrypted);
|
||||
if (!token) throw new Error("installation_token_unavailable");
|
||||
const values: Record<string, string> = {
|
||||
PANEL: job.origin,
|
||||
TOKEN: token,
|
||||
INSTALL_ID: job.id,
|
||||
SLUG: job.slug,
|
||||
};
|
||||
return readFileSync(
|
||||
path.join(process.cwd(), "lib/templates/install-node.sh"),
|
||||
"utf8",
|
||||
).replace(/__(PANEL|TOKEN|INSTALL_ID|SLUG)__/g, (_, key) =>
|
||||
quote(values[key]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { randomBytes, randomUUID, createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { stringify } from "yaml";
|
||||
import { ensureDb } from "@/lib/db";
|
||||
import { decryptText, encryptText } from "@/lib/crypto";
|
||||
import { getEnv } from "@/lib/env";
|
||||
import {
|
||||
ProvisionError,
|
||||
validateCertificate,
|
||||
type ManagedCertificate,
|
||||
} from "@/lib/certificates";
|
||||
import type { Hy2NodeRow } from "@/lib/store";
|
||||
|
||||
export const portSchema = z
|
||||
.object({
|
||||
domain: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.p\.xn--876a\.net$/),
|
||||
hy2Port: z.number().int().min(1).max(65535),
|
||||
statsPort: z.number().int().min(1).max(65535),
|
||||
hy2ListenPort: z.number().int().min(1).max(65535),
|
||||
statsListenPort: z.number().int().min(1).max(65535),
|
||||
})
|
||||
.strict();
|
||||
export const installationSchema = z.object({
|
||||
slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
certificateId: z.coerce.number().int().positive(),
|
||||
});
|
||||
export type Installation = {
|
||||
id: string;
|
||||
token_hash: string;
|
||||
token_encrypted: string;
|
||||
certificate_id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
server_host: string;
|
||||
sni: string;
|
||||
origin: string;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
revoked_at: string | null;
|
||||
completed_at: string | null;
|
||||
node_id: number | null;
|
||||
ports_json: string | null;
|
||||
};
|
||||
const hash = (token: string) =>
|
||||
createHash("sha256").update(token).digest("hex");
|
||||
|
||||
export async function createInstallation(
|
||||
input: z.infer<typeof installationSchema>,
|
||||
) {
|
||||
const data = installationSchema.safeParse(input);
|
||||
if (!data.success)
|
||||
throw new ProvisionError("请检查节点名称、Slug 和托管证书。");
|
||||
input = data.data;
|
||||
const origin = new URL(getEnv().APP_URL);
|
||||
if (
|
||||
origin.protocol !== "https:" &&
|
||||
!["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname)
|
||||
)
|
||||
throw new ProvisionError("请将 APP_URL 设置为平台的公网 HTTPS 地址。");
|
||||
const db = await ensureDb();
|
||||
const cert = db
|
||||
.prepare("SELECT * FROM managed_certificates WHERE id=?")
|
||||
.get(input.certificateId) as ManagedCertificate | undefined;
|
||||
if (!cert) throw new ProvisionError("请选择托管证书。");
|
||||
const certificate = validateCertificate(
|
||||
cert.certificate_pem,
|
||||
decryptText(cert.private_key_encrypted) || "",
|
||||
"install-check.p.xn--876a.net",
|
||||
);
|
||||
if (
|
||||
!/(?:^|, )DNS:\*\.p\.xn--876a\.net(?:,|$)/.test(
|
||||
certificate.subjectAltName || "",
|
||||
)
|
||||
)
|
||||
throw new ProvisionError("请选择包含 *.p.xn--876a.net 的通配符证书。");
|
||||
const id = randomUUID(),
|
||||
token = randomBytes(32).toString("hex"),
|
||||
now = new Date().toISOString();
|
||||
db.transaction(() => {
|
||||
if (
|
||||
db.prepare("SELECT 1 FROM hy2_nodes WHERE slug=?").get(input.slug) ||
|
||||
db
|
||||
.prepare("SELECT 1 FROM node_installations WHERE slug=?")
|
||||
.get(input.slug)
|
||||
)
|
||||
throw new ProvisionError(
|
||||
"此 Slug 已存在,请使用新 Slug,或删除旧安装记录。",
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO node_installations
|
||||
(id,token_hash,token_encrypted,certificate_id,slug,name,server_host,sni,origin,expires_at,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
).run(
|
||||
id,
|
||||
hash(token),
|
||||
encryptText(token),
|
||||
input.certificateId,
|
||||
input.slug,
|
||||
input.name,
|
||||
"",
|
||||
"",
|
||||
origin.origin,
|
||||
new Date(Date.now() + 24 * 3600_000).toISOString(),
|
||||
now,
|
||||
);
|
||||
}).immediate();
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function listInstallations() {
|
||||
const db = await ensureDb();
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id,slug,name,server_host,sni,expires_at,created_at,revoked_at,completed_at,node_id,
|
||||
(SELECT name FROM managed_certificates WHERE id=certificate_id) AS certificate_name
|
||||
FROM node_installations ORDER BY created_at DESC`,
|
||||
)
|
||||
.all() as (Omit<
|
||||
Installation,
|
||||
| "token_hash"
|
||||
| "token_encrypted"
|
||||
| "certificate_id"
|
||||
| "origin"
|
||||
| "ports_json"
|
||||
> & { certificate_name: string })[];
|
||||
}
|
||||
|
||||
export async function installationForDownload(id: string) {
|
||||
const db = await ensureDb();
|
||||
const row = db
|
||||
.prepare("SELECT * FROM node_installations WHERE id=?")
|
||||
.get(id) as Installation | undefined;
|
||||
if (
|
||||
!row ||
|
||||
row.revoked_at ||
|
||||
row.completed_at ||
|
||||
row.expires_at <= new Date().toISOString()
|
||||
)
|
||||
throw new ProvisionError(
|
||||
"安装凭据已过期、撤销或使用完成。请创建新安装记录。",
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function revokeInstallation(id: string, remove = false) {
|
||||
const db = await ensureDb();
|
||||
db.prepare(
|
||||
remove
|
||||
? "DELETE FROM node_installations WHERE id=?"
|
||||
: "UPDATE node_installations SET revoked_at=datetime('now') WHERE id=?",
|
||||
).run(id);
|
||||
}
|
||||
|
||||
export async function provisionNode(
|
||||
token: string,
|
||||
action: "prepare" | "complete",
|
||||
rawPorts?: unknown,
|
||||
) {
|
||||
if (!/^[a-f0-9]{64}$/.test(token)) throw new ProvisionError("安装凭据无效。");
|
||||
const db = await ensureDb();
|
||||
return db
|
||||
.transaction(() => {
|
||||
const job = db
|
||||
.prepare("SELECT * FROM node_installations WHERE token_hash=?")
|
||||
.get(hash(token)) as Installation | undefined;
|
||||
if (!job || job.revoked_at || job.expires_at <= new Date().toISOString())
|
||||
throw new ProvisionError("安装凭据无效或已过期。");
|
||||
if (action === "complete") {
|
||||
if (!job.node_id) throw new ProvisionError("请先获取安装配置。");
|
||||
if (!job.completed_at) {
|
||||
db.prepare(
|
||||
"UPDATE hy2_nodes SET enabled=1,updated_at=? WHERE id=?",
|
||||
).run(new Date().toISOString(), job.node_id);
|
||||
db.prepare(
|
||||
"UPDATE node_installations SET completed_at=? WHERE id=?",
|
||||
).run(new Date().toISOString(), job.id);
|
||||
}
|
||||
return { ok: true, slug: job.slug };
|
||||
}
|
||||
if (job.completed_at)
|
||||
throw new ProvisionError("该凭据已完成安装,不能再次获取私钥。");
|
||||
const parsed = portSchema.safeParse(rawPorts);
|
||||
if (!parsed.success)
|
||||
throw new ProvisionError(
|
||||
"域名须为 .p.xn--876a.net 下的单级子域名,端口必须为 1–65535 的整数。",
|
||||
);
|
||||
const ports = parsed.data,
|
||||
portsJson = JSON.stringify(ports);
|
||||
if (job.ports_json && job.ports_json !== portsJson)
|
||||
throw new ProvisionError(
|
||||
"该安装记录已绑定域名和端口;重试请使用原配置,或创建新安装记录。",
|
||||
);
|
||||
const cert = db
|
||||
.prepare("SELECT * FROM managed_certificates WHERE id=?")
|
||||
.get(job.certificate_id) as ManagedCertificate;
|
||||
const key = decryptText(cert.private_key_encrypted) || "";
|
||||
validateCertificate(cert.certificate_pem, key, ports.domain);
|
||||
const now = new Date().toISOString();
|
||||
let nodeId = job.node_id;
|
||||
if (!nodeId) {
|
||||
if (db.prepare("SELECT 1 FROM hy2_nodes WHERE slug=?").get(job.slug))
|
||||
throw new ProvisionError("节点 Slug 已被占用。");
|
||||
const host = ports.domain;
|
||||
nodeId = Number(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO hy2_nodes
|
||||
(slug,name,auth_token,traffic_stats_url,traffic_stats_secret,enabled,created_at,updated_at,
|
||||
server_host,server_port,client_name,sni,hy2_listen,traffic_stats_listen)
|
||||
VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
job.slug,
|
||||
job.name,
|
||||
randomBytes(32).toString("hex"),
|
||||
`http://${host}:${ports.statsPort}`,
|
||||
randomBytes(32).toString("hex"),
|
||||
now,
|
||||
now,
|
||||
host,
|
||||
ports.hy2Port,
|
||||
job.name,
|
||||
host,
|
||||
`:${ports.hy2ListenPort}`,
|
||||
`:${ports.statsListenPort}`,
|
||||
).lastInsertRowid,
|
||||
);
|
||||
db.prepare(
|
||||
"UPDATE node_installations SET node_id=?,ports_json=?,server_host=?,sni=? WHERE id=?",
|
||||
).run(nodeId, portsJson, host, host, job.id);
|
||||
}
|
||||
const node = db
|
||||
.prepare("SELECT * FROM hy2_nodes WHERE id=?")
|
||||
.get(nodeId) as Hy2NodeRow;
|
||||
const config = stringify({
|
||||
listen: node.hy2_listen,
|
||||
tls: {
|
||||
cert: "/etc/hysteria/server.crt",
|
||||
key: "/etc/hysteria/server.key",
|
||||
},
|
||||
auth: {
|
||||
type: "http",
|
||||
http: {
|
||||
url: `${job.origin}/api/hy2/nodes/${job.slug}/auth?token=${node.auth_token}`,
|
||||
insecure: false,
|
||||
},
|
||||
},
|
||||
trafficStats: {
|
||||
listen: node.traffic_stats_listen,
|
||||
secret: node.traffic_stats_secret,
|
||||
},
|
||||
});
|
||||
return {
|
||||
config,
|
||||
certificate: cert.certificate_pem,
|
||||
privateKey: key,
|
||||
statsSecret: node.traffic_stats_secret,
|
||||
};
|
||||
})
|
||||
.immediate();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
PANEL=__PANEL__
|
||||
TOKEN=__TOKEN__
|
||||
INSTALL_ID=__INSTALL_ID__
|
||||
SLUG=__SLUG__
|
||||
DOMAIN=""
|
||||
HY2_PORT=""
|
||||
STATS_PORT=""
|
||||
HY2_LISTEN=""
|
||||
STATS_LISTEN=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
[ "$#" -ge 2 ] || { echo "参数缺少值:$1"; exit 1; }
|
||||
case "$1" in
|
||||
--domain) DOMAIN=$2;;
|
||||
--hy2-port) HY2_PORT=$2;;
|
||||
--stats-port) STATS_PORT=$2;;
|
||||
--hy2-listen-port) HY2_LISTEN=$2;;
|
||||
--stats-listen-port) STATS_LISTEN=$2;;
|
||||
*) echo "未知参数:$1"; exit 1;;
|
||||
esac
|
||||
shift 2
|
||||
done
|
||||
[ "$(id -u)" -eq 0 ] || { echo '请使用 root 执行。'; exit 1; }
|
||||
command -v systemctl >/dev/null || { echo '需要使用 systemd 的 Linux 服务器。'; exit 1; }
|
||||
[ -d /run/systemd/system ] || { echo 'systemd 未运行。'; exit 1; }
|
||||
case "$(uname -m)" in
|
||||
x86_64) ARCH=amd64;; aarch64|arm64) ARCH=arm64;;
|
||||
armv7l) ARCH=arm;; *) echo '暂不支持此 CPU 架构。'; exit 1;;
|
||||
esac
|
||||
if [ -e /etc/hysteria/config.yaml ] || [ -e /etc/systemd/system/hy2-panel-node.service ]; then
|
||||
[ "$(cat /etc/hysteria/.panel-install-id 2>/dev/null || true)" = "$INSTALL_ID" ] || {
|
||||
echo '检测到已有 HY2 配置;为避免覆盖,安装已停止。请在新服务器运行。'; exit 1;
|
||||
}
|
||||
fi
|
||||
for unit in hysteria-server.service hysteria-server@config.service; do
|
||||
if systemctl is-active --quiet "$unit"; then echo '已有 Hysteria 服务正在运行,安装已停止。'; exit 1; fi
|
||||
done
|
||||
read_port() {
|
||||
local value
|
||||
while true; do
|
||||
read -r -p "$1" value </dev/tty
|
||||
if [[ "$value" =~ ^[0-9]{1,5}$ ]] && (( 10#$value >= 1 && 10#$value <= 65535 )); then
|
||||
printf '%s' "$((10#$value))"; return
|
||||
fi
|
||||
echo '端口必须为 1–65535。' >&2
|
||||
done
|
||||
}
|
||||
echo "节点:$SLUG"
|
||||
if [ -z "$DOMAIN" ]; then read -r -p '节点域名(输入 jp1 或 jp1.p.xn--876a.net):' DOMAIN </dev/tty; fi
|
||||
DOMAIN=$(printf '%s' "$DOMAIN" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; then DOMAIN="$DOMAIN.p.xn--876a.net"; fi
|
||||
[[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.p\.xn--876a\.net$ ]] || { echo '请填写 .p.xn--876a.net 下的单级子域名,例如 jp1.p.xn--876a.net。'; exit 1; }
|
||||
[ -n "$HY2_PORT" ] || HY2_PORT=$(read_port 'HY2 公网 UDP 端口:')
|
||||
[ -n "$STATS_PORT" ] || STATS_PORT=$(read_port '流量统计公网 TCP 端口:')
|
||||
if [ -z "$HY2_LISTEN" ] && [ -z "$STATS_LISTEN" ] && [ -t 0 ]; then
|
||||
read -r -p 'NAT 内外端口是否不同?[y/N] ' MAPPING </dev/tty
|
||||
if [[ "$MAPPING" =~ ^[yY]$ ]]; then
|
||||
HY2_LISTEN=$(read_port 'HY2 本机 UDP 监听端口:')
|
||||
STATS_LISTEN=$(read_port '流量统计本机 TCP 监听端口:')
|
||||
fi
|
||||
fi
|
||||
HY2_LISTEN=${HY2_LISTEN:-$HY2_PORT}
|
||||
STATS_LISTEN=${STATS_LISTEN:-$STATS_PORT}
|
||||
for port in "$HY2_PORT" "$STATS_PORT" "$HY2_LISTEN" "$STATS_LISTEN"; do
|
||||
if ! [[ "$port" =~ ^[1-9][0-9]{0,4}$ ]] || (( port > 65535 )); then echo '端口必须为 1–65535(无前导零)。'; exit 1; fi
|
||||
done
|
||||
if ! command -v curl >/dev/null || ! command -v jq >/dev/null || ! command -v openssl >/dev/null; then
|
||||
if command -v apt-get >/dev/null; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y curl jq openssl ca-certificates
|
||||
elif command -v dnf >/dev/null; then dnf install -y curl jq openssl ca-certificates
|
||||
elif command -v yum >/dev/null; then yum install -y curl jq openssl ca-certificates
|
||||
else echo '请先安装 curl、jq、openssl 和 ca-certificates。'; exit 1; fi
|
||||
fi
|
||||
WORK=$(mktemp -d)
|
||||
STARTED=0
|
||||
cleanup() {
|
||||
local status=$?
|
||||
rm -rf "$WORK"
|
||||
if [ "$status" -ne 0 ]; then
|
||||
if [ "$STARTED" -eq 1 ]; then systemctl stop hy2-panel-node.service || true; fi
|
||||
echo '安装未完成。修复网络/端口问题后,可在凭据有效期内使用相同脚本和端口重试。' >&2
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
printf 'Authorization: Bearer %s\n' "$TOKEN" > "$WORK/headers"
|
||||
# 使用 Hysteria 官方自动化下载入口,仅允许 HTTPS。
|
||||
curl --proto '=https' --proto-redir '=https' -fSL --retry 3 --connect-timeout 20 --max-time 300 \
|
||||
"https://download.hysteria.network/app/latest/hysteria-linux-$ARCH" -o "$WORK/hysteria"
|
||||
chmod 700 "$WORK/hysteria"
|
||||
"$WORK/hysteria" version
|
||||
jq -n --arg domain "$DOMAIN" --argjson h "$HY2_PORT" --argjson s "$STATS_PORT" --argjson hl "$HY2_LISTEN" --argjson sl "$STATS_LISTEN" \
|
||||
'{action:"prepare",ports:{domain:$domain,hy2Port:$h,statsPort:$s,hy2ListenPort:$hl,statsListenPort:$sl}}' > "$WORK/request"
|
||||
api() {
|
||||
local status
|
||||
status=$(curl -sS --connect-timeout 20 --max-time 60 -o "$WORK/response" -w '%{http_code}' \
|
||||
-H @"$WORK/headers" -H 'Content-Type: application/json' --data-binary @"$WORK/request" "$PANEL/api/install")
|
||||
if [ "$status" != 200 ]; then
|
||||
jq -r '.error // "平台请求失败"' "$WORK/response" >&2 || true
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
api
|
||||
jq -er '.config' "$WORK/response" > "$WORK/config.yaml"
|
||||
jq -er '.certificate' "$WORK/response" > "$WORK/server.crt"
|
||||
jq -er '.privateKey' "$WORK/response" > "$WORK/server.key"
|
||||
jq -er '"Authorization: " + .statsSecret' "$WORK/response" > "$WORK/stats-header"
|
||||
openssl x509 -in "$WORK/server.crt" -noout -checkend 0 >/dev/null
|
||||
openssl x509 -in "$WORK/server.crt" -pubkey -noout > "$WORK/cert.pub"
|
||||
openssl pkey -in "$WORK/server.key" -pubout > "$WORK/key.pub"
|
||||
cmp -s "$WORK/cert.pub" "$WORK/key.pub" || { echo '证书与私钥不匹配。'; exit 1; }
|
||||
install -d -m 700 /etc/hysteria /usr/local/lib/hy2-panel
|
||||
# 仅允许重试本安装记录生成的配置。
|
||||
printf '%s' "$INSTALL_ID" > /etc/hysteria/.panel-install-id
|
||||
systemctl stop hy2-panel-node.service 2>/dev/null || true
|
||||
install -m 755 "$WORK/hysteria" /usr/local/lib/hy2-panel/hysteria
|
||||
install -m 600 "$WORK/config.yaml" /etc/hysteria/config.yaml
|
||||
install -m 600 "$WORK/server.crt" /etc/hysteria/server.crt
|
||||
install -m 600 "$WORK/server.key" /etc/hysteria/server.key
|
||||
cat > /etc/systemd/system/hy2-panel-node.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=Hysteria 2 (HY2 Panel)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/lib/hy2-panel/hysteria server --config /etc/hysteria/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=1048576
|
||||
NoNewPrivileges=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
PrivateTmp=true
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl start hy2-panel-node.service
|
||||
STARTED=1
|
||||
READY=0
|
||||
for attempt in {1..15}; do
|
||||
if systemctl is-active --quiet hy2-panel-node.service && \
|
||||
curl -fsS --max-time 2 -H @"$WORK/stats-header" "http://127.0.0.1:$STATS_LISTEN/traffic" -o /dev/null; then
|
||||
READY=1; break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[ "$READY" -eq 1 ] || { echo '服务健康检查失败,请检查端口占用和 systemctl status hy2-panel-node。'; exit 1; }
|
||||
# 完成通知可能已被平台处理但响应丢失;保留已健康启动的服务,避免误停。
|
||||
systemctl enable hy2-panel-node.service
|
||||
STARTED=0
|
||||
printf '{"action":"complete"}' > "$WORK/request"
|
||||
# 重试完成通知,平台支持幂等确认。
|
||||
for attempt in {1..3}; do
|
||||
if api; then break; fi
|
||||
[ "$attempt" -lt 3 ] || exit 1
|
||||
sleep 2
|
||||
done
|
||||
STARTED=0
|
||||
echo "安装完成,节点 $SLUG 已注册并启用。"
|
||||
echo "请在 NAT / 云防火墙中映射 UDP $HY2_PORT → $HY2_LISTEN、TCP $STATS_PORT → $STATS_LISTEN。"
|
||||
echo '流量统计 TCP 端口建议仅允许面板服务器 IP 访问。公网连通情况请在平台查看同步状态。'
|
||||
Reference in New Issue
Block a user