#!/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" <