feat(交付): 客户侧两台服务器离线部署包

内网机/外网机各起一套栈,两台之间不开任何端口,文件只经光闸在
gate/out 与对端 gate/in 之间单向摆渡。一份 compose + 一份 config +
一份 nginx 模板,全部差异收在 .env 里。

- docker-compose.yml:5 服务只发布前端端口;app 容器 IP 锁死并由
  compose 注入 -Dcode.snowflake.workid.<IP>=<WORK_ID>,绕开兜底 99
  超雪花 5 位上限导致启动即崩的问题
- .env.iw / .env.ow:出厂配置与随机口令已填好,仅 MESHSAFE_SECRET
  故意留占位(以 MeshSafe 侧白名单为准);按 maintainer 决定原样入库
- install.sh iw|ow:按角色建 .env -> 自检 -> sha256 校验 tar -> 架构
  核对 -> docker load 后校验 tag -> 建目录/属主/SELinux 标签 -> 起栈
  并逐个等健康。修掉 pipefail 下 `grep | head -1` 静默中断、架构字段
  误读 manifest.json(实际在 Config blob)、die() 未定义先被调用
- export-images.sh:就地刷新 images/*.tar、sql、.env 的 tag 与
  SHA256SUMS,再打 dist 交付盘;tar 与 SHA256SUMS 由 .gitignore 排除
- sql/01+02:从 backend/db 的 PG 全量与初始数据覆盖拷贝。三个
  upgrade-*.sql 的 PG 段(x_upload_config、x_file_security)已在
  init-table-postgres-full.sql 内,空库跑 01->02 即完整现状
- smoke_test.py:18 条断言的现场验收,凭证全部走环境变量
- 部署说明.md:拓扑、交付清单、出厂口令表、iw/ow 差异、验收清单、
  13 条已知坑

镜像 tar 不进 git,装机包由 export-images.sh 出盘。
This commit is contained in:
KodouWZF
2026-11-02 20:40:16 +08:00
parent 3910239b87
commit 4eb67e246e
15 changed files with 3510 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# =============================================================================
# 出交付盘:在我方构建机上执行,把 deploy/offline/ 刷新成一个可直接带走的完整交付包
#
# ./scripts/export-images.sh 1.0.0 # 默认 linux/amd64
# TARGET_PLATFORM=linux/arm64 ./scripts/export-images.sh 1.0.0 # 鲲鹏/飞腾
#
# 刷新后的 deploy/offline/(现场照它装,不再依赖本仓库):
# ├── images/*.tar 五个镜像 + SHA256SUMS.txt
# ├── sql/01-init-table.sql 建表 02-init-data.sql 初始数据
# ├── .env.iw / .env.ow 内网机 / 外网机出厂配置
# └── docker-compose.yml config/ nginx/ scripts/ VERSION.txt
# 另外产出 deploy/offline/dist/sfile-offline-<版本>-<平台>.tar.gz(拷盘/刻录用)
#
# 前置:backend/target/s-file.jar 与 frontend/ 已构建好(见仓库根 README)
# =============================================================================
set -euo pipefail
VER="${1:?用法:export-images.sh <版本号>,如 1.0.0}"
PLATFORM="${TARGET_PLATFORM:-linux/amd64}"
PLATFORM_TAG="$(echo "$PLATFORM" | tr -d '/:')"
HERE="$(cd "$(dirname "$0")/.." && pwd)" # deploy/offline
ROOT="$(cd "$HERE/../.." && pwd)" # 仓库根
OUT="$HERE/dist/sfile-offline-$VER-$PLATFORM_TAG"
APP_IMAGE="sfile-app:$VER"
WEB_IMAGE="sfile-web:$VER"
PG_IMAGE="postgres:18" # 验证过的是 18.3,同大版本即可
REDIS_IMAGE="redis:8" # 验证过的是 8.6.3
MQ_IMAGE="rabbitmq:3.13-management-alpine" # 验证过的是 3.13.7
say() { printf '\033[1;36m==> %s\033[0m\n' "$1"; }
say "构建目标平台:$PLATFORM"
[ "$PLATFORM" = "linux/amd64" ] || say "非 amd64 需要 buildx/QEMU 模拟器,慢属正常"
# ---------- 1. 构建业务镜像 ----------
say "构建 $APP_IMAGE"
docker build --platform "$PLATFORM" -f "$ROOT/deploy/docker/Dockerfile.app" -t "$APP_IMAGE" "$ROOT/backend/target"
say "构建 $WEB_IMAGE(内含内网+外网两套前端,耗时较长)"
docker build --platform "$PLATFORM" -f "$ROOT/deploy/docker/Dockerfile.web" -t "$WEB_IMAGE" "$ROOT/frontend"
# ---------- 2. 基础镜像就位(本机没有才拉,避免交付时缺包) ----------
for img in "$PG_IMAGE" "$REDIS_IMAGE" "$MQ_IMAGE"; do
docker image inspect "$img" >/dev/null 2>&1 || {
say "本机没有 $img,拉取 $PLATFORM 版本"
docker pull --platform "$PLATFORM" "$img"
}
done
# ---------- 3. 镜像 tar 就地导出 ----------
say "docker save 导出镜像到 $HERE/images/(约 700M,耐心等)"
mkdir -p "$HERE/images"
rm -f "$HERE"/images/*.tar "$HERE/images/SHA256SUMS.txt"
docker save -o "$HERE/images/sfile-app.tar" "$APP_IMAGE"
docker save -o "$HERE/images/sfile-web.tar" "$WEB_IMAGE"
docker save -o "$HERE/images/postgres.tar" "$PG_IMAGE"
docker save -o "$HERE/images/redis.tar" "$REDIS_IMAGE"
docker save -o "$HERE/images/rabbitmq.tar" "$MQ_IMAGE"
( cd "$HERE/images" && sha256sum *.tar > SHA256SUMS.txt )
# ---------- 4. SQL 与 .env 就地刷新 ----------
# 建表脚本必须先于灌数据脚本执行,靠文件名前缀定序
say "刷新 sql/(来自 backend/db/)"
mkdir -p "$HERE/sql"
cp "$ROOT/backend/db/init-table-postgres-full.sql" "$HERE/sql/01-init-table.sql"
cp "$ROOT/backend/db/init-data.sql" "$HERE/sql/02-init-data.sql"
say "刷新 .env.iw / .env.ow 的镜像 tag 为 $VER"
for f in .env.iw .env.ow; do
[ -f "$HERE/$f" ] || { say "缺少 $HERE/$f,跳过"; continue; }
sed -i -E "s/^APP_IMAGE=.*/APP_IMAGE=sfile-app:$VER/; s/^WEB_IMAGE=.*/WEB_IMAGE=sfile-web:$VER/" "$HERE/$f"
done
# ---------- 5. 组交付目录并打包 ----------
say "组装 $OUT"
rm -rf "$OUT"
mkdir -p "$OUT/sql"
cp "$HERE/docker-compose.yml" "$HERE/.env.example" "$HERE/.env.iw" "$HERE/.env.ow" "$OUT/"
cp -r "$HERE/config" "$HERE/nginx" "$HERE/scripts" "$HERE/sql" "$HERE/images" "$OUT/"
cp "$HERE/.gitignore" "$OUT/" 2>/dev/null || true
{
echo "s-file 离线交付包 $VER"
echo "构建平台: $PLATFORM"
echo "导出时间: $(date '+%F %T')"
echo
echo "镜像清单(sha256 见 images/SHA256SUMS.txt):"
for t in "$OUT"/images/*.tar; do
TAGS="$(tar -xOf "$t" manifest.json | tr -d '\n' | { grep -oE '"RepoTags":\[[^]]*\]' || true; } | awk 'NR==1{print}')"
printf ' %-18s %-8s %s\n' "$(basename "$t")" "$(du -h "$t" | cut -f1)" "${TAGS:-?}"
done
echo
echo "SQL: sql/01-init-table.sql sql/02-init-data.sql"
echo
echo "现场步骤(两台服务器各执行一次):"
echo " 内网机 tar -xzf sfile-offline-$VER-$PLATFORM_TAG.tar.gz && cd sfile-offline-$VER-$PLATFORM_TAG"
echo " vi .env.iw # 至少改 MESHSAFE_SECRET,按需改口令/网段/目录"
echo " ./scripts/install.sh iw"
echo " 外网机 同上,改成 ./scripts/install.sh ow"
echo " 验证 python3 scripts/smoke_test.py"
} > "$OUT/VERSION.txt"
say "打包交付盘"
mkdir -p "$HERE/dist"
tar -czf "$OUT.tar.gz" -C "$(dirname "$OUT")" "$(basename "$OUT")"
du -h "$OUT.tar.gz" | awk '{print " 交付盘: "$1" "$2}'
echo " 两台服务器各自解压同一份盘,装完只差 install.sh 的参数(iw / ow)"

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env bash
# =============================================================================
# s-file 客户侧一键安装(在每台服务器上各执行一次)
#
# cd /opt/sfile && ./scripts/install.sh iw # 内网机
# cd /opt/sfile && ./scripts/install.sh ow # 外网机
#
# 首次执行会按角色把交付包里的 .env.iw / .env.ow 拷成 .env;之后改 .env 即可,
# 想回到交付默认值就删掉 .env 重跑。
#
# 做五件事:按角色选配置 -> 环境自检 -> 载入镜像 tar -> 建数据目录 -> 起栈并等到健康
# 幂等:重复执行只重建有差异的容器,不动已初始化好的数据库
# =============================================================================
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
cd "$HERE"
ROLE_ARG="${1:-}"
envget() { grep -E "^[[:space:]]*$1=" .env | tail -1 | cut -d= -f2- | tr -d '"' | sed 's/[[:space:]]*$//'; }
step() { printf '\n\033[1;36m==> %s\033[0m\n' "$1"; }
die() { printf '\033[1;31m!!! %s\033[0m\n' "$1" >&2; exit 1; }
case "$ROLE_ARG" in
""|iw|ow) ;;
*) die "未知角色「$ROLE_ARG」,只支持 iw(内网机)或 ow(外网机)" ;;
esac
# ---------- 0. 配置就位 ----------
if [ ! -f .env ]; then
case "$ROLE_ARG" in
iw|ow)
[ -f ".env.$ROLE_ARG" ] || die "缺少 .env.$ROLE_ARG(交付包不完整)"
step "按角色初始化配置:cp .env.$ROLE_ARG .env"
cp ".env.$ROLE_ARG" .env
;;
"") die "用法:./scripts/install.sh iw|ow(iw=内网机,ow=外网机)" ;;
esac
elif [ -n "$ROLE_ARG" ]; then
# 已有 .env 时不让角色参数悄悄覆盖现场改动
CUR_ROLE="$(envget SFILE_ROLE)"
[ "$CUR_ROLE" = "$ROLE_ARG" ] \
|| die ".env 已存在且角色是 $CUR_ROLE,与参数 $ROLE_ARG 不符;确认要换角色请先 rm .env"
fi
# ---------- 0. 前置自检 ----------
step "检查 docker 与 compose"
command -v docker >/dev/null 2>&1 || die "未安装 docker,请先装 Docker Engine(信创系统用厂商源,版本号 >= 20.10)"
docker info >/dev/null 2>&1 || die "docker 守护进程不可用,检查服务是否启动、当前用户是否在 docker 组"
if docker compose version >/dev/null 2>&1; then
DC="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DC="docker-compose"
else
die "缺少 compose:安装 docker compose v2 插件,或用 docker-compose 命令"
fi
echo " 使用:$DC"
DOCKER_VER="$(docker version --format '{{.Server.Version}}')"
echo " docker server:$DOCKER_VER"
[ -f .env ] || die "缺少 .env —— 先执行:cp .env.example .env && vi .env"
if grep -q "CHANGE_ME" .env; then
# MeshSafe 的 secret 只有真的启用检测时才必须填,否则客户没接 MeshSafe 也装不了机
if [ "$(envget MESHSAFE_ENABLED)" = "true" ] && grep -qE "^MESHSAFE_SECRET=.*CHANGE_ME" .env; then
die "启用了 MeshSafe 但 MESHSAFE_SECRET 还是占位值:按 MeshSafe 交付单改成白名单里的 secret,或把 MESHSAFE_ENABLED 改成 false"
fi
rest="$(grep -n CHANGE_ME .env | grep -v '^.*MESHSAFE_SECRET=' | cut -d: -f2 | tr '\n' ' ')"
[ -z "$rest" ] || die ".env 里还有 CHANGE_ME 未替换:$rest"
fi
step "检查交付物"
[ -d images ] || die "缺少 images/ 目录(镜像 tar 应与本脚本同级目录的 images/ 下)"
tar_count=$(find images -maxdepth 1 -name '*.tar' | wc -l)
[ "$tar_count" -eq 5 ] || die "images/ 下应有 5 个 .tar(app/web/pg/redis/mq),实际 $tar_count 个:$(ls images)"
for f in sql/01-init-table.sql sql/02-init-data.sql; do
[ -s "$f" ] || die "缺少 $f,参见 sql/README.md"
done
echo " 镜像 tar:5 个,SQL 脚本就位"
# ---------- 1. 校验交付盘 + 镜像架构 ----------
# 650M 的 tar 经 U 盘/内网 scp 拷坏是最常见的现场故障,坏包 load 到一半才报错更难查
if [ -f images/SHA256SUMS.txt ] && [ "${SKIP_CHECKSUM:-0}" != "1" ]; then
step "校验镜像 tar 完整性(sha256,约半分钟)"
( cd images && sha256sum -c SHA256SUMS.txt ) \
|| die "有 tar 与 SHA256SUMS.txt 不一致:交付盘拷坏了,重新拷一份;确认无碍可 SKIP_CHECKSUM=1 跳过"
fi
step "核对镜像架构"
HOST_ARCH="$(uname -m)"
case "$HOST_ARCH" in
x86_64) EXPECT="amd64" ;;
aarch64|arm64) EXPECT="arm64" ;;
*) EXPECT="unknown" ;;
esac
echo " 本机:$HOST_ARCH (期望镜像架构 ${EXPECT})"
# 架构在镜像的 config blob 里,不在 manifest.json —— manifest.json 只给 Config 的路径
tar_arch() {
local t="$1" cfg
cfg="$(tar -xOf "$t" manifest.json 2>/dev/null | tr -d '\n' \
| { grep -oE '"Config":"[^"]+"' || true; } | awk -F'"' 'NR==1{print $4}')"
[ -n "$cfg" ] || { echo ""; return 0; }
tar -xOf "$t" "$cfg" 2>/dev/null | tr -d '\n' \
| { grep -oE '"architecture":"[a-z0-9-]+"' || true; } | awk -F'"' 'NR==1{print $4}'
}
if [ "$EXPECT" != "unknown" ]; then
# app 和 pg 各查一个:一个是自己构建的,一个是基础镜像,架构错了必是出盘时选错平台
for t in images/sfile-app.tar images/postgres.tar; do
[ -f "$t" ] || continue
# 不能用 `| head -1`:pipefail 下 head 提前关管道会让上游 grep 收到 SIGPIPE(141),脚本静默中断
IMG_ARCH="$(tar_arch "$t")"
if [ -n "${IMG_ARCH:-}" ] && [ "$IMG_ARCH" != "$EXPECT" ]; then
die "$(basename "$t") 是 $IMG_ARCH,本机是 $EXPECT —— 别硬装,回来重出对应架构的包"
fi
printf ' %-18s %s\n' "$(basename "$t")" "${IMG_ARCH:-未识别,继续}"
done
fi
# ---------- 2. 载入镜像 ----------
step "docker load 载入离线镜像"
for t in images/*.tar; do
echo " <- $t"
docker load -i "$t"
done
# compose 用的是 .env 里的 tag,缺一个就会去联网拉,离线机上直接卡死
for img in "$(envget APP_IMAGE)" "$(envget WEB_IMAGE)" "$(envget PG_IMAGE)" \
"$(envget REDIS_IMAGE)" "$(envget MQ_IMAGE)"; do
docker image inspect "$img" >/dev/null 2>&1 \
|| die "载入后仍看不到镜像 $img —— tar 与 .env 的 tag 不一致,别继续,回来看 images/ 和 .env"
echo " ✓ $img"
done
# ---------- 3. 目录 ----------
step "创建数据与摆渡目录"
DATA_ROOT="$(envget SFILE_DATA_ROOT)"
GATE_SEND="$(envget GATE_SEND_DIR)"
GATE_RECV="$(envget GATE_RECV_DIR)"
[ -n "$DATA_ROOT" ] || die ".env 里 SFILE_DATA_ROOT 为空"
mkdir -p "$DATA_ROOT"/{upload,logs,pgdata,redis,mq}
mkdir -p "$GATE_SEND" "$GATE_RECV"
# 数据盘挂载点没起来时直接建目录会落到根分区,先确认一下
if ! mountpoint -q "$DATA_ROOT" 2>/dev/null; then
printf ' \033[1;33m提示:%s 不是独立挂载点,确认容量够(上传+库+日志)\033[0m\n' "$DATA_ROOT"
fi
# pgdata 提前给到容器内 postgres 的 uid=999,避免镜像入口脚本 chown 失败;
# redis/mq 的官方入口脚本自己会改属主,这里只保证目录存在且可写
chown -R 999:999 "$DATA_ROOT/pgdata" 2>/dev/null || true
chmod -R 750 "$DATA_ROOT"
# SELinux(麒麟/统信多为 enforcing):无 :z 标记的绑定挂载需要放开标签,否则库起不来
if command -v chcon >/dev/null 2>&1; then
chcon -Rt svirt_sandbox_file_t "$DATA_ROOT" "$GATE_SEND" "$GATE_RECV" 2>/dev/null \
|| echo " chcon 失败(未启用 SELinux 可忽略)"
fi
echo " $DATA_ROOT / $GATE_SEND / $GATE_RECV 就位"
# ---------- 4. 起栈 ----------
step "启动服务(首次建库约 1-3 分钟)"
$DC up -d
step "等待健康检查"
ROLE="$(envget SFILE_ROLE)"
for svc in pg redis mq app web; do
name="sfile-${ROLE}-${svc}"
printf ' %-24s ' "$name"
for _ in $(seq 1 60); do
st="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$name" 2>/dev/null || echo missing)"
case "$st" in
healthy|running) printf '\033[32m%s\033[0m\n' "$st"; break ;;
missing) sleep 2 ;;
*) sleep 5 ;;
esac
done
[ "${st:-}" = "healthy" ] || [ "${st:-}" = "running" ] || {
printf '\033[31m超时(最后状态:%s)\033[0m\n' "${st:-unknown}"
$DC ps
echo " 看日志:docker logs --tail 200 ${name}"
exit 1
}
done
# ---------- 5. 出口信息 ----------
IP="$(hostname -I 2>/dev/null | tr ' ' '\n' | { grep -E '^[0-9]+\.' || true; } | awk 'NR==1{print}')"
PORT="$(envget WEB_PORT)"
step "完成"
cat <<EOF
访问地址: http://${IP:-<本机IP>}:${PORT:-80}
角色: ${ROLE}(传输方向 location=$(envget SFILE_TRANSFER_LOCATION),站点 $(envget SFILE_SITE_ROOT))
摆渡: 发出 $(envget GATE_SEND_DIR) / 收到 $(envget GATE_RECV_DIR)
MeshSafe: $(envget MESHSAFE_ENABLED) -> $(envget MESHSAFE_BASE_URL)
常用命令:
$DC ps 看状态
docker logs -f sfile-${ROLE}-app 看后端日志
$DC restart app 改完 .env/config 后重启后端
EOF
echo " 首次登录后立刻改掉 admin 的默认口令。"

View File

@@ -0,0 +1,270 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""s-file 交付验收冒烟测试:登录 -> 上传送检 -> 不通过拦截 -> 跨网摆渡 -> 外网解包再送检 -> 人工复核。
在能同时访问内网机与外网机前端的机器上运行(需要 python3 + requests)。
全部取值走环境变量,脚本内不含任何口令:
SFILE_IW_BASE=http://10.0.0.11/api SFILE_OW_BASE=http://10.0.0.12/api \\
SFILE_ADMIN_USER=admin SFILE_ADMIN_PWD='<现场口令>' python3 smoke_test.py
退出码:0 全通过,1 有失败项,2 参数缺失。
"""
import json
import os
import sys
import time
import uuid
try:
import requests
except ImportError:
print("缺少 requests:pip3 install requests")
sys.exit(2)
IW = os.environ.get("SFILE_IW_BASE", "http://127.0.0.1:28180/api").rstrip("/")
OW = os.environ.get("SFILE_OW_BASE", "http://127.0.0.1:28280/api").rstrip("/")
USER = os.environ.get("SFILE_ADMIN_USER", "admin")
PWD = os.environ.get("SFILE_ADMIN_PWD", "")
# 光闸发出目录(内网机侧本地路径)。跨机验收时留空,只断言接口成功
GATE_IW2OW = os.environ.get("SFILE_GATE_SEND_DIR", "")
# EICAR 标准测试串;后端的 MeshSafe 结构引擎会识别「内嵌 EICAR」,
# 因此允许在尾部追加唯一标记(见 main 里的载荷说明)
EICAR = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
STATUS = {"1": "检测中", "2": "检测通过", "3": "检测不通过", "4": "检测失败"}
results = []
def rec(name, ok, detail=""):
results.append((name, bool(ok), detail))
print((" [OK] " if ok else " [FAIL] ") + name + (" " + detail if detail else ""), flush=True)
class Side:
def __init__(self, base, label):
self.base = base
self.label = label
self.s = requests.Session()
self.token = None
def _hdr(self, csrf=False):
# 后端 MyLoginSuccessHandler 靠 x-requested-with 决定是否回 JSON,否则 302 跳转
h = {"x-requested-with": "XMLHttpRequest"}
if self.token:
h["Authorization"] = "Bearer " + self.token
if csrf:
t = self.s.cookies.get("XSRF-TOKEN")
if t:
h["X-XSRF-TOKEN"] = t
return h
def login(self, user, pwd):
r = self.s.post(self.base + "/login",
data={"username": user, "password": pwd, "userType": 1},
headers=self._hdr(), timeout=30)
j = self._json(r)
if str(j.get("code")) != "200":
raise RuntimeError("登录失败 HTTP %s:%s" % (r.status_code, json.dumps(j, ensure_ascii=False)[:300]))
self.token = j["data"]["token"]
# 触发 XSRF-TOKEN cookie(POST/PUT 需要带 X-XSRF-TOKEN)
self.s.get(self.base + "/user/info", headers=self._hdr(), timeout=30)
return j
def get(self, path, params=None):
r = self.s.get(self.base + path, params=params, headers=self._hdr(), timeout=60)
return r.status_code, self._json(r)
def post(self, path, payload=None, files=None):
kw = {"files": files} if files is not None else {"json": payload if payload is not None else {}}
r = self.s.post(self.base + path, headers=self._hdr(csrf=True), timeout=180, **kw)
return r.status_code, self._json(r)
@staticmethod
def _json(r):
try:
return r.json()
except Exception:
return {"_status": r.status_code, "_raw": r.text[:400]}
def ok(j):
return str(j.get("code")) == "200"
def msg(j):
return j.get("msg") or j.get("_raw") or json.dumps(j, ensure_ascii=False)[:200]
def page_find(side, name, root_type, parent_id=0):
"""按文件名在指定列表里找记录。root_type:1 我的文件 / 2 内穿外 / 3 外传内"""
_, j = side.get("/user_file/page",
{"parentId": parent_id, "pageNo": 1, "pageSize": 50, "rootType": root_type})
if not ok(j):
return None
for e in (j.get("data") or {}).get("entityList", []):
if e.get("fileName") == name:
return e
return None
def wait_scan(side, file_code, timeout=240):
"""轮询安全检测结论;检测中(1)继续等,其余状态返回"""
deadline = time.time() + timeout
detail = None
while time.time() < deadline:
_, j = side.get("/file_security/detail", {"fileCode": file_code})
if ok(j):
detail = j.get("data")
st = str((detail or {}).get("scanStatus") or "")
if st and st != "1":
return detail
time.sleep(3)
return detail
def main():
if not PWD:
print("必须设置 SFILE_ADMIN_PWD")
return 2
tag = uuid.uuid4().hex[:8]
clean_name = "sfile-smoke-clean-%s.txt" % tag
bad_name = "sfile-smoke-eicar-%s.txt" % tag
iw, ow = Side(IW, "内网"), Side(OW, "外网")
print("\n目标:内网 %s / 外网 %s" % (IW, OW))
print("\n== 1. 登录两侧 ==")
for side in (iw, ow):
try:
side.login(USER, PWD)
rec("%s 登录" % side.label, True, "token %d 字符" % len(side.token))
except Exception as e:
rec("%s 登录" % side.label, False, str(e))
return 1
_, j = iw.get("/file_security/enabled")
ms_on = ok(j) and j.get("data") is True
rec("MeshSafe 开关", True, "enabled=%s(false 时跳过 3/4/7 段)" % ms_on)
print("\n== 2. 上传 + 建档 ==")
# fileCode 按内容寻址:同一份字节会复用上一条检测结论(含人工复核),
# 载荷必须每轮唯一,否则「不通过拦截」这类断言会被上一次的放行结论污染
uploaded = {}
for name, content in ((clean_name, ("s-file smoke clean payload %s\n" % tag) * 80),
(bad_name, EICAR + "\n# smoke-tag %s\n" % tag)):
_, j = iw.post("/file/upload", files={"file": (name, content.encode(), "text/plain")})
if not ok(j):
rec("上传 %s" % name, False, msg(j))
return 1
f = j["data"]
_, j2 = iw.post("/normal_user_file/create",
{"fileCode": f.get("fileCode"), "fileName": name, "fileType": "1", "parentId": 0})
rec("上传并建档 %s" % name, ok(j2), msg(j2) if not ok(j2) else "fileCode=%s" % f.get("fileCode"))
if not ok(j2):
return 1
uploaded[name] = f.get("fileCode")
print("\n== 3. 检测结论 ==")
ids = {}
verdicts = {}
for name, code in uploaded.items():
found = page_find(iw, name, "1")
if found:
ids[name] = found["id"]
d = wait_scan(iw, code)
st = str((d or {}).get("scanStatus") or "")
verdicts[name] = st
print(" %s -> %s | action=%s reason=%s"
% (name, STATUS.get(st, "未知"), (d or {}).get("action"), ((d or {}).get("reason") or "")[:80]))
if ms_on:
rec("干净文件检测通过", verdicts.get(clean_name) == "2", "状态=%s" % STATUS.get(verdicts.get(clean_name)))
rec("EICAR 检测不通过", verdicts.get(bad_name) == "3", "状态=%s" % STATUS.get(verdicts.get(bad_name)))
print("\n== 4. 不通过文件禁止传输 ==")
if len(ids) != len(uploaded):
rec("内网「我的文件」可见上传文件", False, "只找到 %s" % list(ids))
return 1
if ms_on:
_, j = iw.get("/file_security/share_check", {"userFileId": ids[bad_name]})
d = j.get("data") or {}
rec("share_check 命中不通过", ok(j) and d.get("blockedCount", 0) >= 1,
"blocked=%s scanning=%s" % (d.get("blockedCount"), d.get("scanningCount")))
_, j = iw.post("/transfer_user_file/create", {"userFileId": ids[bad_name]})
rec("不通过文件传输被拦截", not ok(j), msg(j)[:160])
print("\n== 5. 干净文件跨网摆渡 ==")
before = set(os.listdir(GATE_IW2OW)) if GATE_IW2OW and os.path.isdir(GATE_IW2OW) else None
_, j = iw.post("/transfer_user_file/create", {"userFileId": ids[clean_name]})
rec("内网发起传输", ok(j), msg(j) if not ok(j) else "外发文件夹ID=%s" % j.get("data"))
if not ok(j):
return 1
if before is None:
print(" 未设 SFILE_GATE_SEND_DIR(跨机验收常见),跳过摆渡包落盘检查")
else:
new_zip = None
for _ in range(30):
delta = set(os.listdir(GATE_IW2OW)) - before
if delta:
new_zip = sorted(delta)[0]
break
time.sleep(2)
rec("光闸发出目录出现摆渡包", new_zip is not None, new_zip or GATE_IW2OW)
print("\n== 6. 外网解包 + 再送检(监听任务默认每分钟一次) ==")
# 接收侧落在「内穿外」列表(root_type=2),目录名带时间戳后缀,需进目录取子文件
folder = None
deadline = time.time() + 300
while time.time() < deadline and folder is None:
_, j = ow.get("/user_file/page", {"parentId": 0, "pageNo": 1, "pageSize": 50, "rootType": "2"})
for e in (j.get("data") or {}).get("entityList", []):
if (e.get("fileName") or "").startswith(clean_name.rsplit(".", 1)[0]):
folder = e
break
if folder is None:
time.sleep(10)
rec("外网「内穿外」出现接收目录", folder is not None,
str(folder.get("fileName")) if folder else "超时未出现(检查光闸与 FILE_TRANSFER_LOCATION)")
if folder:
child = None
_, j = ow.get("/user_file/page",
{"parentId": folder["id"], "pageNo": 1, "pageSize": 50, "rootType": "2"})
for e in (j.get("data") or {}).get("entityList", []):
if e.get("fileName") == clean_name:
child = e
break
rec("接收目录内可见文件", child is not None, str(child.get("fileCode")) if child else "目录为空")
if child:
d = wait_scan(ow, child["fileCode"], timeout=180)
st = str((d or {}).get("scanStatus") or "")
rec("外网解包后自动送检", (not ms_on) or st == "2",
"状态=%s reason=%s" % (STATUS.get(st, "未知"), ((d or {}).get("reason") or "")[:60]))
print("\n== 7. 人工复核改判 ==")
if not ms_on:
print(" MeshSafe 未启用,跳过")
else:
_, j = iw.post("/file_security/review",
{"fileCode": uploaded[bad_name], "decision": "pass",
"comment": "交付验收复核放行 %s" % tag})
rec("复核改判接口", ok(j), msg(j) if not ok(j) else "")
d = j.get("data") or {}
rec("复核后状态=通过", str(d.get("scanStatus")) == "2",
"状态=%s action=%s" % (STATUS.get(str(d.get("scanStatus"))), d.get("action")))
_, j = iw.get("/file_security/detail", {"fileCode": uploaded[bad_name]})
rec("复核说明落库", ok(j) and tag in ((j.get("data") or {}).get("reviewComment") or ""),
str((j.get("data") or {}).get("reviewComment"))[:80])
_, j = iw.get("/file_security/share_check", {"userFileId": ids[bad_name]})
d = j.get("data") or {}
rec("复核后不再拦截", ok(j) and d.get("blockedCount", 0) == 0, "blocked=%s" % d.get("blockedCount"))
print("\n== 汇总 ==")
passed = sum(1 for _, o, _ in results if o)
for name, o, _ in results:
print(" %s %s" % ("OK " if o else "FAIL", name))
print(" %d/%d 通过" % (passed, len(results)))
return 0 if passed == len(results) else 1
if __name__ == "__main__":
sys.exit(main())