diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0da4d5a..3bb223c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,8 @@ jobs: npm run build - name: Validate compose run: docker compose config --quiet + - name: Validate offsite backup authentication and round trip + run: bash deploy/server/test-sync-backup-offsite.sh - name: Resolve immutable build identity run: | openapi_version="$(awk '/^ version:/{print $2; exit}' openapi/openapi.yaml)" diff --git a/deploy/server/README.md b/deploy/server/README.md index 71e12ba..60c7a07 100644 --- a/deploy/server/README.md +++ b/deploy/server/README.md @@ -65,4 +65,4 @@ chmod 0755 deploy/server/install-ossutil.sh \ sudo deploy/server/install-offsite-backup.sh ``` -安装后默认为禁用状态,不会访问OSS。创建私有Bucket并绑定最小权限ECS RAM角色后,在`/etc/data-terminal-cloud/offsite-backup.env`填写Bucket和前缀、设置`OFFSITE_BACKUP_ENABLED=true`与`OFFSITE_BACKUP_REQUIRED=true`,再手工启动一次`platform-offsite-backup.service`。任务会上传加密归档及摘要、重新下载并复算SHA-256;健康检查在启用后强制要求最新本机归档已有对应远端验证。配置和真实验收步骤见[《阿里云OSS异地备份适配与门禁》](../../docs/offsite-backup.md)。 +安装后默认为禁用状态,不会访问OSS。默认认证方式为`ecs_ram_role`,使用`cn-hangzhou`地域端点并强制先通过IMDSv2确认实例角色,不保存长期AccessKey。创建私有Bucket并绑定最小权限ECS RAM角色后,在`/etc/data-terminal-cloud/offsite-backup.env`填写Bucket和前缀、设置`OFFSITE_BACKUP_ENABLED=true`与`OFFSITE_BACKUP_REQUIRED=true`,再手工启动一次`platform-offsite-backup.service`。任务会上传加密归档及摘要、重新下载并复算SHA-256;健康检查在启用后强制要求最新本机归档已有对应远端验证。配置和真实验收步骤见[《阿里云OSS异地备份适配与门禁》](../../docs/offsite-backup.md)。 diff --git a/deploy/server/install-offsite-backup.sh b/deploy/server/install-offsite-backup.sh index 00277fa..099ca1a 100755 --- a/deploy/server/install-offsite-backup.sh +++ b/deploy/server/install-offsite-backup.sh @@ -16,6 +16,9 @@ OFFSITE_BACKUP_ENABLED=false OFFSITE_BACKUP_REQUIRED=false OFFSITE_OSS_BUCKET= OFFSITE_OSS_PREFIX=data-terminal-cloud/121.41.46.238 +OFFSITE_OSS_AUTH_MODE=ecs_ram_role +OFFSITE_OSS_REGION=cn-hangzhou +OFFSITE_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com OFFSITE_OSS_CONFIG_FILE=/etc/data-terminal-cloud/ossutilconfig EOF fi diff --git a/deploy/server/sync-backup-offsite.sh b/deploy/server/sync-backup-offsite.sh index 16bf9a4..a244985 100755 --- a/deploy/server/sync-backup-offsite.sh +++ b/deploy/server/sync-backup-offsite.sh @@ -74,7 +74,11 @@ source "$offsite_config_file" offsite_enabled=${OFFSITE_BACKUP_ENABLED:-false} bucket=${OFFSITE_OSS_BUCKET:-} prefix=${OFFSITE_OSS_PREFIX:-data-terminal-cloud} +auth_mode=${OFFSITE_OSS_AUTH_MODE:-config} +oss_region=${OFFSITE_OSS_REGION:-} +oss_endpoint=${OFFSITE_OSS_ENDPOINT:-} oss_config=${OFFSITE_OSS_CONFIG_FILE:-/etc/data-terminal-cloud/ossutilconfig} +ecs_metadata_base=${ECS_METADATA_BASE_URL:-http://100.100.100.200/latest} if [[ "$offsite_enabled" != true ]]; then write_status disabled not_enabled @@ -84,7 +88,35 @@ fi [[ "$bucket" =~ ^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$ ]] || fail_sync invalid_bucket 2 [[ "$prefix" =~ ^[A-Za-z0-9._/-]+$ && "$prefix" != /* && "$prefix" != *..* ]] || fail_sync invalid_prefix 2 [[ -x "$ossutil_bin" ]] || fail_sync ossutil_missing 3 -[[ -r "$oss_config" ]] || fail_sync oss_config_missing 4 + +checkpoint_dir="$state_dir/checkpoints" +common=(-f --job 1 --parallel 1 --checkpoint-dir "$checkpoint_dir" --output-format json) +case "$auth_mode" in + config) + [[ -r "$oss_config" ]] || fail_sync oss_config_missing 4 + common+=(-c "$oss_config") + ;; + ecs_ram_role) + command -v curl >/dev/null 2>&1 || fail_sync curl_missing 3 + [[ "$oss_region" =~ ^[a-z0-9][a-z0-9-]{1,62}$ ]] || fail_sync invalid_oss_region 2 + if [[ -z "$oss_endpoint" ]]; then + oss_endpoint="oss-$oss_region.aliyuncs.com" + fi + [[ "$oss_endpoint" =~ ^[a-z0-9][a-z0-9.-]{1,251}[a-z0-9]$ && "$oss_endpoint" != *..* ]] || fail_sync invalid_oss_endpoint 2 + metadata_token=$(curl --silent --show-error --fail --max-time 3 -X PUT \ + -H 'X-aliyun-ecs-metadata-token-ttl-seconds: 60' "$ecs_metadata_base/api/token" || true) + [[ -n "$metadata_token" ]] || fail_sync ecs_metadata_v2_unavailable 4 + role_response="$transfer_dir/ecs-role-name" + role_code=$(curl --silent --show-error --max-time 3 --output "$role_response" --write-out '%{http_code}' \ + -H "X-aliyun-ecs-metadata-token: $metadata_token" "$ecs_metadata_base/meta-data/ram/security-credentials/" || true) + role_name=$(tr -d '\r\n' <"$role_response") + [[ "$role_code" == 200 && "$role_name" =~ ^[A-Za-z0-9@._-]{1,64}$ ]] || fail_sync ecs_ram_role_missing 4 + common+=(--mode EcsRamRole --region "$oss_region" --endpoint "$oss_endpoint") + ;; + *) + fail_sync invalid_auth_mode 2 + ;; +esac latest_backup=$(find "$backup_root" -maxdepth 1 -type f -name 'platform-*.tar.gz.enc' -printf '%T@ %p\n' 2>/dev/null | sort -nr | head -n1 | cut -d' ' -f2- || true) [[ -n "$latest_backup" && -f "$latest_backup" ]] || fail_sync local_backup_missing 5 @@ -99,10 +131,8 @@ source_size=$(stat -c %s "$latest_backup") prefix=${prefix%/} remote_archive="oss://$bucket/$prefix/$source_name" remote_checksum="$remote_archive.sha256" -checkpoint_dir="$state_dir/checkpoints" mkdir -p "$checkpoint_dir" -common=(-f -c "$oss_config" --job 1 --parallel 1 --checkpoint-dir "$checkpoint_dir" --output-format json) if ! "$ossutil_bin" cp "$latest_backup" "$remote_archive" "${common[@]}" --metadata "sha256=$source_sha" >/dev/null; then fail_sync archive_upload_failed 6 fi diff --git a/deploy/server/test-sync-backup-offsite.sh b/deploy/server/test-sync-backup-offsite.sh new file mode 100644 index 0000000..ce92edd --- /dev/null +++ b/deploy/server/test-sync-backup-offsite.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +project_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +candidate="$project_root/deploy/server/sync-backup-offsite.sh" +bash -n "$candidate" +test_root=$(mktemp -d /var/tmp/offsite-backup-test-XXXXXX) +case "$test_root" in /var/tmp/offsite-backup-test-*) ;; *) exit 90;; esac +metadata_pid= +cleanup() { + if [[ -n "$metadata_pid" ]]; then kill "$metadata_pid" 2>/dev/null || true; fi + rm -rf -- "$test_root" +} +trap cleanup EXIT INT TERM +mkdir -p "$test_root/backups" "$test_root/state" "$test_root/mock-oss" + +role_mode_file="$test_root/role-mode" +port_file="$test_root/metadata-port" +printf 'missing\n' >"$role_mode_file" +python3 - "$port_file" "$role_mode_file" <<'PY' & +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import pathlib,sys +port_file=pathlib.Path(sys.argv[1]) +role_mode=pathlib.Path(sys.argv[2]) +class Handler(BaseHTTPRequestHandler): + def log_message(self,*args): pass + def do_PUT(self): + if self.path!='/latest/api/token': self.send_error(404); return + self.send_response(200); self.end_headers(); self.wfile.write(b'test-imdsv2-token') + def do_GET(self): + if self.path!='/latest/meta-data/ram/security-credentials/': self.send_error(404); return + if role_mode.read_text(encoding='utf-8').strip()!='attached': self.send_error(404); return + if self.headers.get('X-aliyun-ecs-metadata-token')!='test-imdsv2-token': self.send_error(401); return + self.send_response(200); self.end_headers(); self.wfile.write(b'DataTerminalCloudOssRole') +server=ThreadingHTTPServer(('127.0.0.1',0),Handler) +port_file.write_text(str(server.server_port),encoding='utf-8') +server.serve_forever() +PY +metadata_pid=$! +for _ in $(seq 1 50); do [[ -s "$port_file" ]] && break; sleep 0.1; done +[[ -s "$port_file" ]] +metadata_base="http://127.0.0.1:$(<"$port_file")/latest" + +cat >"$test_root/enabled-role.env" <<'EOF' +OFFSITE_BACKUP_ENABLED=true +OFFSITE_BACKUP_REQUIRED=true +OFFSITE_OSS_BUCKET=valid-private-backup-bucket +OFFSITE_OSS_PREFIX=data-terminal-cloud/test +OFFSITE_OSS_AUTH_MODE=ecs_ram_role +OFFSITE_OSS_REGION=cn-hangzhou +OFFSITE_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +EOF +set +e +ECS_METADATA_BASE_URL="$metadata_base" OFFSITE_CONFIG_FILE="$test_root/enabled-role.env" OFFSITE_STATE_DIR="$test_root/state" BACKUP_ROOT="$test_root/backups" \ + "$candidate" >/dev/null 2>/dev/null +result=$? +set -e +test "$result" = 4 +python3 - "$test_root/state/status.json" <<'PY' +import json,sys +d=json.load(open(sys.argv[1],encoding='utf-8')) +assert d['status']=='failed' and d['reason']=='ecs_ram_role_missing',d +PY + +cat >"$test_root/disabled.env" <<'EOF' +OFFSITE_BACKUP_ENABLED=false +EOF +rm -f "$test_root/state/status.json" +OFFSITE_CONFIG_FILE="$test_root/disabled.env" OFFSITE_STATE_DIR="$test_root/state" BACKUP_ROOT="$test_root/backups" "$candidate" >/dev/null + +printf 'encrypted-backup-fixture\n' >"$test_root/backups/platform-20260826T140000Z.tar.gz.enc" +(cd "$test_root/backups" && sha256sum platform-20260826T140000Z.tar.gz.enc >platform-20260826T140000Z.tar.gz.enc.sha256) +cat >"$test_root/mock-ossutil" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail +printf '%s\n' "$*" >>"$MOCK_OSS_LOG" +test "$1" = cp +source_path=$2 +target_path=$3 +map_path() { + if [[ "$1" == oss://* ]]; then printf '%s/%s' "$MOCK_OSS_ROOT" "${1#oss://}"; else printf '%s' "$1"; fi +} +source_local=$(map_path "$source_path") +target_local=$(map_path "$target_path") +mkdir -p "$(dirname "$target_local")" +cp "$source_local" "$target_local" +EOF +chmod 0700 "$test_root/mock-ossutil" +printf '[default]\nregion=cn-hangzhou\n' >"$test_root/ossutilconfig" + +cat >"$test_root/config-mode.env" </dev/null +python3 - "$test_root/state/status.json" <<'PY' +import json,sys +d=json.load(open(sys.argv[1],encoding='utf-8')) +assert d['status']=='verified' and d['reason']=='upload_and_download_verified',d +PY + +printf 'attached\n' >"$role_mode_file" +rm -f "$test_root/state/status.json" "$test_root/ossutil.log" +MOCK_OSS_ROOT="$test_root/mock-oss" MOCK_OSS_LOG="$test_root/ossutil.log" OSSUTIL_BIN="$test_root/mock-ossutil" \ + ECS_METADATA_BASE_URL="$metadata_base" OFFSITE_CONFIG_FILE="$test_root/enabled-role.env" OFFSITE_STATE_DIR="$test_root/state" \ + BACKUP_ROOT="$test_root/backups" "$candidate" >/dev/null +python3 - "$test_root/state/status.json" <<'PY' +import json,sys +d=json.load(open(sys.argv[1],encoding='utf-8')) +assert d['status']=='verified' and d['reason']=='upload_and_download_verified',d +PY +grep -q -- '--mode EcsRamRole' "$test_root/ossutil.log" +grep -q -- '--region cn-hangzhou' "$test_root/ossutil.log" +grep -q -- '--endpoint oss-cn-hangzhou.aliyuncs.com' "$test_root/ossutil.log" +echo 'offsite_backup_smoke=ok disabled=ok missing_role=ok config_round_trip=ok ecs_role_round_trip=ok' diff --git a/docs/development-plan.md b/docs/development-plan.md index 44999cd..5507589 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -437,9 +437,9 @@ ## 9. 当前实施状态与后续顺序 -截至2026-08-25,本地工程已经完成阶段1—7的本地开发与验证闭环。1000活跃通话、50 RPS持续30分钟、500回调/秒、500条SSE、10000媒体分片、100并发删除、隔离数据库恢复、进程/Redis恢复、供应商故障、安全扫描、真实Chrome/Edge兼容、全仓检查、跨功能烟测和无测试残留对账均已完成。后续顺序为: +截至2026-08-26,本地工程已经完成阶段1—7的本地开发与验证闭环,并在阿里云单机环境部署标准HTTPS、OIDC、加密本机备份、主机健康签名上报和七项发布候选技术门禁。1000活跃通话、50 RPS持续30分钟、500回调/秒、500条SSE、10000媒体分片、100并发删除、隔离数据库恢复、进程/Redis恢复、供应商故障、安全扫描、真实Chrome/Edge兼容、全仓检查、跨功能烟测和无测试残留对账均已完成。后续顺序为: 1. 外部基础设施探针、运营预检台账、联调问题门禁、可归档技术验收快照、试点日/周证据和v3候选证据包均已完成;生产内容安全已具备本地前置拦截、托管HTTP策略、输入/输出双检和安全失败语义;远程CI供应链已具备绑定提交、运行、14项检查、双镜像摘要和三份SBOM的不可豁免门禁。`PILOT`周期、远程CI、真机固件、真实终端RTC六通矩阵、真实Agent生成/Embedding/内容安全质量、真实告警网关、云KMS/HSM、四类生产等价恢复、七类生产基础设施和Safari精简矩阵均不可由本地模拟报告放行。下一步在目标环境逐项替换证据索引中的`PENDING`。 -2. 待真实火山RTC/豆包、大模型/Embedding账号、终端硬件和生产资源到位后,完成阶段8真机联调、家庭试点、预生产恢复与灰度发布。 +2. OSS异地备份已完成无长期AK的IMDSv2/ECS RAM角色适配;待云账号侧绑定角色、创建私有Bucket后执行真实上传回读验收。随后待真实火山RTC/豆包、大模型/Embedding账号、终端硬件、外部告警接收器和其余生产资源到位,完成阶段8真机联调、家庭试点、预生产恢复与灰度发布。 实际完成证据与未完成项持续维护在[开发状态与门禁记录](development-status.md)中;本计划只定义执行顺序和退出条件,不用计划状态替代测试证据。 diff --git a/docs/development-status.md b/docs/development-status.md index 7974b1b..e1a817f 100644 --- a/docs/development-status.md +++ b/docs/development-status.md @@ -182,7 +182,7 @@ | PostgreSQL隔离恢复 | 实际`pg_dump`/`pg_restore`到随机隔离库,迁移`22|false`、66张表、190个外键和关键计数一致;本地约5.14秒,临时库/备份已清理 | | 阿里云单机加密备份与恢复 | 目标服务器已安装每日systemd备份任务,覆盖PostgreSQL、认证Redis RDB、MinIO全部桶和暂停后的Keycloak数据目录;归档使用AES-256-CBC/PBKDF2加密、逐文件SHA-256并保留14天。首次实际归档成功,数据库从V38备份恢复到随机隔离库后`38|false|2|3|73|215`六项证据一致,归档内Redis RDB格式有效、Keycloak 3个文件一致、临时恢复库为0;同机备份尚不作为跨故障域生产恢复证据 | | 阿里云单机健康监测 | 目标服务器已安装五分钟systemd健康检查,结构化状态覆盖HTTPS、OIDC签发地址、11个常驻容器、本机/异地备份状态与摘要、备份执行结果、三个运行timer、短期IP证书剩余时间和磁盘阈值。异地适配安装后的实际正常路径22项全部healthy;把本机备份最大年龄临时设为0秒后仅`backup_freshness`进入critical并退出1,测试目录已清理且线上容器未停止。可选Webhook仅在状态变化时发送带时间戳和HMAC-SHA256的脱敏状态;真实接收器尚未配置,因此不作为值班通知送达证据 | -| 阿里云OSS异地备份适配 | 目标服务器已按官方SHA-256安装ossutil 2.3.0,新增每日OSS同步任务、加密归档及摘要上传、远端回读复算、原子状态和健康强制门禁。隔离兼容目标验证上传/回读2个文件并得到`verified`;缺身份配置退出4并记录`failed/oss_config_missing`;设为必需但缺状态时健康检查退出1并记录`critical/status_missing`。当前实例无RAM角色、Bucket和OSS配置,正式任务保持`disabled/not_enabled`且健康状态为`not_required`,不能作为真实异地备份证据 | +| 阿里云OSS异地备份适配 | 目标服务器已按官方SHA-256安装ossutil 2.3.0,新增每日OSS同步任务、加密归档及摘要上传、远端回读复算、原子状态和健康强制门禁。ECS正式路径现使用IMDSv2和`EcsRamRole`自动短期凭据,不保存长期AK;隔离配置模式验证上传/回读2个文件并得到`verified`,目标实例缺角色时退出4并记录`failed/ecs_ram_role_missing`。2026-08-26实测地域`cn-hangzhou`且角色元数据404;当前仍无RAM角色和Bucket,正式任务保持`disabled/not_enabled`且健康状态为`not_required`,不能作为真实异地备份证据 | | 进程与Redis恢复 | API/Callbacks/Worker重启恢复;停机期间Outbox仅执行一次;Redis 14个热状态键清空后由签名设备请求重建,PostgreSQL权威计数不变 | | 供应商故障恢复 | V23持久化按租户/供应商的可用状态;连续临时失败暂停新启动但停止任务继续尝试;演练确认暂停后新外呼0、停止尝试2次、历史接口200、CRITICAL告警出现,恢复后全部收敛;V23单版本回退/重升通过 | | 安全专项 | Go 1.26.6容器`govulncheck`可达漏洞0、npm高危审计0、最终镜像HIGH/CRITICAL已修复漏洞0、Gitleaks无泄漏;HTTP认证/授权/注入式输入/1MiB正文/回调签名/Nonce重放/安全头/日志过滤烟测通过 | diff --git a/docs/offsite-backup.md b/docs/offsite-backup.md index 3acff4c..ffdcfd7 100644 --- a/docs/offsite-backup.md +++ b/docs/offsite-backup.md @@ -8,7 +8,7 @@ ## 2. 当前环境状态 -目标ECS已安装并校验`ossutil 2.3.0`,客户端压缩包使用阿里云文档公布的Linux x86_64 SHA-256验证。当前实例没有绑定RAM角色,也没有配置OSS Bucket,因此: +目标ECS已安装并校验`ossutil 2.3.0`,客户端压缩包使用阿里云文档公布的Linux x86_64 SHA-256验证。2026-08-26通过IMDSv2实测实例地域为`cn-hangzhou`,RAM角色元数据返回404;当前实例没有绑定RAM角色,也没有配置OSS Bucket,因此: - `OFFSITE_BACKUP_ENABLED=false`; - 定时器已安装并保持活动,但任务只记录`disabled/not_enabled`; @@ -53,7 +53,7 @@ systemctl list-timers platform-offsite-backup.timer --no-pager 2. 优先给ECS绑定最小权限实例RAM角色,避免长期AccessKey落盘; 3. 授权该角色仅能向指定Bucket前缀上传、读取和查询备份对象; 4. 如需地域级灾备,为目标Bucket配置跨区域复制; -5. 使用ossutil交互配置或RAM角色模式生成`/etc/data-terminal-cloud/ossutilconfig`,权限必须为`0600 root:root`。 +5. 推荐直接使用脚本的`ecs_ram_role`模式;该模式通过IMDSv2确认角色存在,再让ossutil自动取得短期STS凭据,不需要AccessKey或ossutil凭据文件。 当前脚本需要上传后回读,因此至少要具备指定前缀的写入和读取权限。Bucket创建、RAM授权和跨区域复制属于云资源变更,不能通过服务器root账号替代完成。 @@ -66,9 +66,14 @@ OFFSITE_BACKUP_ENABLED=true OFFSITE_BACKUP_REQUIRED=true OFFSITE_OSS_BUCKET=your-private-backup-bucket OFFSITE_OSS_PREFIX=data-terminal-cloud/121.41.46.238 +OFFSITE_OSS_AUTH_MODE=ecs_ram_role +OFFSITE_OSS_REGION=cn-hangzhou +OFFSITE_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com OFFSITE_OSS_CONFIG_FILE=/etc/data-terminal-cloud/ossutilconfig ``` +`OFFSITE_OSS_CONFIG_FILE`只在`OFFSITE_OSS_AUTH_MODE=config`时读取,用于兼容受控配置文件或隔离测试;阿里云ECS正式路径使用`ecs_ram_role`。脚本强制通过IMDSv2获取元数据令牌,不回退IMDSv1。角色缺失时任务以`ecs_ram_role_missing`失败,不会继续读取本机归档或尝试匿名访问OSS。 + 然后执行: ```bash @@ -89,7 +94,11 @@ systemctl start platform-health-monitor.service | 原因 | 含义 | |---|---| | `ossutil_missing` | 官方客户端未安装 | +| `curl_missing` | ECS RAM角色模式缺少IMDSv2请求客户端 | | `oss_config_missing` | 身份配置不可读 | +| `ecs_metadata_v2_unavailable` | 无法取得IMDSv2元数据令牌 | +| `ecs_ram_role_missing` | ECS未绑定实例RAM角色或角色名称无效 | +| `invalid_oss_region` / `invalid_oss_endpoint` | OSS地域或端点配置不合法 | | `local_backup_missing` | 没有本机加密备份 | | `local_checksum_failed` | 本机归档已损坏 | | `archive_upload_failed` | OSS归档上传失败 | @@ -105,6 +114,8 @@ systemctl start platform-health-monitor.service - 禁用状态:任务成功记录`disabled/not_enabled`,不访问OSS; - 隔离兼容目标:上传归档和摘要、回读并复算,状态为`verified`且远端有2个文件; - 缺身份配置:退出4并记录`failed/oss_config_missing`; +- ECS RAM角色缺失:在目标阿里云实例通过IMDSv2实测退出4并记录`failed/ecs_ram_role_missing`; +- 兼容配置模式:隔离OSS替身完成上传、摘要上传、双回读和三方摘要一致校验; - 健康门禁:设为必需但缺远端状态时退出1并记录`offsite_backup=critical/status_missing`。 隔离兼容目标只验证脚本控制流与摘要闭环,不是阿里云真实Bucket证据。真实门禁必须在配置RAM角色和私有Bucket后重新执行。