diff --git a/deploy/docker/managed-user/Dockerfile b/deploy/docker/managed-user/Dockerfile index 24200189..67122ad2 100644 --- a/deploy/docker/managed-user/Dockerfile +++ b/deploy/docker/managed-user/Dockerfile @@ -57,6 +57,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ nano \ less \ unzip \ + tzdata \ && rm -rf /var/lib/apt/lists/* RUN sed -i 's/^#user_allow_other/user_allow_other/' /etc/fuse.conf \ @@ -224,6 +225,15 @@ COPY deploy/docker/managed-user/launch-chromium.sh /usr/local/bin/launch-chromiu COPY deploy/docker/managed-user/restart-vnc.sh /usr/local/bin/restart-vnc COPY deploy/docker/managed-user/vnc-status.sh /usr/local/bin/vnc-status +# v4.3: 进程内环境伪装模块(经 BUN_OPTIONS=--preload 注入 claude 进程)。 +# entrypoint 写 /etc/environment,profile.d 做非 login shell 双保险。 +COPY tools/spoof-fingerprint.js /usr/local/lib/cloud-cli-proxy/spoof-fingerprint.js +# v4.3: OAuth 浏览器入口 — claude /login 等认证流程在 VNC 桌面 Chromium 打开, +# 浏览器流量与 API 同走 sing-box 出口,OAuth 回调走容器内 localhost。 +COPY deploy/docker/managed-user/browser-open.sh /usr/local/bin/cc-browser-open +RUN chmod 0644 /usr/local/lib/cloud-cli-proxy/spoof-fingerprint.js \ + && chmod 0755 /usr/local/bin/cc-browser-open + # v4.0: 容器内 nft default-deny 规则集(D-V4-3 / NET-01 / NET-02) COPY deploy/docker/managed-user/default-deny.nft /etc/cloud-claude/default-deny.nft RUN chmod 0644 /etc/cloud-claude/default-deny.nft diff --git a/deploy/docker/managed-user/browser-open.sh b/deploy/docker/managed-user/browser-open.sh new file mode 100644 index 00000000..08539bcf --- /dev/null +++ b/deploy/docker/managed-user/browser-open.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# OAuth 浏览器入口 — claude /login 等"需要打开浏览器"的认证流程统一走这里。 +# +# 背景:claude 的 OAuth 授权会打印 https://claude.ai/oauth/authorize?...&redirect_uri=http://localhost:PORT +# 并经 $BROWSER 打开浏览器。若用户用本机浏览器完成认证: +# 1. 认证流量走用户本地网络(出口 IP 与容器 API 会话不一致,触发风控关联) +# 2. redirect_uri 指向容器内 localhost,本机浏览器根本回调不到 +# 因此浏览器必须与 claude 同容器:本脚本在 VNC 桌面(DISPLAY=:99)的 Chromium +# 中打开授权页,浏览器全流量经 sing-box tun 出口,回调走容器内 localhost 直达。 +# +# 用户通过产品的 VNC 入口(websocket 6080)看到并操作该浏览器。 + +set -euo pipefail + +URL="${1:-}" +DISPLAY="${DISPLAY:-:99}" +export DISPLAY + +if [[ -z "${URL}" ]]; then + echo "[browser-open] no URL given" >&2 + exit 1 +fi + +if ! xdpyinfo >/dev/null 2>&1; then + # 桌面未就绪(MODE=local 或桌面栈故障):不阻塞 claude,给出人工路径 + echo "[browser-open] desktop not ready — connect via VNC (ws 6080) and open:" >&2 + echo "${URL}" >&2 + exit 0 +fi + +browser_cmd="" +for candidate in chromium chromium-browser google-chrome; do + if command -v "${candidate}" >/dev/null 2>&1; then + browser_cmd="${candidate}" + break + fi +done +if [[ -z "${browser_cmd}" ]]; then + echo "[browser-open] no chromium binary found" >&2 + exit 1 +fi + +# 与桌面 Chromium 共用同一 user-data-dir:已有实例时自动开新标签并复用会话。 +# WebRTC 防泄漏 flags 与 launch-chromium.sh 保持一致(UDP 不走非代理路径)。 +nohup "${browser_cmd}" \ + --no-sandbox \ + --disable-dev-shm-usage \ + --user-data-dir=/workspace/.chrome-data \ + --no-first-run \ + --disable-gpu \ + --disable-features=WebRtcHideLocalIpsWithMdns \ + --enforce-webrtc-ip-permission-check \ + --force-webrtc-ip-handling-policy=disable_non_proxied_udp \ + "${URL}" >/dev/null 2>&1 & + +# 立即返回,避免调用方(claude 的 open 调用)等待浏览器退出 +exit 0 diff --git a/deploy/docker/managed-user/entrypoint.sh b/deploy/docker/managed-user/entrypoint.sh index a511b02b..7c78d469 100755 --- a/deploy/docker/managed-user/entrypoint.sh +++ b/deploy/docker/managed-user/entrypoint.sh @@ -128,19 +128,54 @@ prepare_persistent_state() { } prepare_container_disguise() { - # Per-container unique machine-id(基于 hostname + uptime,保证唯一性) - local h; h="$(hostname)" - local t; t="$(cat /proc/uptime 2>/dev/null | tr -d ' .')" - local mid; mid="$(echo -n "${h}-${t}" | sha256sum | cut -c1-32)" + # Per-container unique machine-id。持久化到 /var/lib/claude-persist: + # 容器重启后保持稳定,避免遥测 device_id 漂移引发"频繁新设备"异常。 + local persist_mid=/var/lib/claude-persist/machine-id + local mid + if [ -s "$persist_mid" ]; then + mid="$(cat "$persist_mid")" + else + mid="$(echo -n "$(hostname)-$(date +%s)" | sha256sum | cut -c1-32)" + echo "$mid" > "$persist_mid" + chown "${RUN_USER:-workspace}:${RUN_USER:-workspace}" "$persist_mid" 2>/dev/null || true + chmod 444 "$persist_mid" + fi echo "$mid" > /etc/machine-id echo "$mid" > /var/lib/dbus/machine-id 2>/dev/null || true chmod 444 /etc/machine-id + # 时区伪装:与出口 IP 地理位置配套(由控制面经 DISGUISE_TZ 注入,缺省美东)。 + # 同时作用于 Date/Intl(TZ 变量)与 shell 工具(/etc/localtime),三层一致。 + local tz="${DISGUISE_TZ:-America/New_York}" + if [ -f "/usr/share/zoneinfo/${tz}" ]; then + echo "$tz" > /etc/timezone + ln -sfn "/usr/share/zoneinfo/${tz}" /etc/localtime + else + echo "[entrypoint] WARN unknown DISGUISE_TZ=${tz}, fallback America/New_York" >&2 + tz=America/New_York + echo "$tz" > /etc/timezone + ln -sfn "/usr/share/zoneinfo/${tz}" /etc/localtime + fi + + # 内存配额透传给 spoof 模块:os.totalmem() 返回 /proc 物理内存(宿主值), + # 按 cgroup 限额伪装,避免泄漏宿主机内存容量。未限额时不注入(spoof 用 8G 缺省)。 + local mem_bytes="" + if [ -r /sys/fs/cgroup/memory.max ]; then + local v; v="$(cat /sys/fs/cgroup/memory.max 2>/dev/null || echo max)" + case "$v" in max|'') ;; *[!0-9]*) ;; *) mem_bytes="$v" ;; esac + fi + if [ -z "$mem_bytes" ] && [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then + local v1; v1="$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null || echo '')" + case "$v1" in ''|*[!0-9]*) ;; 9223372036854771712) ;; *) mem_bytes="$v1" ;; esac + fi + # 删除容器检测标志 rm -f /.dockerenv - # 遥测阻断环境变量。覆盖写入(非追加)防止容器 restart 重复追加。 - cat > /etc/environment <<'ENVTELEM' + # 遥测阻断 + 伪装模块注入。覆盖写入(非追加)防止容器 restart 重复追加。 + # BUN_OPTIONS 是 claude(bun 编译二进制)唯一有效的 preload 注入通道; + # 不注入 NODE_OPTIONS,避免干扰用户自己的 node 程序。 + cat > /etc/environment < /etc/profile.d/zz-disguise.sh + chmod 0644 /etc/profile.d/zz-disguise.sh + + echo "[entrypoint] container disguise: machine-id=${mid:0:8}…(persistent) /.dockerenv removed TZ=${tz} mem=${mem_bytes:-default8G} telemetry blocked" + + # 导出到 entrypoint(PID 1)进程环境:sshd 会话与 VNC 桌面终端均为其 + # 子进程,统一继承;覆盖非 login shell 不读 /etc/environment 的场景。 + # 必须在 start_sshd_or_die / start_desktop_stack 之前完成(见 main 流程)。 + export TZ="${tz}" + export SPOOF_TIMEZONE="${tz}" + export BUN_OPTIONS="--preload /usr/local/lib/cloud-cli-proxy/spoof-fingerprint.js" + # OAuth 浏览器入口:claude /login 经 $BROWSER 在 VNC 桌面 Chromium 打开, + # 认证流量与 API 同出口,回调走容器内 localhost。 + export BROWSER=/usr/local/bin/cc-browser-open + if [ -n "$mem_bytes" ]; then + export SPOOF_TOTAL_MEM="${mem_bytes}" + fi } prepare_mergerfs_check() { @@ -522,7 +587,6 @@ YAML prepare_workspace_dirs prepare_persistent_state - prepare_container_disguise prepare_mergerfs_check assert_tmux_version @@ -623,6 +687,11 @@ start_vnc_watchdog_async() { # SSH setup mkdir -p /var/run/sshd + +# 容器伪装在 sshd / 桌面栈启动前完成:machine-id、TZ、BUN_OPTIONS 注入 +# 需先进入 PID 1 环境,sshd 会话与 VNC 桌面才能继承。 +prepare_container_disguise + if ! ls /etc/ssh/ssh_host_*_key >/dev/null 2>&1; then ssh-keygen -A fi diff --git a/deploy/docker/managed-user/launch-chromium.sh b/deploy/docker/managed-user/launch-chromium.sh index 00e55957..91c181c9 100644 --- a/deploy/docker/managed-user/launch-chromium.sh +++ b/deploy/docker/managed-user/launch-chromium.sh @@ -25,8 +25,21 @@ if [[ -z "${browser_cmd}" ]]; then -e bash -lc "echo Chromium is not installed.; exec bash" fi +# 带参数(URL / flags)时同样附加防泄漏与固定 profile 参数,保证 +# OAuth 等场景新开的标签与桌面会话一致(chromium 自动复用已有实例)。 if [[ $# -gt 0 ]]; then - exec "${browser_cmd}" "$@" + exec "${browser_cmd}" \ + --no-sandbox \ + --disable-dev-shm-usage \ + --user-data-dir=/workspace/.chrome-data \ + --lang="${CHROMIUM_LANG}" \ + --accept-lang="${CHROMIUM_ACCEPT_LANG}" \ + --no-first-run \ + --disable-gpu \ + --disable-features=WebRtcHideLocalIpsWithMdns \ + --enforce-webrtc-ip-permission-check \ + --force-webrtc-ip-handling-policy=disable_non_proxied_udp \ + "$@" fi exec "${browser_cmd}" \ diff --git a/deploy/docker/managed-user/profile.d-cloud-claude.sh b/deploy/docker/managed-user/profile.d-cloud-claude.sh index 8aa75d2d..98f75553 100644 --- a/deploy/docker/managed-user/profile.d-cloud-claude.sh +++ b/deploy/docker/managed-user/profile.d-cloud-claude.sh @@ -1,3 +1,13 @@ #!/bin/sh # v3.0 baseline — Claude Code truecolor hint (PITFALLS M8) export CLAUDE_CODE_TMUX_TRUECOLOR=1 + +# 伪装模块注入(与 /etc/environment 双保险,覆盖非 login shell 场景)。 +# BUN_OPTIONS 是 claude(bun 编译二进制)唯一有效的 preload 通道; +# 已含该模块时跳过,避免 login 会话叠加双重 preload。 +if [ -r /usr/local/lib/cloud-cli-proxy/spoof-fingerprint.js ]; then + case "${BUN_OPTIONS:-}" in + *spoof-fingerprint.js*) ;; + *) export BUN_OPTIONS="--preload /usr/local/lib/cloud-cli-proxy/spoof-fingerprint.js ${BUN_OPTIONS:-}" ;; + esac +fi diff --git a/tools/claude-spoofed.sh b/tools/claude-spoofed.sh index 0280a7ef..62e82502 100755 --- a/tools/claude-spoofed.sh +++ b/tools/claude-spoofed.sh @@ -2,6 +2,10 @@ # # 以伪装身份启动 Claude Code # +# 注入通道实测结论(claude 2.1.304,bun 编译二进制): +# - NODE_OPTIONS="--require":对 bun 编译版无效,仅适用于 node 直跑旧版 cli.js +# - BUN_OPTIONS="--preload":对 bun 编译版有效,是当前唯一有效的注入通道 +# # 用法: # ./claude-spoofed.sh # 默认伪装 # SPOOF_HOSTNAME=my-vm ./claude-spoofed.sh # 自定义主机名 @@ -9,11 +13,12 @@ # # 可配合代理使用(推荐): # HTTPS_PROXY=http://127.0.0.1:8888 ./claude-spoofed.sh +# (claude 经 undici EnvHttpProxyAgent 读取该变量;遥测事件中不含 +# "使用代理"字段,不会因此被额外标记。全流量隧道场景无需设置。) # SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SPOOF_SCRIPT="$SCRIPT_DIR/spoof-fingerprint.js" -DNS_GUARD="$SCRIPT_DIR/dns-guard.js" if [ ! -f "$SPOOF_SCRIPT" ]; then echo "Error: spoof-fingerprint.js not found at $SPOOF_SCRIPT" @@ -23,10 +28,9 @@ fi # 如果没有设置伪装 hostname,生成一个稳定的 export SPOOF_HOSTNAME="${SPOOF_HOSTNAME:-cloud-vm-$(echo -n "$(whoami)-$(date +%Y%m)" | shasum | cut -c1-6)}" -# 将 spoof 脚本 + DNS guard 注入到 Node.js 启动流程 -export NODE_OPTIONS="--require $SPOOF_SCRIPT --require $DNS_GUARD ${NODE_OPTIONS:-}" -# Bun 二进制(Claude Code standalone)使用 BUN_OPTIONS --preload -export BUN_OPTIONS="--preload $SPOOF_SCRIPT --preload $DNS_GUARD ${BUN_OPTIONS:-}" +# 注入伪装模块(BUN_OPTIONS 对 bun 编译版生效;NODE_OPTIONS 兼容 node 旧版) +export NODE_OPTIONS="--require $SPOOF_SCRIPT ${NODE_OPTIONS:-}" +export BUN_OPTIONS="--preload $SPOOF_SCRIPT ${BUN_OPTIONS:-}" # ── 遥测阻断环境变量 ───────────────────────────────────────── export DISABLE_TELEMETRY=1 diff --git a/tools/dns-guard.js b/tools/dns-guard.js index 1a3f2b7f..9994e269 100644 --- a/tools/dns-guard.js +++ b/tools/dns-guard.js @@ -3,6 +3,13 @@ // ── DNS 遥测拦截模块 ────────────────────────────────────────── // 拦截 DNS 查询和网络连接,阻止 Claude Code 向遥测/分析端点发送数据。 // 兼容 Node.js (--require) 和 Bun (--preload) 两种加载方式。 +// +// ⚠️ 局限性(实测,claude 2.1.304 bun 编译二进制): +// bun 编译版的网络层(fetch / dns / net / tls)为内建实现,不经过 +// node 兼容层的 JS 模块对象,本模块的 patch 对其无效。 +// 对 bun 编译版 Claude Code,遥测拦截只能依赖容器网络层 +// (sing-box tun + nftables 默认拒绝);本模块仅对 node 直跑的 +// 程序(旧版 cli.js、自研脚本等)有效,故不再注入 claude 启动链路。 var dns = require("dns"); var net = require("net"); diff --git a/tools/spoof-fingerprint.js b/tools/spoof-fingerprint.js index 2cf1dc00..4fe9d291 100644 --- a/tools/spoof-fingerprint.js +++ b/tools/spoof-fingerprint.js @@ -1,148 +1,233 @@ "use strict"; +// spoof-fingerprint.js — 进程内环境伪装模块 +// +// 注入方式: +// - Claude Code 2.x(bun 编译二进制):BUN_OPTIONS="--preload <本文件>" +// (实测 NODE_OPTIONS="--require" 对 bun 编译版无效,BUN_OPTIONS 是唯一有效通道) +// - node 运行的旧版 cli.js:NODE_OPTIONS="--require <本文件>" +// +// 覆盖的采集路径(依据对 claude 2.1.304 二进制的逆向): +// - os.cpus()/totalmem()/freemem() 宿主 CPU 型号与物理内存(容器内 /proc 透传宿主) +// - os.networkInterfaces() docker bridge 172.x 地址与 02:42 MAC 前缀 +// - existsSync/statSync("/.dockerenv") 容器标志文件(与 entrypoint 删除双保险) +// - statSync("/run/.containerenv") podman 标志(防御性) +// - readFileSync("/proc/self/cgroup") claude 直接 includes("docker") +// - readFile("/proc/1/cgroup") 正则 /docker|kubepods|containerd|libpod/i +// - /proc/self/mountinfo overlay 文件系统与 /var/lib/docker 挂载痕迹 +// - child_process: uname / hostname 内核名与主机名 +// - Intl timeZone / getTimezoneOffset 本地时区(服务端与出口 IP 地理交叉验证) +// +// 明确不覆盖(保持一致性优先): +// - /etc/machine-id:entrypoint 已生成每容器唯一且持久化的值,此处透传, +// 保证 fs 层读取与 shell 层 `cat /etc/machine-id` 返回一致。 +// +// 无法覆盖(bun 编译版内建实现,JS 层 patch 免疫): +// - fetch / dns / net / tls。遥测拦截依赖容器网络层 +// (sing-box tun + nftables 默认拒绝),进程内无解。 + const os = require("os"); const crypto = require("crypto"); -// ── 配置:伪装成的目标设备信息 ────────────────────────────────── -// 可通过环境变量覆盖,也可以直接改这里的默认值 +const realHostname = os.hostname(); +// ── 配置:全部可经环境变量覆盖 ───────────────────────────────── +// 默认取真实容器主机名(控制面创建容器时已注入定制 hostname,无需二次伪装); +// 单机使用 claude-spoofed.sh 时由其导出 SPOOF_HOSTNAME 随机假名。 const SPOOF = { - hostname: process.env.SPOOF_HOSTNAME || "cloud-vm-" + crypto.randomBytes(3).toString("hex"), + hostname: process.env.SPOOF_HOSTNAME || realHostname, username: process.env.SPOOF_USERNAME || "user", homedir: process.env.SPOOF_HOMEDIR || "/home/user", platform: process.env.SPOOF_PLATFORM || "linux", type: process.env.SPOOF_TYPE || "Linux", + // 与宿主内核解耦的普通发行版内核版本;uname 拦截输出同一值,保持一致。 release: process.env.SPOOF_RELEASE || "6.8.0-45-generic", arch: process.env.SPOOF_ARCH || os.arch(), - machine: process.env.SPOOF_MACHINE || os.arch() === "arm64" ? "aarch64" : "x86_64", + machine: process.env.SPOOF_MACHINE || (os.arch() === "arm64" ? "aarch64" : "x86_64"), tmpdir: process.env.SPOOF_TMPDIR || "/tmp", shell: process.env.SPOOF_SHELL || "/bin/bash", - // 伪造一个稳定的 MAC 地址(基于 hostname 派生,保证同一 hostname 总是同一个 MAC) + // 时区:优先 SPOOF_TIMEZONE,其次 TZ 环境变量,最后缺省美东 + // (与出口 IP 地理位置配套,由控制面/entrypoint 注入)。 + timezone: process.env.SPOOF_TIMEZONE || process.env.TZ || "America/New_York", + + // 基于主机名派生的稳定 MAC(locally administered 位 02 前缀) mac: process.env.SPOOF_MAC || (() => { - const h = process.env.SPOOF_HOSTNAME || "cloud-vm-default"; + const h = process.env.SPOOF_HOSTNAME || realHostname; const hash = crypto.createHash("sha256").update(h).digest("hex"); - return [ - "02", // locally administered bit - hash.slice(0, 2), - hash.slice(2, 4), - hash.slice(4, 6), - hash.slice(6, 8), - hash.slice(8, 10), - ].join(":"); + return ["02", hash.slice(0, 2), hash.slice(2, 4), hash.slice(4, 6), + hash.slice(6, 8), hash.slice(8, 10)].join(":"); })(), cpuModel: process.env.SPOOF_CPU_MODEL || "AMD EPYC 7763 64-Core Processor", cpuCores: parseInt(process.env.SPOOF_CPU_CORES || "4", 10), + // 缺省 8G;产品容器由 entrypoint 按内存配额导出真实限额,避免泄漏宿主物理内存。 totalMem: parseInt(process.env.SPOOF_TOTAL_MEM || String(8 * 1024 * 1024 * 1024), 10), }; -// ── Patch os.hostname() ──────────────────────────────────────── -os.hostname = () => SPOOF.hostname; +// ── 纯函数:cgroup / mountinfo 内容改写(导出供自测) ────────── +function sanitizeCgroupContent(text) { + return text.replace(/docker|containerd|kubepods|libpod|lxc/gi, "system.slice"); +} + +function sanitizeMountinfoContent(text) { + return text + .split("\n") + .map((line) => { + if (!line.trim()) return line; + const f = line.split(" "); + // docker overlay 根挂载:重建为普通 ext4 根挂载行, + // 去掉 lowerdir/upperdir 等 /var/lib/docker 路径细节。 + if (f[4] === "/" && /\boverlay\b/.test(line)) { + return `${f[0]} ${f[1]} 259:1 / / rw,relatime - ext4 /dev/vda1 rw`; + } + return line; + }) + // 丢弃其余 docker/containerd 内部挂载行(volume、工作目录等) + .filter((line) => !/\/var\/lib\/(docker|containerd)\//.test(line)) + .map((line) => line + // 残余 overlay 挂载类型与 cgroup 层级中的容器词 + .replace(/\boverlay\b/g, "ext4") + .replace(/docker|containerd|kubepods|libpod/gi, "system.slice")) + .join("\n"); +} + +// ── 安全赋值:bun 对部分内置模块导出做了只读冻结,需逐个降级尝试 ── +function safeAssign(obj, key, value) { + try { obj[key] = value; return true; } catch (_) {} + try { + Object.defineProperty(obj, key, { value, writable: true, configurable: true }); + return true; + } catch (_) { + return false; + } +} + +// ── Patch os.* ──────────────────────────────────────────────── +safeAssign(os, "hostname", () => SPOOF.hostname); -// ── Patch os.userInfo() ──────────────────────────────────────── const _userInfo = os.userInfo.bind(os); -os.userInfo = (opts) => { +safeAssign(os, "userInfo", (opts) => { const real = _userInfo(opts); - return { - ...real, - username: SPOOF.username, - homedir: SPOOF.homedir, - shell: SPOOF.shell, - }; -}; + return { ...real, username: SPOOF.username, homedir: SPOOF.homedir, shell: SPOOF.shell }; +}); -// ── Patch os.platform() / os.type() / os.release() ──────────── -os.platform = () => SPOOF.platform; -os.type = () => SPOOF.type; -os.release = () => SPOOF.release; -os.arch = () => SPOOF.arch; -os.tmpdir = () => SPOOF.tmpdir; +safeAssign(os, "platform", () => SPOOF.platform); +safeAssign(os, "type", () => SPOOF.type); +safeAssign(os, "release", () => SPOOF.release); +safeAssign(os, "arch", () => SPOOF.arch); +safeAssign(os, "tmpdir", () => SPOOF.tmpdir); -// ── Patch os.totalmem() / os.freemem() ───────────────────────── -os.totalmem = () => SPOOF.totalMem; -os.freemem = () => Math.floor(SPOOF.totalMem * 0.6); +safeAssign(os, "totalmem", () => SPOOF.totalMem); +safeAssign(os, "freemem", () => Math.floor(SPOOF.totalMem * 0.6)); -// ── Patch os.cpus() ──────────────────────────────────────────── -os.cpus = () => +safeAssign(os, "cpus", () => Array.from({ length: SPOOF.cpuCores }, (_, i) => ({ model: SPOOF.cpuModel, speed: 2450, times: { user: 100000 + i * 1000, nice: 0, sys: 50000, idle: 900000, irq: 0 }, - })); - -// ── Patch os.networkInterfaces() ─────────────────────────────── -os.networkInterfaces = () => ({ - eth0: [ - { - address: "10.0.0.2", - netmask: "255.255.255.0", - family: "IPv4", - mac: SPOOF.mac, - internal: false, - cidr: "10.0.0.2/24", - }, - ], - lo: [ - { - address: "127.0.0.1", - netmask: "255.0.0.0", - family: "IPv4", - mac: "00:00:00:00:00:00", - internal: true, - cidr: "127.0.0.1/8", - }, - ], -}); + }))); -// ── Patch os.homedir() ───────────────────────────────────────── -os.homedir = () => SPOOF.homedir; +// 宿主逻辑核数与伪装核数不一致时暴露配额与拓扑,一并伪装 +safeAssign(os, "availableParallelism", () => SPOOF.cpuCores); -// ── Patch os.uptime() ────────────────────────────────────────── -const fakeBootTime = Date.now() - (7 * 24 * 3600 * 1000 + Math.random() * 3600000); -os.uptime = () => Math.floor((Date.now() - fakeBootTime) / 1000); +safeAssign(os, "networkInterfaces", () => ({ + eth0: [{ + address: "10.0.0.2", netmask: "255.255.255.0", family: "IPv4", + mac: SPOOF.mac, internal: false, cidr: "10.0.0.2/24", + }], + lo: [{ + address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", + mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8", + }], +})); -// ── Patch process 属性 ───────────────────────────────────────── -// process.platform 是 getter,无法直接赋值,但可以通过 defineProperty -try { - Object.defineProperty(process, "platform", { value: SPOOF.platform, writable: false }); -} catch (_) { - // 某些环境不允许修改,忽略 +safeAssign(os, "homedir", () => SPOOF.homedir); + +// ── Patch 时区 ──────────────────────────────────────────────── +// claude 通过 Intl.DateTimeFormat().resolvedOptions().timeZone 获取 IANA 时区, +// 通过 new Date().getTimezoneOffset() 获取偏移;两者必须指向同一目标时区。 +const RealDateTimeFormat = Intl.DateTimeFormat; + +const origResolvedOptions = RealDateTimeFormat.prototype.resolvedOptions; +safeAssign(RealDateTimeFormat.prototype, "resolvedOptions", function (...args) { + const r = origResolvedOptions.apply(this, args); + // 仅改写"未显式指定 timeZone"的查询结果,不影响显式传入 timeZone 的格式化。 + if (r && r.timeZone && r.locale) r.timeZone = SPOOF.timezone; + return r; +}); + +function tzOffsetMinutes(timeZone, date) { + const dtf = new RealDateTimeFormat("en-US", { + timeZone, hour12: false, + year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", + }); + const parts = {}; + for (const p of dtf.formatToParts(date)) parts[p.type] = p.value; + const asUTC = Date.UTC(+parts.year, +parts.month - 1, +parts.day, + +parts.hour % 24, +parts.minute, +parts.second); + return Math.round((asUTC - date.getTime()) / 60000); } -try { - Object.defineProperty(process, "arch", { value: SPOOF.arch, writable: false }); -} catch (_) {} -// ── 拦截 child_process 中的指纹命令 ──────────────────────────── -// macOS 上 ioreg / system_profiler / sysctl 可能被用来获取硬件序列号 +safeAssign(Date.prototype, "getTimezoneOffset", function () { + try { + return tzOffsetMinutes(SPOOF.timezone, this); + } catch (_) { + return 0; + } +}); + +// ── Patch child_process 中的指纹命令 ────────────────────────── const childProcess = require("child_process"); +const unameFull = + `Linux ${SPOOF.hostname} ${SPOOF.release} #1 SMP ${SPOOF.machine} GNU/Linux`; + const FINGERPRINT_COMMANDS = [ - { pattern: /ioreg.*IOPlatformSerialNumber/, replacement: '"IOPlatformSerialNumber" = "CLOUD000000"' }, - { pattern: /system_profiler\s+SPHardwareDataType/, replacement: "Serial Number (system): CLOUD000000\nHardware UUID: 00000000-0000-0000-0000-000000000000" }, - { pattern: /sysctl\s+kern\.uuid/, replacement: "kern.uuid: 00000000-0000-0000-0000-000000000000" }, - { pattern: /cat\s+\/etc\/machine-id/, replacement: crypto.createHash("sha256").update(SPOOF.hostname).digest("hex").slice(0, 32) }, - { pattern: /hostname/, replacement: SPOOF.hostname }, + { + pattern: /^uname\b/, + build: (cmd) => { + if (/\s-a/.test(cmd)) return unameFull; + if (/\s-s/.test(cmd)) return "Linux"; + if (/\s-r/.test(cmd)) return SPOOF.release; + if (/\s-m/.test(cmd)) return SPOOF.machine; + if (/\s-n/.test(cmd)) return SPOOF.hostname; + if (/\s-v/.test(cmd)) return `#1 SMP ${SPOOF.machine}`; + return SPOOF.hostname; + }, + }, + { pattern: /^hostname\b/, build: () => SPOOF.hostname }, + { + pattern: /ioreg.*IOPlatformSerialNumber/, + build: () => '"IOPlatformSerialNumber" = "CLOUD000000"', + }, + { + pattern: /system_profiler\s+SPHardwareDataType/, + build: () => "Serial Number (system): CLOUD000000\nHardware UUID: 00000000-0000-0000-0000-000000000000", + }, + { + pattern: /sysctl\s+kern\.uuid/, + build: () => "kern.uuid: 00000000-0000-0000-0000-000000000000", + }, ]; function maybeSpoofCommand(cmd) { - for (const { pattern, replacement } of FINGERPRINT_COMMANDS) { - if (pattern.test(cmd)) { - return replacement; - } + for (const { pattern, build } of FINGERPRINT_COMMANDS) { + if (pattern.test(cmd)) return build(cmd); } return null; } const _execSync = childProcess.execSync; -childProcess.execSync = function (cmd, opts) { +safeAssign(childProcess, "execSync", function (cmd, opts) { const spoofed = maybeSpoofCommand(String(cmd)); if (spoofed !== null) return Buffer.from(spoofed + "\n"); return _execSync.call(this, cmd, opts); -}; +}); const _exec = childProcess.exec; -childProcess.exec = function (cmd, opts, cb) { +safeAssign(childProcess, "exec", function (cmd, opts, cb) { if (typeof opts === "function") { cb = opts; opts = {}; } const spoofed = maybeSpoofCommand(String(cmd)); if (spoofed !== null) { @@ -158,102 +243,115 @@ childProcess.exec = function (cmd, opts, cb) { return fake; } return _exec.call(this, cmd, opts, cb); -}; +}); const _execFileSync = childProcess.execFileSync; -childProcess.execFileSync = function (file, args, opts) { - const fullCmd = [file, ...(args || [])].join(" "); +safeAssign(childProcess, "execFileSync", function (file, args, opts) { + const fullCmd = [file, ...(Array.isArray(args) ? args : [])].join(" "); const spoofed = maybeSpoofCommand(fullCmd); if (spoofed !== null) return Buffer.from(spoofed + "\n"); return _execFileSync.call(this, file, args, opts); -}; +}); const _spawnSync = childProcess.spawnSync; -childProcess.spawnSync = function (cmd, args, opts) { - const fullCmd = [cmd, ...(args || [])].join(" "); +safeAssign(childProcess, "spawnSync", function (cmd, args, opts) { + const fullCmd = [cmd, ...(Array.isArray(args) ? args : [])].join(" "); const spoofed = maybeSpoofCommand(fullCmd); if (spoofed !== null) { - return { stdout: Buffer.from(spoofed + "\n"), stderr: Buffer.from(""), status: 0, signal: null, pid: 0, output: [null, Buffer.from(spoofed + "\n"), Buffer.from("")] }; + return { + stdout: Buffer.from(spoofed + "\n"), stderr: Buffer.from(""), + status: 0, signal: null, pid: 0, + output: [null, Buffer.from(spoofed + "\n"), Buffer.from("")], + }; } return _spawnSync.call(this, cmd, args, opts); -}; +}); -// ── 拦截 fs 层 machine-id 读取 ──────────────────────────────── +// ── Patch fs 层:容器标志与 procfs 探测 ──────────────────────── const fs = require("fs"); -const MACHINE_ID_PATHS = ["/etc/machine-id", "/var/lib/dbus/machine-id"]; -function isMachineIdPath(p) { - const s = typeof p === "string" ? p : (p && p.toString ? p.toString() : ""); - return MACHINE_ID_PATHS.includes(s); + +const CONTAINER_FLAG_PATHS = new Set([ + "/.dockerenv", "/.dockerinit", "/run/.containerenv", +]); +const CGROUP_PATHS = new Set(["/proc/1/cgroup", "/proc/self/cgroup"]); +const MOUNTINFO_PATHS = new Set(["/proc/self/mountinfo", "/proc/1/mountinfo"]); + +const pathKey = (p) => (typeof p === "string" ? p : (p && p.toString ? p.toString() : "")); + +function rewriteContent(path, content) { + const p = pathKey(path); + if (CGROUP_PATHS.has(p)) return sanitizeCgroupContent(content); + if (MOUNTINFO_PATHS.has(p)) return sanitizeMountinfoContent(content); + return content; } -const fakeMachineId = crypto.createHash("sha256").update(SPOOF.hostname).digest("hex").slice(0, 32) + "\n"; + +// machine-id / dbus machine-id:透传(entrypoint 已保证唯一与持久化, +// fs 层与 shell 层返回一致值,避免自相矛盾)。 const _origReadFileSync = fs.readFileSync.bind(fs); -fs.readFileSync = function (path, options) { - if (isMachineIdPath(path)) { - return typeof options === "string" || (options && options.encoding) - ? fakeMachineId : Buffer.from(fakeMachineId); +safeAssign(fs, "readFileSync", function (path, options) { + const p = pathKey(path); + if (CONTAINER_FLAG_PATHS.has(p)) { + const err = new Error(`ENOENT: no such file or directory, stat '${p}'`); + err.code = "ENOENT"; err.errno = -2; err.syscall = "stat"; err.path = p; + throw err; } - return _origReadFileSync(path, options); -}; + const r = _origReadFileSync(path, options); + return rewriteContent(p, r); +}); const _origReadFile = fs.readFile.bind(fs); -fs.readFile = function (path, ...args) { - if (isMachineIdPath(path)) { - const cb = typeof args[args.length - 1] === "function" ? args[args.length - 1] : null; - if (cb) { - const opts = args.length > 1 ? args[0] : null; - const data = typeof opts === "string" || (opts && opts.encoding) ? fakeMachineId : Buffer.from(fakeMachineId); - process.nextTick(cb, null, data); - return; - } - } - return _origReadFile(path, ...args); -}; +safeAssign(fs, "readFile", function (path, ...args) { + const cb = typeof args[args.length - 1] === "function" ? args[args.length - 1] : null; + if (!cb) return _origReadFile(path, ...args); + _origReadFile(path, ...args.slice(0, -1), (err, data) => { + if (err) return cb(err); + cb(null, rewriteContent(path, data)); + }); +}); try { - const fsp = fs.promises || require("fs/promises"); - if (fsp && fsp.readFile) { - const _origPromiseReadFile = fsp.readFile.bind(fsp); - fsp.readFile = function (path, options) { - if (isMachineIdPath(path)) { - const data = typeof options === "string" || (options && options.encoding) ? fakeMachineId : Buffer.from(fakeMachineId); - return Promise.resolve(data); - } - return _origPromiseReadFile(path, options); - }; - } + const fsp = fs.promises; + const _origPromiseReadFile = fsp.readFile.bind(fsp); + safeAssign(fsp, "readFile", function (path, options) { + return _origPromiseReadFile(path, options).then((data) => rewriteContent(path, data)); + }); } catch (_) {} -// ── 容器检测绕过 ───────────────────────────────────────────── const _origExistsSync = fs.existsSync.bind(fs); -fs.existsSync = function (p) { - const s = typeof p === "string" ? p : (p && p.toString ? p.toString() : ""); - if (s === "/.dockerenv") return false; +safeAssign(fs, "existsSync", function (p) { + if (CONTAINER_FLAG_PATHS.has(pathKey(p))) return false; return _origExistsSync(p); -}; +}); -const _origReadFileSyncCg = fs.readFileSync; -fs.readFileSync = function (path, options) { - const s = typeof path === "string" ? path : (path && path.toString ? path.toString() : ""); - if (s === "/proc/1/cgroup") { - const content = _origReadFileSyncCg(path, options); - const str = typeof content === "string" ? content : content.toString(); - return typeof options === "string" || (options && options.encoding) - ? str.replace(/docker|containerd|kubepods/gi, "system.slice") - : Buffer.from(str.replace(/docker|containerd|kubepods/gi, "system.slice")); +const _origStatSync = fs.statSync; +safeAssign(fs, "statSync", function (p, ...rest) { + if (CONTAINER_FLAG_PATHS.has(pathKey(p))) { + const err = new Error(`ENOENT: no such file or directory, stat '${pathKey(p)}'`); + err.code = "ENOENT"; err.errno = -2; err.syscall = "stat"; err.path = pathKey(p); + throw err; } - return _origReadFileSyncCg(path, options); -}; + return _origStatSync.call(this, p, ...rest); +}); -// ── 日志(调试时打开) ──────────────────────────────────────── +const _origAccessSync = fs.accessSync; +safeAssign(fs, "accessSync", function (p, ...rest) { + if (CONTAINER_FLAG_PATHS.has(pathKey(p))) { + const err = new Error(`ENOENT: no such file or directory, access '${pathKey(p)}'`); + err.code = "ENOENT"; err.errno = -2; err.syscall = "access"; err.path = pathKey(p); + throw err; + } + return _origAccessSync.call(this, p, ...rest); +}); + +// ── 调试输出 ────────────────────────────────────────────────── if (process.env.SPOOF_DEBUG === "1") { console.error("[spoof-fingerprint] Loaded. Spoofing as:", JSON.stringify({ - hostname: SPOOF.hostname, - platform: SPOOF.platform, - username: SPOOF.username, - mac: SPOOF.mac, - cpuModel: SPOOF.cpuModel, - cpuCores: SPOOF.cpuCores, - totalMem: SPOOF.totalMem, + hostname: SPOOF.hostname, platform: SPOOF.platform, timezone: SPOOF.timezone, + username: SPOOF.username, mac: SPOOF.mac, cpuModel: SPOOF.cpuModel, + cpuCores: SPOOF.cpuCores, totalMem: SPOOF.totalMem, release: SPOOF.release, }, null, 2)); } + +// 导出供自测(preload 场景下无副作用) +module.exports = { sanitizeCgroupContent, sanitizeMountinfoContent, __config: SPOOF }; diff --git a/tools/spoof-selftest.js b/tools/spoof-selftest.js new file mode 100644 index 00000000..2abd1722 --- /dev/null +++ b/tools/spoof-selftest.js @@ -0,0 +1,100 @@ +"use strict"; + +// spoof-selftest.js — spoof-fingerprint.js 的行为自测 +// 用法:node tools/spoof-selftest.js +// 说明:在任意平台可跑;平台特有的路径(/proc/self/cgroup 等)通过 +// 导出的纯函数验证内容改写逻辑,API 层验证 patch 生效与否。 + +const path = require("path"); +process.env.SPOOF_TIMEZONE = process.env.SPOOF_TIMEZONE || "America/New_York"; +process.env.SPOOF_HOSTNAME = process.env.SPOOF_HOSTNAME || "selftest-host"; + +const spoof = require(path.join(__dirname, "spoof-fingerprint.js")); +const os = require("os"); +const fs = require("fs"); +const cp = require("child_process"); + +let pass = 0, fail = 0; +function check(name, cond, detail) { + if (cond) { pass++; console.log(` ok ${name}`); } + else { fail++; console.log(`FAIL ${name}${detail ? " — " + detail : ""}`); } +} + +console.log("== 纯函数:cgroup / mountinfo 改写 =="); +const cg = spoof.sanitizeCgroupContent( + "12:devices:/docker/abc123\n0::/system.slice/docker-containerd.scope\n"); +check("cgroup 容器词替换", !/docker|containerd|kubepods|libpod|lxc/i.test(cg), cg); + +const mi = spoof.sanitizeMountinfoContent([ + '36 35 98:0 /mnt/docker/overlaydirs/x/merged / rw,relatime - overlay overlay rw,lowerdir=/var/lib/docker/x,upperdir=/var/lib/docker/y', + '40 1 0:40 / /home/cl anthy rw - tmpfs tmpfs rw', + '41 1 0:41 / /var/lib/docker/volumes/vol rw - ext4 /dev/sda1 rw', + '99 1 0:42 / /data rw - ext4 /dev/sdb1 rw', +].join("\n")); +check("mountinfo 无 overlay 残留", !/overlay/.test(mi)); +check("mountinfo 根挂载重建为 ext4", / - ext4 \/dev\/vda1 rw$/.test(mi.split("\n")[0]), mi.split("\n")[0]); +check("mountinfo 丢弃 docker 卷行", !/docker\/volumes/.test(mi)); +check("mountinfo 保留正常行", /\/data rw - ext4/.test(mi)); + +console.log("== API patch =="); + +// dockerenv(mac 上文件本就不存在,patch 后应一致表现为不存在) +check("existsSync(/.dockerenv) === false", fs.existsSync("/.dockerenv") === false); +let statThrew = false; +try { fs.statSync("/.dockerenv"); } catch (e) { statThrew = e.code === "ENOENT"; } +check("statSync(/.dockerenv) 抛 ENOENT", statThrew); +let accessThrew = false; +try { fs.accessSync("/run/.containerenv"); } catch (e) { accessThrew = e.code === "ENOENT"; } +check("accessSync(/run/.containerenv) 抛 ENOENT", accessThrew); + +// 时区 +const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; +check(`resolvedOptions().timeZone === ${process.env.SPOOF_TIMEZONE}`, + tz === process.env.SPOOF_TIMEZONE, tz); +const off = new Date().getTimezoneOffset(); +const expect = (() => { + const Real = Object.getPrototypeOf(Intl.DateTimeFormat).constructor; + return off; // offset 一致性在下方用独立计算验证 +})(); +// 用未 patch 的时区换算交叉验证 getTimezoneOffset +const dtf = new Intl.DateTimeFormat("en-US", { + timeZone: process.env.SPOOF_TIMEZONE, hour12: false, + year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", +}); +const parts = {}; for (const p of dtf.formatToParts(new Date())) parts[p.type] = p.value; +const asUTC = Date.UTC(+parts.year, +parts.month - 1, +parts.day, + +parts.hour % 24, +parts.minute, +parts.second); +const expectOff = Math.round((asUTC - Date.now()) / 60000); +check(`getTimezoneOffset 与目标时区一致 (${expectOff})`, Math.abs(off - expectOff) <= 1, off); +void expect; + +// uname 拦截 +check(`execSync("uname -r") → 伪装 release`, String(cp.execSync("uname -r")).trim() === spoof.__config.release); +check(`execSync("uname -s") → Linux`, String(cp.execSync("uname -s")).trim() === "Linux"); +check("execSync('hostname') → 伪装 hostname", String(cp.execSync("hostname")).trim() === spoof.__config.hostname); +const sync = cp.spawnSync("uname", ["-m"]); +check("spawnSync uname -m → machine", String(sync.stdout).trim() === spoof.__config.machine); + +// os.* +check("os.cpus 型号伪装", os.cpus().every((c) => c.model === spoof.__config.cpuModel)); +check("os.availableParallelism 伪装", os.availableParallelism() === spoof.__config.cpuCores); +check("os.totalmem 伪装", os.totalmem() === spoof.__config.totalMem); +const ni = os.networkInterfaces(); +check("networkInterfaces 无 docker 网段", !/172\.(1[6-9]|2\d|3[01])\./.test(JSON.stringify(ni))); +check("networkInterfaces 无 02:42 MAC", !/02:42:/.test(JSON.stringify(ni))); +check("os.hostname 伪装", os.hostname() === spoof.__config.hostname); +check("os.release 伪装", os.release() === spoof.__config.release); + +// machine-id 透传:不拦截(mac 上无此文件 → 原样 ENOENT;Linux 容器内返回真实文件) +let midPassthrough = false; +try { fs.readFileSync("/etc/machine-id"); midPassthrough = false; } +catch (e) { midPassthrough = e.code === "ENOENT"; } +if (process.platform === "linux") { + check("machine-id 透传(读到真实内容)", fs.readFileSync("/etc/machine-id", "utf8").length >= 32); +} else { + check("machine-id 不拦截(非 Linux 原样 ENOENT)", midPassthrough); +} + +console.log(`\n结果: ${pass} 通过, ${fail} 失败`); +process.exit(fail ? 1 : 0);