From 8ea2fd028e0058154a9e82fc201a1c04dfc34589 Mon Sep 17 00:00:00 2001 From: Eeveid <448859157@qq.com> Date: Mon, 25 May 2026 10:35:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8F=91=E5=B8=83=E5=8C=85?= =?UTF-8?q?=E6=89=93=E5=8C=85=E5=92=8C=E5=AE=89=E8=A3=85=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 24 ++ scripts/build-release.sh | 194 +++++++++++++ scripts/install-server-release.sh | 455 ++++++++++++++++++++++++++++++ 3 files changed, 673 insertions(+) create mode 100755 scripts/build-release.sh create mode 100755 scripts/install-server-release.sh diff --git a/README.md b/README.md index b9d0165..8a6aa2e 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,30 @@ lightops/ ## 一条命令安装 Server +推荐生产环境使用“发布包安装”。目标服务器只需要下载发布包、解压并注册 systemd 服务,不需要安装 Rust、Node.js,也不需要现场编译。 + +先在构建机或 CI 上生成发布包: + +```bash +bash scripts/build-release.sh --target x86_64-unknown-linux-gnu +``` + +生成的文件在 `target/releases/`,把 `lightops-*.tar.gz` 上传到 Gitea Release、对象存储或任意可访问的下载地址。然后在目标 Linux 服务器上执行: + +```bash +curl -fsSL https://gitea.kmux.cn/Eeveid/lightOps/raw/branch/main/scripts/install-server-release.sh | bash -s -- --url https://example.com/lightops.tar.gz +``` + +如果同时提供 SHA256: + +```bash +curl -fsSL https://gitea.kmux.cn/Eeveid/lightOps/raw/branch/main/scripts/install-server-release.sh | bash -s -- --url https://example.com/lightops.tar.gz --sha256 +``` + +发布包安装脚本会随机选择未占用端口,生成随机管理员账号和密码,并输出内网地址、本机地址、公网地址、端口、用户名和密码。首次凭据会保存到 `/etc/lightops/initial-admin.txt`。 + +源码安装适合开发环境或没有发布包时使用,会在目标服务器现场安装 Rust、Node.js 并编译。 + 在目标 Linux 服务器上使用 root 执行: ```bash diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..8f546be --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +VERSION="" +TARGET="" +OUTPUT_DIR="target/releases" +SKIP_BUILD="false" + +usage() { + cat <<'EOF' +LightOps 发布包打包脚本 + +用法: + bash scripts/build-release.sh [选项] + +选项: + --version 发布版本,默认读取 Cargo.toml workspace 版本并追加 git 短提交 + --target Rust target triple,例如 x86_64-unknown-linux-gnu + --output-dir 输出目录,默认 target/releases + --skip-build 跳过构建,只打包现有产物 + -h, --help 显示帮助 +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="${2:?缺少 --version 参数值}" + shift 2 + ;; + --target) + TARGET="${2:?缺少 --target 参数值}" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="${2:?缺少 --output-dir 参数值}" + shift 2 + ;; + --skip-build) + SKIP_BUILD="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "未知参数:$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +log() { + printf '\033[1;32m[LightOps]\033[0m %s\n' "$*" +} + +fail() { + printf '\033[1;31m[LightOps]\033[0m %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "缺少命令:$1" +} + +repo_root() { + git rev-parse --show-toplevel 2>/dev/null || pwd +} + +detect_version() { + local base sha + base="$(sed -nE '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p}' Cargo.toml | head -n 1)" + if [[ -z "$base" ]]; then + base="0.1.0" + fi + sha="$(git rev-parse --short HEAD 2>/dev/null || true)" + if [[ -n "$sha" ]]; then + echo "${base}-${sha}" + else + echo "$base" + fi +} + +detect_platform() { + local os arch + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m)" + case "$arch" in + x86_64|amd64) arch="x86_64" ;; + aarch64|arm64) arch="aarch64" ;; + esac + echo "${arch}-${os}" +} + +ROOT="$(repo_root)" +cd "$ROOT" + +require_cmd tar +if [[ "$SKIP_BUILD" != "true" ]]; then + require_cmd npm + require_cmd cargo +fi + +if [[ -z "$VERSION" ]]; then + VERSION="$(detect_version)" +fi + +PLATFORM="${TARGET:-$(detect_platform)}" +PACKAGE_NAME="lightops-${VERSION}-${PLATFORM}" +STAGE_DIR="target/release-package/${PACKAGE_NAME}" +ARCHIVE_PATH="${OUTPUT_DIR}/${PACKAGE_NAME}.tar.gz" + +if [[ "$SKIP_BUILD" != "true" ]]; then + log "构建前端" + cd "$ROOT/web" + if [[ -f package-lock.json ]]; then + npm ci + else + npm install + fi + npm run build + + log "构建 Rust 二进制" + cd "$ROOT" + if [[ -n "$TARGET" ]]; then + cargo build --release --target "$TARGET" -p lightops-server -p lightops-agent + BIN_DIR="target/${TARGET}/release" + else + cargo build --release -p lightops-server -p lightops-agent + BIN_DIR="target/release" + fi +else + cd "$ROOT" + if [[ -n "$TARGET" ]]; then + BIN_DIR="target/${TARGET}/release" + else + BIN_DIR="target/release" + fi +fi + +SERVER_BIN="${BIN_DIR}/lightops-server" +AGENT_BIN="${BIN_DIR}/lightops-agent" +if [[ "$(uname -s)" == MINGW* || "$(uname -s)" == MSYS* || "$(uname -s)" == CYGWIN* ]]; then + [[ -f "${SERVER_BIN}.exe" ]] && SERVER_BIN="${SERVER_BIN}.exe" + [[ -f "${AGENT_BIN}.exe" ]] && AGENT_BIN="${AGENT_BIN}.exe" +fi + +[[ -f "$SERVER_BIN" ]] || fail "缺少 lightops-server 构建产物:$SERVER_BIN" +[[ -f "$AGENT_BIN" ]] || fail "缺少 lightops-agent 构建产物:$AGENT_BIN" +[[ -d "$ROOT/web/dist" ]] || fail "缺少前端构建产物:web/dist" + +log "整理发布目录:$STAGE_DIR" +rm -rf "$STAGE_DIR" +mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/web" "$STAGE_DIR/scripts" "$STAGE_DIR/config" "$OUTPUT_DIR" + +install -m 0755 "$SERVER_BIN" "$STAGE_DIR/bin/lightops-server" +install -m 0755 "$AGENT_BIN" "$STAGE_DIR/bin/lightops-agent" +cp -a "$ROOT/web/dist" "$STAGE_DIR/web/dist" +cp -a "$ROOT/store" "$STAGE_DIR/store" +install -m 0755 "$ROOT/scripts/install-server-release.sh" "$STAGE_DIR/scripts/install-server-release.sh" +install -m 0755 "$ROOT/scripts/install-agent.sh" "$STAGE_DIR/scripts/install-agent.sh" +install -m 0755 "$ROOT/scripts/upgrade-agent.sh" "$STAGE_DIR/scripts/upgrade-agent.sh" +install -m 0755 "$ROOT/scripts/uninstall-agent.sh" "$STAGE_DIR/scripts/uninstall-agent.sh" +install -m 0755 "$ROOT/scripts/update-from-git.sh" "$STAGE_DIR/scripts/update-from-git.sh" +cp "$ROOT/config/server.toml.example" "$STAGE_DIR/config/server.toml.example" + +cat >"$STAGE_DIR/RELEASE.txt" </dev/null || echo unknown) +EOF + +log "生成压缩包:$ARCHIVE_PATH" +tar -C "$(dirname "$STAGE_DIR")" -czf "$ARCHIVE_PATH" "$(basename "$STAGE_DIR")" + +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$ARCHIVE_PATH" >"${ARCHIVE_PATH}.sha256" +elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$ARCHIVE_PATH" >"${ARCHIVE_PATH}.sha256" +fi + +cat < +EOF diff --git a/scripts/install-server-release.sh b/scripts/install-server-release.sh new file mode 100755 index 0000000..ac234fc --- /dev/null +++ b/scripts/install-server-release.sh @@ -0,0 +1,455 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +PACKAGE_URL="" +SHA256="" +INSTALL_DIR="/opt/lightops" +CONFIG_DIR="/etc/lightops" +BIND_ADDR="" +BIND_ADDR_SET="false" +BIND_ADDR_SOURCE="auto" +PUBLIC_URL="" +PUBLIC_URL_SET="false" +PUBLIC_URL_SOURCE="auto" +ADMIN_USER="" +ADMIN_PASS="" + +usage() { + cat <<'EOF' +LightOps Server 发布包安装脚本 + +用法: + bash install-server-release.sh --url <发布包下载地址> [选项] + +选项: + --url lightops-*.tar.gz 下载地址,必填 + --sha256 发布包 SHA256,可选 + --install-dir 安装目录,默认 /opt/lightops + --config-dir 配置目录,默认 /etc/lightops + --bind 监听地址,默认随机选择一个空闲端口 + --public-url 面板访问地址,默认根据公网 IP 或内网 IP 输出 + -h, --help 显示帮助 + +示例: + curl -fsSL https://gitea.kmux.cn/Eeveid/lightOps/raw/branch/main/scripts/install-server-release.sh | bash -s -- --url https://example.com/lightops.tar.gz +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --url) + PACKAGE_URL="${2:?缺少 --url 参数值}" + shift 2 + ;; + --sha256) + SHA256="${2:?缺少 --sha256 参数值}" + shift 2 + ;; + --install-dir) + INSTALL_DIR="${2:?缺少 --install-dir 参数值}" + shift 2 + ;; + --config-dir) + CONFIG_DIR="${2:?缺少 --config-dir 参数值}" + shift 2 + ;; + --bind) + BIND_ADDR="${2:?缺少 --bind 参数值}" + BIND_ADDR_SET="true" + BIND_ADDR_SOURCE="user" + shift 2 + ;; + --public-url) + PUBLIC_URL="${2:?缺少 --public-url 参数值}" + PUBLIC_URL_SET="true" + PUBLIC_URL_SOURCE="user" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "未知参数:$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$(id -u)" -ne 0 ]]; then + echo "请使用 root 用户运行" >&2 + exit 1 +fi + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "发布包安装当前只支持 Linux 服务器" >&2 + exit 1 +fi + +if [[ -z "$PACKAGE_URL" ]]; then + usage >&2 + exit 2 +fi + +if ! command -v systemctl >/dev/null 2>&1; then + echo "未检测到 systemd,当前安装脚本需要 systemd 管理服务" >&2 + exit 1 +fi + +case "$INSTALL_DIR" in + ""|"/"|"/opt"|"/etc"|"/usr"|"/usr/local") + echo "安装目录过于危险:$INSTALL_DIR" >&2 + exit 1 + ;; +esac + +if [[ "$INSTALL_DIR" == *" "* || "$CONFIG_DIR" == *" "* ]]; then + echo "安装目录和配置目录暂不支持空格" >&2 + exit 1 +fi + +log() { + printf '\033[1;32m[LightOps]\033[0m %s\n' "$*" +} + +fail() { + printf '\033[1;31m[LightOps]\033[0m %s\n' "$*" >&2 + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "缺少命令:$1" +} + +install_runtime_deps() { + if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y ca-certificates curl tar sqlite3 + elif command -v dnf >/dev/null 2>&1; then + dnf install -y ca-certificates curl tar sqlite + elif command -v yum >/dev/null 2>&1; then + yum install -y ca-certificates curl tar sqlite + elif command -v pacman >/dev/null 2>&1; then + pacman -Sy --noconfirm ca-certificates curl tar sqlite + fi + need_cmd curl + need_cmd tar + need_cmd sqlite3 +} + +primary_lan_ip() { + local ip + ip="$(hostname -I 2>/dev/null | awk '{print $1}')" + if [[ -z "$ip" ]]; then + ip="127.0.0.1" + fi + echo "$ip" +} + +external_ip() { + curl -fsS --max-time 3 https://api.ipify.org 2>/dev/null || true +} + +port_in_use() { + local port="$1" + if command -v ss >/dev/null 2>&1; then + ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}$" && return 0 + fi + if command -v netstat >/dev/null 2>&1; then + netstat -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}$" && return 0 + fi + return 1 +} + +random_port() { + local value + if command -v shuf >/dev/null 2>&1; then + shuf -i 20000-49999 -n 1 + return + fi + value="$(od -An -N2 -tu2 /dev/urandom 2>/dev/null | tr -d ' ')" + echo $((20000 + value % 30000)) +} + +random_token() { + local length="${1:-20}" + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex "$((length / 2 + 4))" | cut -c1-"$length" + else + od -An -N"$((length / 2 + 4))" -tx1 /dev/urandom | tr -d ' \n' | cut -c1-"$length" + fi +} + +json_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/\\n}" + printf '%s' "$value" +} + +toml_string_value() { + local key="$1" + local file="$2" + sed -nE "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*\"(.*)\"[[:space:]]*$/\\1/p" "$file" | tail -n 1 +} + +load_existing_config_values() { + local config_file existing_bind existing_public_url + config_file="$CONFIG_DIR/server.toml" + if [[ ! -f "$config_file" ]]; then + return + fi + existing_bind="$(toml_string_value "bind" "$config_file")" + existing_public_url="$(toml_string_value "public_url" "$config_file")" + if [[ "$BIND_ADDR_SOURCE" != "user" && -n "$existing_bind" ]]; then + BIND_ADDR="$existing_bind" + BIND_ADDR_SET="true" + BIND_ADDR_SOURCE="existing" + log "沿用已有监听地址:$BIND_ADDR" + fi + if [[ "$PUBLIC_URL_SET" != "true" && -n "$existing_public_url" ]]; then + PUBLIC_URL="$existing_public_url" + PUBLIC_URL_SOURCE="existing" + fi +} + +choose_bind_addr() { + local host port candidate i + if [[ "$BIND_ADDR_SET" == "true" ]]; then + port="${BIND_ADDR##*:}" + if [[ ! "$port" =~ ^[0-9]+$ || "$port" -lt 1 || "$port" -gt 65535 ]]; then + fail "监听端口无效:$BIND_ADDR" + fi + if [[ "$BIND_ADDR_SOURCE" == "user" ]] && port_in_use "$port"; then + fail "端口 $port 已被占用,请使用 --bind 指定其他端口" + fi + return + fi + host="0.0.0.0" + for i in $(seq 1 80); do + candidate="$(random_port)" + if ! port_in_use "$candidate"; then + BIND_ADDR="${host}:${candidate}" + log "已选择空闲端口:$candidate" + return + fi + done + fail "未能找到可用端口,请使用 --bind 手动指定" +} + +download_package() { + local tmp_dir archive + tmp_dir="$(mktemp -d)" + archive="$tmp_dir/lightops.tar.gz" + log "下载发布包:$PACKAGE_URL" >&2 + curl -fL --retry 3 --connect-timeout 20 -o "$archive" "$PACKAGE_URL" + if [[ -n "$SHA256" ]]; then + local actual + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$archive" | awk '{print $1}')" + else + actual="$(openssl dgst -sha256 "$archive" | awk '{print $2}')" + fi + [[ "$actual" == "$SHA256" ]] || fail "发布包 SHA256 校验失败" + fi + echo "$archive" +} + +install_package() { + local archive tmp_dir src + archive="$1" + tmp_dir="$(mktemp -d)" + tar -xzf "$archive" -C "$tmp_dir" + src="$(find "$tmp_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + [[ -n "$src" ]] || fail "发布包结构无效" + [[ -x "$src/bin/lightops-server" ]] || fail "发布包缺少 bin/lightops-server" + [[ -x "$src/bin/lightops-agent" ]] || fail "发布包缺少 bin/lightops-agent" + [[ -d "$src/web/dist" ]] || fail "发布包缺少 web/dist" + + log "安装发布包到:$INSTALL_DIR" + mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" + rm -rf "$INSTALL_DIR/web/dist" + mkdir -p "$INSTALL_DIR/bin" "$INSTALL_DIR/web" + cp -a "$src/bin/lightops-server" "$INSTALL_DIR/lightops-server" + cp -a "$src/bin/lightops-agent" "$INSTALL_DIR/lightops-agent" + cp -a "$src/bin" "$INSTALL_DIR/bin" + cp -a "$src/web/dist" "$INSTALL_DIR/web/dist" + [[ -d "$src/store" ]] && cp -a "$src/store" "$INSTALL_DIR/store" + [[ -d "$src/scripts" ]] && cp -a "$src/scripts" "$INSTALL_DIR/scripts" + chmod 0755 "$INSTALL_DIR/lightops-server" "$INSTALL_DIR/lightops-agent" + install -m 0755 "$INSTALL_DIR/lightops-server" /usr/local/bin/lightops-server + install -m 0755 "$INSTALL_DIR/lightops-agent" /usr/local/bin/lightops-agent +} + +write_config() { + if [[ -z "$PUBLIC_URL" ]]; then + PUBLIC_URL="http://$(primary_lan_ip):${BIND_ADDR##*:}" + fi + if [[ -f "$CONFIG_DIR/server.toml" ]]; then + log "保留已有配置:$CONFIG_DIR/server.toml" + return + fi + local jwt_secret + jwt_secret="$(random_token 64)" + cat >"$CONFIG_DIR/server.toml" </etc/systemd/system/lightops-server.service </dev/null 2>&1; then + return + fi + fi + sleep 1 + done + journalctl -u lightops-server -n 120 --no-pager >&2 || true + fail "LightOps Server 启动失败或 HTTP 接口未就绪" +} + +start_service() { + systemctl restart lightops-server + wait_service +} + +database_file() { + echo "$INSTALL_DIR/lightops.db" +} + +admin_exists() { + local db_file count + db_file="$(database_file)" + [[ -f "$db_file" ]] || return 1 + count="$(sqlite3 "$db_file" "SELECT COUNT(*) FROM users;" 2>/dev/null || echo 0)" + [[ "${count:-0}" -gt 0 ]] +} + +initialize_admin() { + local credential_file payload init_url response + credential_file="$CONFIG_DIR/initial-admin.txt" + if admin_exists; then + ADMIN_USER="" + ADMIN_PASS="" + return + fi + ADMIN_USER="lightops_$(random_token 8)" + ADMIN_PASS="$(random_token 24)" + payload="{\"username\":\"$(json_escape "$ADMIN_USER")\",\"password\":\"$(json_escape "$ADMIN_PASS")\"}" + init_url="http://127.0.0.1:${BIND_ADDR##*:}/api/auth/init" + response="$(curl -fsS --max-time 10 -H 'Content-Type: application/json' -d "$payload" "$init_url" 2>/dev/null || true)" + if [[ "$response" == *"管理员已初始化"* ]]; then + ADMIN_USER="" + ADMIN_PASS="" + return + fi + [[ "$response" == *'"success":true'* ]] || fail "管理员初始化失败" + cat >"$credential_file" <