修复Linux安装器目标检查与发布文件失败回退

This commit is contained in:
xuans 2026-09-17 13:05:14 +08:00
parent 9d3bd50251
commit 8c3d878680
4 changed files with 299 additions and 9 deletions

View File

@ -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"`,

View File

@ -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
}

View File

@ -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)

View File

@ -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`、私有设备和临时目录、内核与控制组保护、只读系统目录和受限地址族。安装后必须验证: