Scert/deploy/install-linux.sh

456 lines
20 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
set -Eeuo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly INSTALL_DIR="/scert"
readonly DEFAULT_CERTIFICATE_DIR="/ssl"
readonly SERVICE_USER="Scert"
readonly SERVICE_GROUP="Scert"
readonly SERVICE_DIR="/etc/systemd/system"
readonly SERVICE_DROP_IN_DIR="$SERVICE_DIR/Scert.service.d"
readonly INSTALL_METADATA_FILE="$INSTALL_DIR/conf/installation.json"
source_dir="$SCRIPT_DIR"
certificate_dir="$DEFAULT_CERTIFICATE_DIR"
certificate_dir_explicit=false
nologin_shell=""
transaction_dir=""
transaction_active=false
declare -a transaction_targets=()
print_usage() {
cat <<'USAGE'
Scert Linux 安装器
用法:
sudo ./install-linux.sh [选项]
选项:
--source-dir DIR 安装产物目录,必须包含 scert 和 scert-admin
--certificate-dir DIR 证书根目录,默认 /ssl
-h, --help 显示帮助
Linux 安装目录固定为 /scert,证书业务目录可在首次初始化时继续配置。
安装器只负责安装文件、创建运行用户和目录、注册 systemd unit,并执行一次
systemctl daemon-reload。安装器不会启动或启用 Scert-admin,也不会创建公网防火墙规则。
USAGE
}
fail() {
printf '安装失败:%s\n' "$1" >&2
exit 1
}
parse_args() {
while (($# > 0)); do
case "$1" in
--source-dir)
(($# >= 2)) || fail "--source-dir 缺少参数"
source_dir="$2"
shift 2
;;
--certificate-dir)
(($# >= 2)) || fail "--certificate-dir 缺少参数"
certificate_dir="$2"
certificate_dir_explicit=true
shift 2
;;
-h|--help)
print_usage
exit 0
;;
*)
fail "不支持的参数:$1"
;;
esac
done
}
load_existing_certificate_dir() {
if [[ "$certificate_dir_explicit" == "true" || ! -e "$INSTALL_METADATA_FILE" ]]; then
return
fi
reject_symlink_path "$INSTALL_METADATA_FILE" "安装路径记录"
[[ -f "$INSTALL_METADATA_FILE" ]] || fail "安装路径记录必须是普通文件:$INSTALL_METADATA_FILE"
local line
local existing_certificate_dir=""
while IFS= read -r line; do
if [[ "$line" =~ \"certificate_root\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
existing_certificate_dir="${BASH_REMATCH[1]}"
break
fi
done < "$INSTALL_METADATA_FILE"
[[ -n "$existing_certificate_dir" ]] || fail "无法从既有安装记录读取证书目录,请显式使用 --certificate-dir"
certificate_dir="$existing_certificate_dir"
}
validate_certificate_dir() {
[[ "$certificate_dir" == /* ]] || fail "证书目录必须是绝对路径"
[[ "$certificate_dir" != "/" ]] || fail "证书目录不能是根目录"
[[ "$certificate_dir" =~ ^/[A-Za-z0-9._/-]+$ ]] || fail "证书目录只能包含字母、数字、点、下划线、连字符和斜杠"
[[ ! "$certificate_dir" =~ (^|/)\.\.(/|$) ]] || fail "证书目录不能包含上级目录片段"
reject_symlink_path "$certificate_dir" "证书目录"
certificate_dir="$(readlink -m -- "$certificate_dir")"
[[ "$certificate_dir" != "/" ]] || fail "证书目录不能是根目录"
case "$certificate_dir" in
/bin|/bin/*|/boot|/boot/*|/dev|/dev/*|/etc|/etc/*|/home|/home/*|/lib|/lib/*|/lib64|/lib64/*|/proc|/proc/*|/root|/root/*|/run|/run/*|/sbin|/sbin/*|/sys|/sys/*|/tmp|/tmp/*|/usr|/usr/*)
fail "证书目录不能位于受保护的系统路径:$certificate_dir"
;;
/var|/var/*)
case "$certificate_dir" in
/var/lib/scert|/var/lib/scert/*) ;;
*) fail "证书目录只能使用 /var/lib/scert 下的专用路径:$certificate_dir" ;;
esac
;;
/scert|/scert/*)
fail "证书目录不能覆盖 Scert 的安装、配置、数据、日志、备份或文档目录:$certificate_dir"
;;
esac
}
reject_symlink_path() {
local target_path="$1"
local description="$2"
local current_path=""
local part
local -a path_parts
IFS='/' read -r -a path_parts <<< "${target_path#/}"
for part in "${path_parts[@]}"; do
[[ -n "$part" ]] || continue
current_path="$current_path/$part"
if [[ -L "$current_path" ]]; then
fail "$description 不能包含符号链接:$current_path"
fi
done
}
validate_managed_paths() {
local managed_path
for managed_path in \
"$INSTALL_DIR" \
"$INSTALL_DIR/conf" \
"$INSTALL_DIR/data" \
"$INSTALL_DIR/log" \
"$INSTALL_DIR/backups" \
"$INSTALL_DIR/docs" \
"$INSTALL_DIR/web" \
"$INSTALL_DIR/scert" \
"$INSTALL_DIR/scert-admin" \
"$INSTALL_DIR/scert-storage" \
"$INSTALL_METADATA_FILE" \
"$INSTALL_DIR/docs/Scert-service-management.md" \
"$INSTALL_DIR/docs/Scert-admin-service-management.md" \
"$SERVICE_DIR/Scert.service" \
"$SERVICE_DIR/Scert-admin.service" \
"$SERVICE_DIR/Scert-storage.service" \
"$SERVICE_DIR/Scert-storage.socket" \
"$SERVICE_DROP_IN_DIR" \
"$SERVICE_DROP_IN_DIR/10-certificate-path.conf"; do
reject_symlink_path "$managed_path" "Scert 受管路径"
done
for managed_path in "$INSTALL_DIR" "$INSTALL_DIR/conf" "$INSTALL_DIR/data" \
"$INSTALL_DIR/log" "$INSTALL_DIR/backups" "$INSTALL_DIR/docs" \
"$INSTALL_DIR/web" "$SERVICE_DIR" "$SERVICE_DROP_IN_DIR"; do
[[ ! -e "$managed_path" || -d "$managed_path" ]] || fail "Scert 目录路径不是普通目录:$managed_path"
done
for managed_path in "$INSTALL_DIR/scert" "$INSTALL_DIR/scert-admin" "$INSTALL_DIR/scert-storage" \
"$INSTALL_METADATA_FILE" "$INSTALL_DIR/docs/Scert-service-management.md" \
"$INSTALL_DIR/docs/Scert-admin-service-management.md" \
"$SERVICE_DIR/Scert.service" "$SERVICE_DIR/Scert-admin.service" "$SERVICE_DIR/Scert-storage.service" "$SERVICE_DIR/Scert-storage.socket" \
"$SERVICE_DROP_IN_DIR/10-certificate-path.conf"; do
[[ ! -e "$managed_path" || -f "$managed_path" ]] || fail "Scert 文件路径不是普通文件:$managed_path"
done
if [[ -d "$INSTALL_DIR/web" ]]; then
[[ -z "$(find "$INSTALL_DIR/web" ! -type f ! -type d -print -quit)" ]] || fail "既有 Web 目录包含符号链接或特殊文件,拒绝覆盖"
fi
}
validate_environment() {
[[ "$(uname -s)" == "Linux" ]] || fail "此安装器只支持 Linux"
[[ "$(id -u)" -eq 0 ]] || fail "请使用 root 或 sudo 运行安装器"
local command_name
for command_name in install systemctl getent groupadd useradd id chmod chown cut grep readlink find file mv cp mktemp; do
command -v "$command_name" >/dev/null 2>&1 || fail "找不到系统命令:$command_name"
done
nologin_shell="$(command -v nologin || true)"
[[ -n "$nologin_shell" ]] || fail "找不到 nologin,无法创建禁止登录的服务用户"
load_existing_certificate_dir
validate_certificate_dir
validate_managed_paths
}
validate_artifacts() {
[[ -d "$source_dir" ]] || fail "安装产物目录不存在:$source_dir"
source_dir="$(cd -- "$source_dir" && pwd -P)"
case "$source_dir/" in
"$INSTALL_DIR/"|"$INSTALL_DIR/web/"*) fail "不能从正在替换的安装文件或 Web 目录安装" ;;
esac
local artifact
for artifact in scert scert-admin scert-storage; do
[[ -f "$source_dir/$artifact" && ! -L "$source_dir/$artifact" ]] || fail "安装产物必须是普通文件:$source_dir/$artifact"
[[ -r "$source_dir/$artifact" && -x "$source_dir/$artifact" ]] || fail "安装产物不可读取或执行:$source_dir/$artifact"
local artifact_description
artifact_description="$(LC_ALL=C file -b -- "$source_dir/$artifact")"
[[ "$artifact_description" == *"ELF 64-bit LSB"* && "$artifact_description" == *"x86-64"* ]] || \
fail "安装产物不是 Linux amd64 ELF:$source_dir/$artifact"
done
[[ -f "$source_dir/web/index.html" && ! -L "$source_dir/web/index.html" ]] || fail "缺少普通前端入口文件:$source_dir/web/index.html"
[[ -z "$(find "$source_dir/web" -type l -print -quit)" ]] || fail "Web 安装产物不能包含符号链接"
[[ -z "$(find "$source_dir/web" ! -type f ! -type d -print -quit)" ]] || fail "Web 安装产物包含不支持的特殊文件"
[[ -f "$SCRIPT_DIR/systemd/Scert.service" && ! -L "$SCRIPT_DIR/systemd/Scert.service" ]] || fail "缺少普通 Scert systemd unit"
[[ -f "$SCRIPT_DIR/systemd/Scert-admin.service" && ! -L "$SCRIPT_DIR/systemd/Scert-admin.service" ]] || fail "缺少普通 Scert-admin systemd unit"
[[ -f "$SCRIPT_DIR/systemd/Scert-storage.service" && ! -L "$SCRIPT_DIR/systemd/Scert-storage.service" ]] || fail "缺少证书目录服务单元"
[[ -f "$SCRIPT_DIR/systemd/Scert-storage.socket" && ! -L "$SCRIPT_DIR/systemd/Scert-storage.socket" ]] || fail "缺少证书目录本机通道单元"
[[ -f "$SCRIPT_DIR/systemd/Scert-admin服务管理说明.md" && ! -L "$SCRIPT_DIR/systemd/Scert-admin服务管理说明.md" ]] || fail "缺少普通 Scert-admin 服务管理说明"
[[ -f "$SCRIPT_DIR/systemd/Scert服务管理说明.md" && ! -L "$SCRIPT_DIR/systemd/Scert服务管理说明.md" ]] || fail "缺少普通 Scert 服务管理说明"
}
begin_install_transaction() {
install -d -o root -g "$SERVICE_GROUP" -m 0750 "$INSTALL_DIR"
transaction_dir="$(mktemp -d "$INSTALL_DIR/.install-XXXXXXXX")"
chmod 0700 "$transaction_dir"
transaction_targets=(
"$INSTALL_METADATA_FILE" "$INSTALL_DIR/scert" "$INSTALL_DIR/scert-admin"
"$INSTALL_DIR/scert-storage" "$SERVICE_DIR/Scert-storage.service" "$SERVICE_DIR/Scert-storage.socket"
"$INSTALL_DIR/web" "$SERVICE_DIR/Scert.service" "$SERVICE_DIR/Scert-admin.service"
"$SERVICE_DROP_IN_DIR/10-certificate-path.conf"
"$INSTALL_DIR/docs/Scert-service-management.md"
"$INSTALL_DIR/docs/Scert-admin-service-management.md"
)
local index target
for index in "${!transaction_targets[@]}"; do
target="${transaction_targets[$index]}"
printf '%s\n' "$target" >> "$transaction_dir/targets.txt"
if [[ -e "$target" ]]; then
cp -a -- "$target" "$transaction_dir/before-$index"
fi
done
transaction_active=true
}
finish_install_transaction() {
local status=$?
trap - EXIT INT TERM
if [[ "$transaction_active" != "true" ]]; then
exit "$status"
fi
set +e
local index target
local rollback_failed=false
for ((index=${#transaction_targets[@]}-1; index>=0; index--)); do
target="${transaction_targets[$index]}"
if [[ -e "$target" || -L "$target" ]]; then
if ! mv -T -- "$target" "$transaction_dir/failed-$index"; then
printf '无法保留失败文件,请人工恢复:%s\n' "$target" >&2
rollback_failed=true
continue
fi
fi
if [[ -e "$transaction_dir/before-$index" ]]; then
if ! mv -T -- "$transaction_dir/before-$index" "$target"; then
printf '无法恢复升级前文件,请人工恢复:%s\n' "$target" >&2
rollback_failed=true
fi
fi
done
if ! reload_systemd; then
rollback_failed=true
fi
if [[ "$rollback_failed" == "true" ]]; then
printf '安装回退不完整,禁止启动服务。恢复记录:%s\n' "$transaction_dir" >&2
else
printf '安装失败,已恢复升级前发布文件,服务保持停止。失败现场:%s\n' "$transaction_dir" >&2
fi
[[ "$status" -ne 0 ]] || status=1
exit "$status"
}
ensure_services_stopped() {
local unit properties key value
local load_state active_state sub_state main_pid control_pid job
for unit in Scert.service Scert-admin.service Scert-storage.service Scert-storage.socket; do
if ! properties="$(systemctl show "$unit" -p LoadState -p ActiveState -p SubState -p MainPID -p ControlPID -p Job)"; then
fail "无法查询 $unit 的停服状态,拒绝安装"
fi
load_state="" active_state="" sub_state="" main_pid="" control_pid="" job="unknown"
while IFS='=' read -r key value; do
case "$key" in
LoadState) load_state="$value" ;;
ActiveState) active_state="$value" ;;
SubState) sub_state="$value" ;;
MainPID) main_pid="$value" ;;
ControlPID) control_pid="$value" ;;
Job) job="$value" ;;
esac
done <<< "$properties"
# 查询失败、过渡状态或排队任务均不能作为已停服的证据。
case "$load_state:$active_state:$sub_state" in
loaded:inactive:dead|loaded:failed:failed|not-found:inactive:dead) ;;
*) fail "升级或安装前必须确认 $unit 已停止,当前状态:$load_state/$active_state/$sub_state" ;;
esac
if [[ "$unit" == *.socket && -z "$main_pid" ]]; then
main_pid="0"
fi
[[ "$main_pid" == "0" && "$control_pid" == "0" && -z "$job" ]] || \
fail "$unit 仍有进程、排队任务或缺失状态,拒绝安装"
done
}
ensure_service_account() {
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
if ! groupadd --system "$SERVICE_GROUP"; then
if groupadd --help 2>&1 | grep -q -- '--badname'; then
groupadd --system --badname "$SERVICE_GROUP"
else
fail "当前系统无法创建名称为 $SERVICE_GROUP 的系统用户组"
fi
fi
fi
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
if ! useradd --system --gid "$SERVICE_GROUP" --home-dir "$INSTALL_DIR" --shell "$nologin_shell" "$SERVICE_USER"; then
if useradd --help 2>&1 | grep -q -- '--badname'; then
useradd --system --badname --gid "$SERVICE_GROUP" --home-dir "$INSTALL_DIR" --shell "$nologin_shell" "$SERVICE_USER"
else
fail "当前系统无法创建名称为 $SERVICE_USER 的系统用户"
fi
fi
fi
[[ "$(id -gn "$SERVICE_USER")" == "$SERVICE_GROUP" ]] || fail "用户 $SERVICE_USER 未使用预期用户组 $SERVICE_GROUP"
local current_shell
current_shell="$(getent passwd "$SERVICE_USER" | cut -d: -f7)"
case "$current_shell" in
*/nologin|*/false) ;;
*) fail "现有用户 $SERVICE_USER 允许交互式登录,请先修正其登录 Shell" ;;
esac
}
create_directories() {
install -d -o root -g "$SERVICE_GROUP" -m 0750 "$INSTALL_DIR"
install -d -o root -g root -m 0755 "$INSTALL_DIR/docs"
install -d -o root -g root -m 0755 "$INSTALL_DIR/web"
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \
"$INSTALL_DIR/conf" \
"$INSTALL_DIR/data" \
"$INSTALL_DIR/log" \
"$INSTALL_DIR/backups"
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$certificate_dir"
chown -R -h "$SERVICE_USER:$SERVICE_GROUP" \
"$INSTALL_DIR/conf" \
"$INSTALL_DIR/data" \
"$INSTALL_DIR/log" \
"$INSTALL_DIR/backups"
chmod 0700 "$INSTALL_DIR/conf"
local sensitive_file
for sensitive_file in scert.json master.key secrets.json; do
if [[ -f "$INSTALL_DIR/conf/$sensitive_file" ]]; then
chown "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR/conf/$sensitive_file"
chmod 0600 "$INSTALL_DIR/conf/$sensitive_file"
fi
done
}
write_install_metadata() {
local temporary_file="$INSTALL_METADATA_FILE.new.$$"
printf '{\n "certificate_root": "%s"\n}\n' "$certificate_dir" > "$temporary_file"
chown root:"$SERVICE_GROUP" "$temporary_file"
chmod 0640 "$temporary_file"
mv -fT -- "$temporary_file" "$INSTALL_METADATA_FILE"
}
install_binaries() {
local main_temporary="$INSTALL_DIR/.scert.new.$$"
local admin_temporary="$INSTALL_DIR/.scert-admin.new.$$"
install -o root -g root -m 0755 "$source_dir/scert" "$main_temporary"
install -o root -g root -m 0755 "$source_dir/scert-admin" "$admin_temporary"
mv -fT -- "$main_temporary" "$INSTALL_DIR/scert"
mv -fT -- "$admin_temporary" "$INSTALL_DIR/scert-admin"
local storage_temporary="$INSTALL_DIR/.scert-storage.new.$$"
install -o root -g root -m 0755 "$source_dir/scert-storage" "$storage_temporary"
mv -fT -- "$storage_temporary" "$INSTALL_DIR/scert-storage"
}
install_web() {
local staged_web="$transaction_dir/web-new"
cp -a -- "$source_dir/web" "$staged_web"
find "$staged_web" -type d -exec chown root:root {} + -exec chmod 0755 {} +
find "$staged_web" -type f -exec chown root:root {} + -exec chmod 0644 {} +
mv -T -- "$INSTALL_DIR/web" "$transaction_dir/web-replaced"
mv -T -- "$staged_web" "$INSTALL_DIR/web"
}
install_service_files() {
install -o root -g root -m 0644 "$SCRIPT_DIR/systemd/Scert.service" "$SERVICE_DIR/Scert.service"
install -o root -g root -m 0644 "$SCRIPT_DIR/systemd/Scert-admin.service" "$SERVICE_DIR/Scert-admin.service"
install -o root -g root -m 0644 "$SCRIPT_DIR/systemd/Scert-storage.socket" "$SERVICE_DIR/Scert-storage.socket"
install -o root -g root -m 0644 "$SCRIPT_DIR/systemd/Scert-storage.service" "$SERVICE_DIR/Scert-storage.service"
# 路径已通过 ASCII 白名单验证;追加固定命令的单个参数,不执行用户提供的命令。
printf '\n[Service]\nExecStart=\nExecStart=/scert/scert-storage --root %s\n' "$certificate_dir" >> "$SERVICE_DIR/Scert-storage.service"
install -d -o root -g root -m 0755 "$SERVICE_DROP_IN_DIR"
printf '[Service]\nReadWritePaths=\nReadWritePaths=%s /scert/conf /scert/data /scert/log /scert/backups\n' \
"$certificate_dir" > "$SERVICE_DROP_IN_DIR/10-certificate-path.conf"
chmod 0644 "$SERVICE_DROP_IN_DIR/10-certificate-path.conf"
install -o root -g root -m 0644 \
"$SCRIPT_DIR/systemd/Scert服务管理说明.md" \
"$INSTALL_DIR/docs/Scert-service-management.md"
install -o root -g root -m 0644 \
"$SCRIPT_DIR/systemd/Scert-admin服务管理说明.md" \
"$INSTALL_DIR/docs/Scert-admin-service-management.md"
}
reload_systemd() {
systemctl daemon-reload
}
print_completion() {
printf '%s\n' \
'Scert Linux 文件安装完成。' \
'' \
'已安装:' \
' 核心程序:/scert/scert' \
' 密码重置程序:/scert/scert-admin' \
' 配置目录:/scert/conf' \
' 数据目录:/scert/data' \
' 日志目录:/scert/log' \
' 备份目录:/scert/backups' \
" 证书目录:$certificate_dir" \
" 安装路径记录:$INSTALL_METADATA_FILE" \
'' \
'本次只加载了 systemd unit,没有启动任何服务,也没有设置开机启动。' \
'首次初始化会读取安装路径记录,并将上述证书目录作为默认值。' \
'请先完成初始化配置。Scert-admin 需要密码重置时,管理员仅使用:' \
' systemctl start Scert-admin' \
' systemctl stop Scert-admin' \
'' \
'Scert 主服务是否启用和启动,应在初始化配置及证书部署完成后按运维流程执行。' \
'本机防火墙和云安全组端口放行由初始化流程处理或提示,不由本安装器创建公网规则。'
}
main() {
parse_args "$@"
validate_environment
validate_artifacts
ensure_services_stopped
ensure_service_account
trap finish_install_transaction EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
begin_install_transaction
create_directories
write_install_metadata
install_binaries
install_web
install_service_files
reload_systemd
transaction_active=false
printf '升级前发布文件已保留:%s\n' "$transaction_dir"
print_completion
}
main "$@"