From 8c3d8786808cd142229af4b1c6422e9d638aa0c0 Mon Sep 17 00:00:00 2001 From: xuans Date: Thu, 17 Sep 2026 13:05:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DLinux=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E5=99=A8=E7=9B=AE=E6=A0=87=E6=A3=80=E6=9F=A5=E4=B8=8E=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E6=96=87=E4=BB=B6=E5=A4=B1=E8=B4=A5=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/admin/installer_test.go | 4 + deploy/install-linux.sh | 105 +++++++++++-- deploy/tests/install-linux-test.py | 191 +++++++++++++++++++++++ doc/生产部署与运维说明.md | 8 + 4 files changed, 299 insertions(+), 9 deletions(-) create mode 100644 deploy/tests/install-linux-test.py diff --git a/backend/internal/admin/installer_test.go b/backend/internal/admin/installer_test.go index 82c5c53..d58ff98 100644 --- a/backend/internal/admin/installer_test.go +++ b/backend/internal/admin/installer_test.go @@ -38,6 +38,10 @@ func TestLinuxInstallerPolicy(t *testing.T) { `/scert|/scert/*`, `chown -R -h "$SERVICE_USER:$SERVICE_GROUP"`, `write_install_metadata`, + `begin_install_transaction`, + `trap finish_install_transaction EXIT`, + `mv -fT -- "$main_temporary" "$INSTALL_DIR/scert"`, + `mv -T -- "$INSTALL_DIR/web" "$transaction_dir/web-replaced"`, `load_existing_certificate_dir`, `certificate_dir_explicit=true`, `file -b -- "$source_dir/$artifact"`, diff --git a/deploy/install-linux.sh b/deploy/install-linux.sh index cf14641..893506c 100644 --- a/deploy/install-linux.sh +++ b/deploy/install-linux.sh @@ -14,6 +14,9 @@ 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' @@ -146,13 +149,28 @@ validate_managed_paths() { "$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_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_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 rm; do + 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)" @@ -165,6 +183,9 @@ validate_environment() { 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; do [[ -f "$source_dir/$artifact" && ! -L "$source_dir/$artifact" ]] || fail "安装产物必须是普通文件:$source_dir/$artifact" @@ -183,6 +204,65 @@ validate_artifacts() { [[ -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/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() { if systemctl is-active --quiet Scert.service; then fail "升级或安装前必须停止 Scert.service" @@ -251,7 +331,7 @@ write_install_metadata() { printf '{\n "certificate_root": "%s"\n}\n' "$certificate_dir" > "$temporary_file" chown root:"$SERVICE_GROUP" "$temporary_file" chmod 0640 "$temporary_file" - mv -f -- "$temporary_file" "$INSTALL_METADATA_FILE" + mv -fT -- "$temporary_file" "$INSTALL_METADATA_FILE" } install_binaries() { @@ -259,16 +339,17 @@ install_binaries() { 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 -f -- "$main_temporary" "$INSTALL_DIR/scert" - mv -f -- "$admin_temporary" "$INSTALL_DIR/scert-admin" + mv -fT -- "$main_temporary" "$INSTALL_DIR/scert" + mv -fT -- "$admin_temporary" "$INSTALL_DIR/scert-admin" } install_web() { - [[ -z "$(find "$INSTALL_DIR/web" -type l -print -quit)" ]] || fail "既有 Web 目录包含符号链接,拒绝覆盖" - [[ -z "$(find "$INSTALL_DIR/web" ! -type f ! -type d -print -quit)" ]] || fail "既有 Web 目录包含不支持的特殊文件" - cp -a "$source_dir/web/." "$INSTALL_DIR/web/" - find "$INSTALL_DIR/web" -type d -exec chown root:root {} + -exec chmod 0755 {} + - find "$INSTALL_DIR/web" -type f -exec chown root:root {} + -exec chmod 0644 {} + + 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() { @@ -321,12 +402,18 @@ main() { 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 } diff --git a/deploy/tests/install-linux-test.py b/deploy/tests/install-linux-test.py new file mode 100644 index 0000000..cd7a9c6 --- /dev/null +++ b/deploy/tests/install-linux-test.py @@ -0,0 +1,191 @@ +"""在私有挂载命名空间中验证固定路径安装器,不操作正式服务。""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + + +def run(*args, **kwargs): + return subprocess.run(args, check=True, text=True, **kwargs) + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def snapshot(root): + result = {} + for path in sorted(root.rglob("*")): + info = path.lstat() + relative = str(path.relative_to(root)) + if path.is_symlink(): + result[relative] = ["link", os.readlink(path), info.st_uid, info.st_gid] + elif path.is_file(): + result[relative] = [digest(path), info.st_mode & 0o777, info.st_uid, info.st_gid] + else: + result[relative] = ["directory", info.st_mode & 0o777, info.st_uid, info.st_gid] + return result + + +def child(args): + base = Path(args.child).resolve(strict=True) + marker = base / "namespace-marker" + if marker.read_text() != "scert-installer-test\n": + raise RuntimeError("隔离目录标识不正确") + source = base / "source" + run("mount", "--bind", str(Path(args.source).resolve(strict=True)), str(source)) + run("mount", "-o", "remount,bind,ro", str(source)) + for name, target in [("install", "/scert"), ("units", "/etc/systemd/system"), ("certificates", args.certificate_root)]: + run("mount", "--bind", str(base / name), target) + if os.stat(base / name).st_ino != os.stat(target).st_ino: + raise RuntimeError("隔离挂载未生效") + environment = dict(os.environ, PATH=str(base / "commands") + ":/usr/sbin:/usr/bin:/sbin:/bin") + command = ["bash", str(source / "install-linux.sh"), "--source-dir", str(source)] + if not (base / "install/conf/installation.json").exists(): + command.extend(["--certificate-dir", args.certificate_root]) + result = subprocess.run( + command, + env=environment, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + ) + (base / "output.txt").write_text(result.stdout) + (base / "result.json").write_text(json.dumps({"exit_code": result.returncode})) + + +def prepare(base, scenario, certificate_root): + for name in ["install/conf", "install/data", "install/log", "install/backups", "install/docs", "install/web/assets", "units/Scert.service.d", "certificates", "commands", "source"]: + (base / name).mkdir(parents=True, exist_ok=True) + (base / "namespace-marker").write_text("scert-installer-test\n") + for name in ["scert", "scert-admin", "docs/Scert-service-management.md", "docs/Scert-admin-service-management.md", "web/index.html", "web/assets/old.js"]: + (base / "install" / name).write_text("old:" + name) + (base / "install/conf/installation.json").write_text(json.dumps({"certificate_root": certificate_root}) + "\n") + for name in ["conf/scert.json", "conf/master.key", "conf/secrets.json", "data/scert.db", "backups/keep"]: + (base / "install" / name).write_text("preserve:" + name) + (base / "certificates/keep.pem").write_text("preserve:certificate") + for name in ["Scert.service", "Scert-admin.service", "Scert.service.d/10-certificate-path.conf"]: + (base / "units" / name).write_text("old:" + name) + command_log = str(base / "systemctl.log") + counter = str(base / "reload-count") + systemctl = base / "commands/systemctl" + systemctl.write_text( + '#!/usr/bin/env bash\nset -eu\n' + f'printf "%s\\n" "$*" >> "{command_log}"\n' + 'if [[ "$1" == "is-active" ]]; then exit 3; fi\n' + '[[ "$*" == "daemon-reload" ]] || exit 97\n' + + (f'if [[ ! -e "{counter}" ]]; then touch "{counter}"; exit 42; fi\n' if scenario == "reload-failure" else "") + + ('exit 42\n' if scenario == "rollback-reload-failure" else "") + ) + systemctl.chmod(0o755) + if scenario == "web-link": + (base / "install/web/assets/link").symlink_to(base / "certificates/keep.pem") + if scenario == "binary-directory": + (base / "install/scert").rename(base / "old-scert") + (base / "install/scert").mkdir() + if scenario in ["unit-failure", "fresh-failure"]: + command = base / "commands/install" + command.write_text( + '#!/usr/bin/env bash\nset -eu\n' + 'if [[ "${@: -1}" == "/etc/systemd/system/Scert-admin.service" ]]; then exit 43; fi\n' + 'exec /usr/bin/install "$@"\n' + ) + command.chmod(0o755) + if scenario in ["fresh-failure", "fresh-success"]: + (base / "install").rename(base / "unused-install-fixture") + (base / "install").mkdir() + (base / "units").rename(base / "unused-units-fixture") + (base / "units").mkdir() + + +def parent(args): + if sys.platform != "linux" or os.geteuid() != 0: + raise RuntimeError("测试需要 Linux root 和私有挂载命名空间") + for target in ["/scert", args.certificate_root, "/etc/systemd/system"]: + if not Path(target).is_dir() or Path(target).is_symlink(): + raise RuntimeError("测试挂载目标必须是既有普通目录") + run("getent", "passwd", "Scert", stdout=subprocess.DEVNULL) + run("unshare", "--mount", "--propagation", "private", "true") + output = Path(args.output).resolve(strict=True) + for target in [Path("/scert"), Path(args.certificate_root), Path("/etc/systemd/system")]: + if output == target or target in output.parents: + raise RuntimeError("测试产物不能位于被隔离的挂载点内") + source = Path(args.source).resolve(strict=True) + failures = [] + for scenario in args.scenarios.split(","): + if scenario not in ["web-link", "binary-directory", "unit-failure", "reload-failure", "rollback-reload-failure", "success", "fresh-failure", "fresh-success"]: + raise RuntimeError("未知测试场景") + base = Path(tempfile.mkdtemp(prefix=scenario + "-", dir=output)) + prepare(base, scenario, args.certificate_root) + protected = ["conf/scert.json", "conf/master.key", "conf/secrets.json", "data/scert.db", "backups/keep"] + if scenario.startswith("fresh-"): + protected = [] + before_protected = {name: digest(base / "install" / name) for name in protected} + before_install = snapshot(base / "install") + before_units = snapshot(base / "units") + before_certificates = snapshot(base / "certificates") + run("unshare", "--mount", "--propagation", "private", sys.executable, str(Path(__file__).resolve()), + "--source", str(source), "--child", str(base), "--certificate-root", args.certificate_root) + code = json.loads((base / "result.json").read_text())["exit_code"] + errors = [] + if (code == 0) != (scenario in ["success", "fresh-success"]): + errors.append("退出码不符合预期") + if {name: digest(base / "install" / name) for name in protected} != before_protected: + errors.append("业务文件被修改") + if snapshot(base / "certificates") != before_certificates: + errors.append("证书文件被修改") + if scenario in ["success", "fresh-success"]: + for name in ["scert", "scert-admin", "web/index.html"]: + if digest(base / "install" / name) != digest(source / name): + errors.append("安装文件内容不正确:" + name) + if (base / "install/web/assets/old.js").exists(): + errors.append("旧前端资源仍在生效目录") + else: + after = snapshot(base / "install") + for name, expected in before_install.items(): + # 安装器会统一业务目录权限,仅比较发布文件与安装记录。 + if name in ["scert", "scert-admin", "conf/installation.json"] or name.startswith(("web/", "docs/")): + if after.get(name) != expected: + errors.append("失败后发布文件未保留:" + name) + if scenario == "fresh-failure": + for name in ["scert", "scert-admin", "web", "conf/installation.json"]: + if (base / "install" / name).exists(): + errors.append("首次安装失败仍残留生效文件:" + name) + if any(path.is_file() for path in (base / "units").rglob("*")): + errors.append("首次安装失败仍残留服务文件") + elif snapshot(base / "units") != before_units: + errors.append("失败后服务文件未恢复") + for path in (base / "install").glob(".install-*"): + if path.stat().st_mode & 0o777 != 0o700 or path.stat().st_uid != 0: + errors.append("升级副本目录权限不正确") + text = (base / "output.txt").read_text() + if scenario == "rollback-reload-failure" and "安装回退不完整,禁止启动服务" not in text: + errors.append("回退失败缺少明确提示") + if scenario in ["unit-failure", "reload-failure", "fresh-failure"] and "已恢复升级前发布文件" not in text: + errors.append("缺少回退完成记录") + command_log = base / "systemctl.log" + calls = command_log.read_text().splitlines() if command_log.exists() else [] + allowed = {"is-active --quiet Scert.service", "is-active --quiet Scert-admin.service", "daemon-reload"} + if any(call not in allowed for call in calls): + errors.append("出现不允许的服务操作") + report = {"scenario": scenario, "exit_code": code, "errors": errors, "artifacts": str(base)} + (base / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2)) + print(json.dumps(report, ensure_ascii=False), flush=True) + failures.extend(errors) + if failures: + raise SystemExit(1) + + +parser = argparse.ArgumentParser() +parser.add_argument("--source", required=True) +parser.add_argument("--output") +parser.add_argument("--child") +parser.add_argument("--certificate-root", required=True) +parser.add_argument("--scenarios", default="web-link,binary-directory,unit-failure,reload-failure,rollback-reload-failure,success,fresh-failure,fresh-success") +arguments = parser.parse_args() +if arguments.child: + child(arguments) +else: + parent(arguments) diff --git a/doc/生产部署与运维说明.md b/doc/生产部署与运维说明.md index 730f40b..f668c89 100644 --- a/doc/生产部署与运维说明.md +++ b/doc/生产部署与运维说明.md @@ -150,6 +150,14 @@ sudo systemctl status Scert --no-pager -l 没有显式传入 `--certificate-dir` 时,升级会读取 `/scert/conf/installation.json` 并保留既有证书根目录。升级不得覆盖 `/scert/conf/scert.json`、数据库、主密钥、敏感配置、证书和备份。 +安装器在任何发布文件写入前检查既有目标类型;文件目标不能是目录,Web 目录不能包含符号链接或特殊文件。升级前发布文件会保留在 root 所有、权限 `0700` 的 `/scert/.install-XXXXXXXX` 中,`targets.txt` 按行记录目标,`before-0` 等编号与行号减一对应。不要删除该目录,确认新版本业务验收完成后再按保留策略人工处理。 + +Web 使用整目录替换,旧资源不会继续留在当前 Web 目录中;原目录和升级副本均保留。安装器捕获非零退出、INT 和 TERM 时会尝试恢复安装记录、两个程序、Web、两个 unit、证书目录 drop-in 和两份服务说明,并重新加载 systemd。失败现场保存在同一目录的 `failed-*` 中,不启动或启用服务。若提示“安装回退不完整,禁止启动服务”,必须先核查保留目录与 systemd 状态,不能继续执行启动命令。 + +此回退仅涵盖安装中的发布文件变更,不回退服务用户创建、既有业务目录权限规范化,不替代停服数据库备份;断电、SIGKILL、磁盘故障及安装成功后启动新程序造成的数据迁移,也不保证自动恢复。需要回到旧版本时,应保留现场,使用已校验的旧版完整发布包重新安装;若新程序已经改写数据库,应先按对应版本的备份恢复流程处理,不得仅覆盖可执行文件。 + +仓库提供 `deploy/tests/install-linux-test.py`,仅用于 Linux root 下的隔离文件安装测试。它以私有挂载命名空间映射固定安装路径,使用新建测试目录和 systemctl 故障注入桩,保留全部产物;要求指定既有普通证书挂载点及挂载点之外的产物目录。该测试不能代替真实 systemd 升级、启动、就绪检查和完整业务回退验收。 + ### 4.3 systemd 安全边界 主服务使用独立 `Scert` 用户、`UMask=0077`、`NoNewPrivileges`、私有设备和临时目录、内核与控制组保护、只读系统目录和受限地址族。安装后必须验证: