先回答一个问题:没有网络时,怎样区分“命令没有主动联网”和“命令其实仍能打开外部 socket”?tools/offline-check.sh 把两件事分开验证:先确认本机已有运行所需的工具和 artifact,再在 Linux/Ubuntu 的 user/network namespace 子进程中做 socket 探针,并在同一隔离域运行检查、站点构建和 demo。
make offline
→ 预检 Go / Node / pnpm / Wasm / Astro
→ 设置 Go 与前端离线变量
→ unshare --user --map-root-user --net
→ bash 子进程
→ socket 探针:1.1.1.1:443 应为 ENETUNREACH
→ make check
→ pnpm build
→ PLM / prepared-copy / Linux prepared-COW demo
隔离对象是这个脚本启动的子进程及其后代,不是用户整个终端、SSH 会话或其他程序。脚本外的网络不会因为一次 make offline 自动断开。
1. 参数和平台选择
tools/offline-check.sh · L1–L61 · 打开完整源码
#!/usr/bin/env bash
# Restriction applies only to this check's children, not the user's network.
set -euo pipefail
cd -- "$(dirname -- "$0")/.."
case "${1-}" in
"") export SPINE_PROBE_ONLY=false ;;
--probe-only) export SPINE_PROBE_ONLY=true ;;
*) printf 'Usage: %s [--probe-only]\n' "$0" >&2; exit 2 ;;
esac
case "$(uname -s)" in
Linux)
command -v unshare >/dev/null || { printf 'Missing unshare. Linux needs user/network namespaces.\n' >&2; exit 1; }
isolation=(unshare --user --map-root-user --net)
;;
Darwin)
command -v sandbox-exec >/dev/null || { printf 'Missing sandbox-exec.\n' >&2; exit 1; }
isolation=(sandbox-exec -p '(version 1) (allow default) (deny network*)')
;;
*) printf 'Unsupported platform: %s\n' "$(uname -s)" >&2; exit 1 ;;
esac
command -v python3 >/dev/null || { printf 'Missing python3.\n' >&2; exit 1; }
if [[ "$SPINE_PROBE_ONLY" == false ]]; then
for tool in go node pnpm; do
command -v "$tool" >/dev/null || { printf 'Missing %s; prepare tools while online.\n' "$tool" >&2; exit 1; }
done
[[ -s dist/spine.wasm ]] || { printf 'Missing dist/spine.wasm; run make guest first.\n' >&2; exit 1; }
[[ -x site/node_modules/.bin/astro ]] || { printf 'Missing site dependencies; run cd site && pnpm install --frozen-lockfile while online.\n' >&2; exit 1; }
fi
export GOPROXY=off GOSUMDB=off GOTOOLCHAIN=local
export npm_config_offline=true ASTRO_TELEMETRY_DISABLED=1
"${isolation[@]}" bash -eu -s <<'OFFLINE'
python3 - <<'PY'
import errno
import platform
import socket
expected = errno.ENETUNREACH if platform.system() == 'Linux' else errno.EPERM
with socket.socket() as sock:
sock.settimeout(3)
try:
sock.connect(('1.1.1.1', 443))
except OSError as error:
if error.errno != expected:
raise SystemExit(f'Unexpected network failure: {error}; expected errno {expected}')
print(f'Verified: child network is unreachable (errno {error.errno}: {error.strerror})')
else:
raise SystemExit('Network unexpectedly available')
PY
if [[ "$SPINE_PROBE_ONLY" == true ]]; then
printf 'Offline isolation probe passed.\n'
exit 0
fi
make check
(cd site && pnpm build)
go run ./cmd/demo -source examples/order.py -plm
go run ./cmd/demo -source examples/order.py -prefix -prepared-copy
if [[ "$(uname -s)" == Linux ]]; then
go run ./cmd/demo -source examples/order.py -prefix -prepared-cow
fi
printf 'Offline check passed with child networking denied.\n'
OFFLINE
脚本先启用 Bash 严格模式,并把当前目录切到仓库根目录。${1-} 在 set -u 下安全读取可能不存在的第一个参数;空参数表示完整检查,--probe-only 表示只验证隔离和网络探针,其他参数以退出码 2 报调用错误。
isolation 是 Bash 数组,保存稍后要执行的隔离命令。Linux 分支要求 unshare 存在,然后使用 --user --map-root-user --net:user namespace 给子进程一套独立的用户映射,--map-root-user 不等于获得宿主机 root;network namespace 给子进程独立的网络设备、路由和连接空间。当前项目面向 Linux x86_64 的 Guest 构建,Ubuntu 上的离线验收走的就是这条 namespace 路径。
Darwin 分支改用 sandbox-exec 和拒绝网络操作的 profile,其他系统直接失败。无论平台,脚本都要求 python3,因为网络探针由 Python socket 实现。平台工具的存在检查只确认命令在 PATH 中,不把“命令可找到”扩大成“完整工具链已验证”。
2. 预检和离线变量
完整模式才检查 go、node、pnpm、非空的 dist/spine.wasm 以及 site/node_modules/.bin/astro。这组检查的含义是“后面的消费方有机会启动”:Go module cache 是否完整、站点所有依赖是否可用、Wasm 是否能运行,仍要由后续命令验证。
预检提示用户在线准备依赖,但脚本不会在这里执行安装或下载。它也不会调用 make setup-build 或 make guest。因此完整模式要求以下输入已经存在:Go module cache、Node/pnpm 依赖、站点源码所需的构建依赖、dist/spine.wasm,以及 Guest 重建所需的 build/inputs(后者由被调用的运行路径按需消费)。缺少输入应当导致失败,而不是暂时解除隔离。
外层为子进程设置两组环境变量:
GOPROXY=off、GOSUMDB=off、GOTOOLCHAIN=local约束 Go,不访问模块代理、外部 checksum database,也不自动切换远程 toolchain;npm_config_offline=true、ASTRO_TELEMETRY_DISABLED=1约束 npm/Astro 的离线和 telemetry 行为。
这些变量是工具层约束。它们不能替代操作系统的网络隔离,所以脚本仍必须通过 ${isolation[@]} 启动子 shell。
3. socket 探针怎样证明子进程被阻断
隔离子 shell 从标准输入读取 here-document 中的脚本。外层用单引号保护 body,避免在启动隔离环境前展开变量;子 shell 自己执行探针和后续命令。探针用 Python 创建 socket,设置 3 秒超时,连接固定 IP 1.1.1.1 的 443 端口。直接使用 IP 是为了避免把 DNS 失败误判成网络隔离。
Linux 预期 errno 是 ENETUNREACH(网络不可达),Darwin 预期是 EPERM(操作不允许)。探针只捕获 OSError,并严格比较 errno:超时、其他错误码或连接成功都算失败。连接成功会报告网络意外可用;收到预期错误才打印验证通过。这个探针证明的是该隔离子进程访问一个固定外部地址的结果,不是覆盖所有潜在通信通道的完整安全审计。
--probe-only 在探针通过后立即退出 0,不要求 Go、Node、pnpm,也不运行测试或站点构建。完整模式则继续在同一隔离子 shell 中执行后续命令,保证被检查的命令继承相同的网络边界。
4. 同一隔离域里检查什么
完整路径先执行 make check,再在子 shell 中进入 site 执行 pnpm build,然后运行 PLM demo 和 prefix 加 prepared full-copy demo。Linux 额外运行 prepared-COW demo;Darwin 跳过它,因为当前 COW 真实路径是 Linux 专用。所有命令都受 bash -eu 和外层严格模式约束,任意一个返回非零都会停止脚本,最终通过消息不会打印。
这条顺序体现产物依赖:Go/Guest 检查消费现有 dist/spine.wasm,站点构建消费已安装的 Node 依赖,demo 消费 Go module cache 和 Guest artifact。离线脚本验收的是“已有输入能否在禁网条件下闭合”,不是“禁网环境能否从零准备输入”。
5. 失败结果怎样解释
- 参数错误退出 2;系统不支持、缺
unshare/sandbox-exec/python3或完整模式预检缺项,退出 1。 - 探针得到超时、错误 errno 或成功连接,都不能算离线通过。
make check、pnpm build或任一 demo 失败时,脚本停止,不打印最终通过消息。- 只有
--probe-only通过,只能证明这一轮固定地址探针通过;它不证明 Go、站点或 Guest 检查通过。
所以 GOPROXY=off 失败通常说明 Go cache 或 toolchain 输入不完整;namespace 探针失败说明子进程的网络边界没有达到脚本要求;后续构建失败则说明已有输入与源码组合仍不能完成该阶段。不要把这些原因合并成一个笼统的“离线失败”。
6. 读完后应能回答
- 为什么
GOPROXY=off之后还必须做 socket 探针? - 为什么 Ubuntu 的
unshare --net只影响检查子进程,不会断开脚本外的 SSH? - 为什么 probe-only 通过不等于完整离线验收通过?
下一步读本地阅读服务:它不参与 namespace 验收,只提供已经生成的 site/dist。本章覆盖 tools/offline-check.sh 的全部非空源码行,没有执行 namespace、网络探针、测试、pnpm 构建或 demo;当前机器的实际隔离权限和缓存状态仍未现场验证。