From 8fd147bccc1362ac5849c77e655c9fb5a22a5a89 Mon Sep 17 00:00:00 2001 From: Gloridust Date: Sat, 6 Jun 2026 01:41:45 +0800 Subject: [PATCH] fix(P0): unique persistent machine-id per instance + manual reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All instances shared the image-baked machine-id (a67bf09f...), so Tencent saw every WechatOnCloud account worldwide as one "device" — a textbook device-farm signal triggering risk control and the forced-logout loop reported across old and new versions. - docker/woc-identity.sh: new /custom-cont-init.d/00-woc-identity hook — generates a unique machine-id on first start, persists it in the data volume (survives restart/upgrade/recreate), writes /etc/machine-id + /var/lib/dbus/machine-id, removes /.dockerenv. Existing instances get a fresh unique id on first upgraded start (volume lacks the file). - regenInstanceMachineId + POST /api/admin/instances/:id/regen-machine-id: roll a brand-new device id and restart, for accounts re-flagged by risk control. Gated on the hook being present (old image → instructs upgrade). - Admin 实例卡片「安全」弹窗新增「重置设备 ID 并重启」。 Verified: two fresh containers get distinct machine-ids; id persists across restart; regen (rm persisted file + restart) yields a new persistent id. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker/Dockerfile | 7 +++++- docker/woc-identity.sh | 43 +++++++++++++++++++++++++++++++++++ panel/server/src/docker.ts | 20 ++++++++++++++++ panel/server/src/index.ts | 16 +++++++++++++ panel/web/src/api.ts | 2 ++ panel/web/src/pages/Admin.tsx | 39 +++++++++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 docker/woc-identity.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index 5b1c356..12006f0 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -72,7 +72,12 @@ RUN chmod +x /woc/wechat-ctl.sh COPY autostart /defaults/autostart RUN chmod +x /defaults/autostart -# 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本(否则旧实例升级后用不上新逻辑) +# 启动钩子(00):给每个实例唯一且持久的 machine-id,避免所有实例共用镜像里烤死的同一个, +# 触发腾讯"设备农场"风控导致登录即被强制退出。须在 autostart(拉起微信)之前执行,故用 00 前缀。 +COPY woc-identity.sh /custom-cont-init.d/00-woc-identity +RUN chmod +x /custom-cont-init.d/00-woc-identity + +# 启动钩子(01):每次启动用镜像内最新 autostart 覆盖数据卷旧副本(否则旧实例升级后用不上新逻辑) COPY woc-update-autostart /custom-cont-init.d/01-woc-autostart RUN chmod +x /custom-cont-init.d/01-woc-autostart diff --git a/docker/woc-identity.sh b/docker/woc-identity.sh new file mode 100644 index 0000000..7c96565 --- /dev/null +++ b/docker/woc-identity.sh @@ -0,0 +1,43 @@ +#!/usr/bin/with-contenv bash +# linuxserver 启动钩子(/custom-cont-init.d,root 身份,每次启动、在各服务起来之前执行)。 +# +# 目的:给每个实例一个【唯一且持久】的设备身份,避免所有实例共用镜像里烤死的同一个 machine-id。 +# +# 背景(P0):Debian/基础镜像把 machine-id 固化在镜像层里,于是全世界每个 wechat-on-cloud +# 实例的 /etc/machine-id 都相同。machine-id 是 Linux 上最接近"设备指纹"的标识,微信会读它做 +# 风控;成千上万个账号共用同一个 machine-id = 典型"设备农场"特征 → 被腾讯批量判风险 → 登录即 +# 被强制退出、反复循环。 +# +# 解法: +# 1) 在数据卷(/config,随实例持久)里存一个本实例专属的随机 machine-id; +# 2) 每次启动把它写回 /etc/machine-id 与 /var/lib/dbus/machine-id。 +# 这样:实例之间互不相同(破掉设备农场特征),且重启 / 升级 / 重建容器都保持不变("设备老在变" +# 同样可疑)。仅在卷里尚无该文件时才生成,故老实例首启会拿到一个新的唯一 id(之后恒定)。 +set -e + +ID_FILE=/config/.woc-machine-id + +# 生成 32 位小写十六进制(systemd machine-id 格式):优先用内核 uuid 源,去掉连字符。 +if [ ! -s "$ID_FILE" ]; then + if [ -r /proc/sys/kernel/random/uuid ]; then + tr -d '-' < /proc/sys/kernel/random/uuid | tr 'A-F' 'a-f' > "$ID_FILE" + else + head -c16 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$ID_FILE" + fi +fi + +MID="$(tr -dc 'a-f0-9' < "$ID_FILE" | head -c 32)" +# 兜底:若文件内容异常(长度不足 32),重新生成 +if [ "${#MID}" -ne 32 ]; then + MID="$(tr -d '-' < /proc/sys/kernel/random/uuid | tr 'A-F' 'a-f' | head -c 32)" + printf '%s\n' "$MID" > "$ID_FILE" +fi + +printf '%s\n' "$MID" > /etc/machine-id 2>/dev/null || true +mkdir -p /var/lib/dbus +printf '%s\n' "$MID" > /var/lib/dbus/machine-id 2>/dev/null || true + +# 抹掉最明显的容器标记(微信可能据此判定非真实桌面)。/.dockerenv 由 docker 注入,删掉无副作用。 +rm -f /.dockerenv 2>/dev/null || true + +echo "[woc-identity] machine-id 已设为本实例专属(持久化于数据卷)" diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index c3603dc..4ca365e 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -165,6 +165,26 @@ export async function upgradeInstance(inst: Instance): Promise { await runInstance(inst); } +// 重置实例的设备 machine-id:删掉持久化的 .woc-machine-id 后重启,由 00-woc-identity 钩子重新生成 +// 一个全新的唯一值(相当于"换一台新设备")。用于某账号被腾讯风控标记后手动滚新设备身份。 +// 仅对含身份钩子的新镜像有效;旧镜像(升级前)无钩子,先 throw 提示升级,避免做无用功。 +export async function regenInstanceMachineId(inst: Instance): Promise { + const hasHook = ( + await execCapture(inst, [ + 'sh', + '-c', + 'test -f /custom-cont-init.d/00-woc-identity && echo yes || echo no', + ]) + ).trim(); + if (hasHook !== 'yes') { + throw new Error('该实例运行的是旧镜像(无设备身份模块),请先「升级实例」后再重置设备 ID'); + } + // 删除持久化文件;重启时钩子检测到缺失 → 生成新的唯一 machine-id 并写回卷 + await execCapture(inst, ['sh', '-c', 'rm -f /config/.woc-machine-id']); + await stopInstance(inst); + await runInstance(inst); +} + // 停止实例容器(保留容器与数据卷,可再启动)。 export async function stopInstance(inst: Instance): Promise { try { diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 1f36b3d..ede6d0e 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -53,6 +53,7 @@ import { listOrphanContainers, removeContainerById, instanceMemoryMB, + regenInstanceMachineId, } from './docker.js'; import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js'; import { parseHost, parseAllowedHosts, isAllowedHost } from './host-guard.js'; @@ -395,6 +396,21 @@ app.put('/api/admin/instances/:id/mem-limits', async (req, reply) => { } }); +// 重置实例的设备 machine-id(仅管理员):滚一个全新的唯一设备身份并重启实例。 +// 用于某微信账号被腾讯按"设备风险"标记、登录即被踢时,像"换台新设备"一样恢复。会触发重新扫码登录。 +app.post('/api/admin/instances/:id/regen-machine-id', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const id = (req.params as any).id; + const inst = findInstance(id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + await regenInstanceMachineId(inst); + return { ok: true }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '重置设备 ID 失败' }); + } +}); + // 删除实例(仅管理员):默认保留数据卷,?purge=1 才永久删聊天记录 app.delete('/api/admin/instances/:id', async (req, reply) => { if (!requireAdmin(req, reply)) return; diff --git a/panel/web/src/api.ts b/panel/web/src/api.ts index ee97945..07b90d8 100644 --- a/panel/web/src/api.ts +++ b/panel/web/src/api.ts @@ -91,6 +91,8 @@ export const api = { method: 'POST', body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined }), }), + regenMachineId: (id: string) => + req(`/api/admin/instances/${id}/regen-machine-id`, { method: 'POST' }), getInstanceMemLimits: (id: string) => req(`/api/admin/instances/${id}/mem-limits`), setInstanceMemLimits: (id: string, soft: number | null | undefined, hard: number | null | undefined) => diff --git a/panel/web/src/pages/Admin.tsx b/panel/web/src/pages/Admin.tsx index 6e0da8c..6a0af94 100644 --- a/panel/web/src/pages/Admin.tsx +++ b/panel/web/src/pages/Admin.tsx @@ -534,6 +534,7 @@ function ResetPassword({ user, onClose, onDone }: { user: PanelUser; onClose: () // hard:超过即强制重启(无视会话,防止 OOM) // 留空 = 使用面板全局默认(来自 env)。 function InstanceSecurity({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) { + const { toast, confirm } = useUI(); const [data, setData] = useState(null); // 输入字段:空串 = "使用默认"(→ 提交时映射为 null) const [softStr, setSoftStr] = useState(''); @@ -541,6 +542,28 @@ function InstanceSecurity({ inst, onClose, onDone }: { inst: InstanceWithStatus; const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); const [loaded, setLoaded] = useState(false); + const [regenBusy, setRegenBusy] = useState(false); + + const regenMachineId = async () => { + const ok = await confirm({ + title: '重置该实例的设备 ID?', + body: '会生成一个全新的设备标识(machine-id)并重启实例,相当于"换一台新设备"。微信需要重新扫码登录。适用于该账号被微信判定设备风险、登录即被强制退出的情况。', + danger: true, + confirmText: '重置并重启', + }); + if (!ok) return; + setRegenBusy(true); + try { + await api.regenMachineId(inst.id); + toast('已重置设备 ID,实例正在重启,请稍后重新扫码登录', 'ok'); + onClose(); + onDone(); + } catch (e: any) { + toast(e.message || '重置失败', 'error'); + } finally { + setRegenBusy(false); + } + }; // 首次加载 + 每 5s 刷新 currentMB(运行实例的实时内存) useEffect(() => { @@ -664,6 +687,22 @@ function InstanceSecurity({ inst, onClose, onDone }: { inst: InstanceWithStatus;
提示:日常活跃内存约 1500 MiB;soft 建议略高于此(如 2000),hard 建议远低于宿主可用内存(如 3000~4000)。
+ +
设备身份(machine-id)
+
+ 微信会用设备标识做风控。若该账号被判定设备风险、登录后被强制退出且反复循环, + 可重置为一个全新的唯一设备 ID(相当于换台新设备),再重新扫码登录。会重启该实例。 +
+ + {err &&
{err}
} )}