9 Commits

  • feat: 中文输入条 + 文件下载/另存修复 + 卡连接自愈 + CF/MAC/音频
    中文输入(彻底改造,弃用脆弱的 VNC IME 拦截)
    - 关闭 KasmVNC enable_ime:VNC 直接打字回归纯 keysym,英文/数字正常、不再损坏
    - 新增底部「中文输入条」:面板真实 textarea 原生输入法 → POST /type → 容器内 xclip+xdotool
      粘贴进微信,可靠且与浏览器/输入法无关。flex 列布局(nav/画面/输入条三者并列不遮挡),
      牛奶布艺主题配色,可一键收起。
    
    稳定性 / 自愈
    - watchdog 新增响应性探测:实例 I/O/服务 stall(进程在、显示在线但读不出 VNC 文件、永远"正在
      连接桌面")时,连续 2 次无响应即自动重启自愈
    - 前端 12s 未加载出来 → 「桌面无响应」+ 重新连接/重启,不再无限转圈
    - PWA 新 SW 接管即自动重载一次,更新一刷即生效(修"改了仍看旧界面")
    
    文件
    - 下载:正确解析 tar、跳过 PAX 扩展头(中文名文件曾因此大小错误/损坏)
    - 另存:每次启动确保 /config/Desktop 归 abc,修微信另存"保存失败"
    
    安全 / 伪装
    - Host 白名单支持 *.example.com 通配 + X-Forwarded-Host(修 CF 反代域名仍被拒)
    - 设备伪装新增真实网卡 MAC(厂商 OUI,替代容器本地管理位 MAC)
    
    音频:扬声器自动连接(首个手势激活)、焦点离开自动断、麦克风(HTTPS)
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • docs: add社媒 links (Twitter / Telegram) to README
    Hero badges + a 交流与关注 section: x.com/gloridust1024, t.me/WechatOnCloud.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • fix(P0): unique persistent machine-id per instance + manual reset
    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) <noreply@anthropic.com>
  • ci: optional Docker Hub mirror in release workflow
    - release.yml: dual-push to GHCR + Docker Hub when vars.DOCKERHUB_USERNAME
      is set; falls back to GHCR-only when unset (no behavior change for forks).
    - .env.example: surface docker.io as a first-class WOC_IMAGE_PREFIX option.
    - doc/发布到GHCR.md: document the one-time Variable + Secret setup and the
      prerequisite of pre-creating the public repos on hub.docker.com.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  • docs(security): clarify multi-host allowlist + echo rejected host in 400
    - .env.example: surface multi-domain syntax (PANEL_ALLOWED_HOSTS=a,b,c),
      IPv6 literal example, and reverse-proxy troubleshooting tip.
    - index.ts: 400 response includes the rejected `host` and a hint pointing
      at PANEL_ALLOWED_HOSTS — drops the diagnostic floor when reverse-proxy
      Host-passthrough is misconfigured.
    
    Follow-up to #13 (DNS-rebinding allowlist).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  • Merge pull request #13 from aaronjmars/security/host-allowlist-dns-rebinding
    fix(security): gate panel Host header to block DNS rebinding
  • fix(security): gate panel Host header to block DNS rebinding
    Without Host validation, a malicious page the operator visits can use DNS
    rebinding to point a hostname at the panel's loopback / LAN IP and drive
    every authenticated API from the operator's own browser — including the
    docker.sock-backed admin endpoints. The README's "intranet-only" guidance
    does not cover this: the browser is the trust-boundary crossing.
    
    Add an onRequest hook (plus a Host check on raw WebSocket upgrades) that
    allows loopback + RFC1918 LAN by default and accepts public hostnames via
    PANEL_ALLOWED_HOSTS (documented in .env.example and threaded through
    docker-compose.yml). 35 inject()-driven assertions; tsc --noEmit clean.
    
    Detected by Aeon + manual review (DNS-rebinding-gate axis).
    Severity: high
    CWE-346 (Origin Validation Error)
18 changed files with 1055 additions and 141 deletions
+40 -6
View File
@@ -8,12 +8,14 @@ WOC_PASSWORD=wechat
# 同时作用于面板镜像和新建微信实例所用镜像。
WOC_VERSION=latest
# 镜像源前缀(registry + 命名空间)。默认 GHCR 官方
# 中国大陆网络拉 ghcr.io 常 TLS 超时,可改用 GHCR 反代镜像(前提:GHCR 包已设为 Public):
# WOC_IMAGE_PREFIX=ghcr.nju.edu.cn/gloridust # 南京大学镜像站反代 ghcr.io(较稳定)
# 镜像源前缀(registry + 命名空间)。本项目镜像同步发布到 GHCR 和 Docker Hub
#
# 默认走 GHCR;如果 ghcr.io 拉不动,按需切到其它源:
# WOC_IMAGE_PREFIX=docker.io/gloridust # Docker Hub(国际网络通用、无需 PAT 即可拉公开镜像)
# WOC_IMAGE_PREFIX=ghcr.nju.edu.cn/gloridust # 南京大学镜像站反代 ghcr.io(国内较稳)
# WOC_IMAGE_PREFIX=registry.cn-hangzhou.aliyuncs.com/你的命名空间 # 自己的阿里云 ACR / 腾讯 TCR / 华为 SWR
#
# 反代会把 ghcr.io/gloridust/xxx 映射为 <反代>/gloridust/xxx。
# 若用自己的国内仓库(阿里云 ACR / 腾讯 TCR / 华为 SWR),填该仓库的完整前缀即可,例如:
# WOC_IMAGE_PREFIX=registry.cn-hangzhou.aliyuncs.com/你的命名空间
WOC_IMAGE_PREFIX=ghcr.io/gloridust
# 宿主用户 uid/gid(飞牛上用 `id` 命令查看;单用户 NAS 一般是 1000)。
@@ -28,8 +30,31 @@ WOC_TZ=Asia/Shanghai
# 面板是唯一对外入口;微信实例不直接对宿主暴露,由面板反向代理。
WOC_HTTP_PORT=36080
# 面板允许的 Host 头白名单。用于阻止 DNS rebinding:攻击者控制的域名解析到面板
# LAN/loopback 地址后,浏览器视其为同源、自动带上面板 cookie,能从受害者浏览器内
# 发起任何已鉴权 API 调用(含挂载 docker.sock 的管理端点);sameSite=lax 不挡此攻击。
#
# 默认始终放行:localhost / 127.0.0.1 / ::1 + RFC1918 私网(10/8、172.16-31/12、
# 192.168/16、169.254/16)—— 覆盖直连 NAS / 局域网访问场景,**不需要改任何东西**。
#
# 套 HTTPS 反代部署(Caddy / nginx / 飞牛 内置反代 / Cloudflare Tunnel 等)时,把对外
# 域名加进来。**支持多个域名,用英文逗号分隔**;填免端口主机名,大小写不敏感:
#
# 单域名: PANEL_ALLOWED_HOSTS=woc.example.com
# 多域名: PANEL_ALLOWED_HOSTS=woc.example.com,woc.lan,wechat.mynas.cn
# 通配子域: PANEL_ALLOWED_HOSTS=*.example.com (匹配 a.example.com,不含裸 example.com
# 含 IPv6 字面量:PANEL_ALLOWED_HOSTS=[2001:db8::1]
#
# 改完务必用 **docker compose up -d** 让容器带新环境重建;只 `docker restart` **不会**加载新值!
#
# 排错:若反代/Cloudflare 部署后仍看到 400 "Host header not allowed",错误响应里会回显面板实际
# 收到的 host / forwardedHost —— 把那个值(或其通配)加进本白名单即可。面板也会自动接受
# X-Forwarded-Host(可信反代设置),故多数反代无需额外配置。
PANEL_ALLOWED_HOSTS=
# ── 音频 / 麦克风 / 摄像头 ───────────────────────────────────
# 音频(听)开箱即用,进入桌面后点 KasmVNC 左侧工具条的扬声器开启。
# 音频(听)**自动开启**,进入桌面、点一下画面(浏览器自动播放策略要求一次手势)即可出声,
# 无需手动找工具条。仅当前聚焦的实例出声,切走/切到别的实例会自动断开,避免多端串音。
# 麦克风(说) / 摄像头(视频):浏览器要求"安全上下文",即必须通过 HTTPS 访问面板
# (或 localhost)。生产环境务必给面板套 HTTPS(反代/证书),否则浏览器会禁用麦克风与摄像头。
#
@@ -56,6 +81,15 @@ WOC_ENABLE_GPU=
# 避免拖垮整台宿主。常规使用单实例约需 1~1.5GiB,设 3~4 较稳妥。
WOC_INSTANCE_MEM_GB=
# ── 设备伪装(降低被微信判"非真实设备"风控的概率) ───────────
# 每个实例默认已自动做:唯一且持久的 machine-id(避免全网共用同一个,触发"设备农场"风控)、
# 像个人电脑的 hostname(不再是 woc-wx-<hex>)、移除 /.dockerenv 容器标记。这些恒定开启。
#
# WOC_SPOOF_OS 是否把 /etc/os-release 伪装成 deepin(微信官方支持的发行版,且 Deepin 基于
# Debian、与本镜像用户态一致)。默认 1(开);设 0 恢复显示真实 Debian。
# 说明:设备伪装是「尽力而为」,非保证不被封;详见 doc/设备伪装.md。
WOC_SPOOF_OS=1
# ── 自愈 watchdog(应对 KasmVNC/Xvnc 长跑内存泄漏) ───────────
# 实测 Xvnc 长跑 24h 可膨胀到 ~9GiB,原因在 KasmVNC/Xvnc 自身的 framebuffer / 软渲染 cache
# 累积,不归本项目控制。面板内置一个 watchdog:周期性检查每个 running 实例的 working set
+17 -1
View File
@@ -50,11 +50,27 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 可选:同时推 Docker Hub。仅当仓库设置里加了 vars.DOCKERHUB_USERNAME 时启用,
# 让 fork 用户开箱即可走 GHCR;想加 Docker Hub 双推只需配 1 个 Variable + 1 个 Secret。
# 配置方法:repo Settings → Secrets and variables → Actions
# · Variables → DOCKERHUB_USERNAME = 你的 Docker Hub 用户名
# · Secrets → DOCKERHUB_TOKEN = Docker Hub Access Tokenhub.docker.com → Account Settings → Personal access tokens
- name: Log in to Docker Hub
if: vars.DOCKERHUB_USERNAME != ''
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker metadata (tags + labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}
# metadata-action 接受多行 images:每行一个目标 registrybuild-push-action 会同时推。
# Docker Hub 那行只在配了 DOCKERHUB_USERNAME 时出现;否则只剩 GHCR 一行(与旧行为一致)。
images: |
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}
${{ vars.DOCKERHUB_USERNAME != '' && format('docker.io/{0}/{1}', vars.DOCKERHUB_USERNAME, matrix.image) || '' }}
# 语义化标签:vX.Y.Z → X.Y.Z / X.Y / X;默认分支额外打 latest
tags: |
type=semver,pattern={{version}}
+10
View File
@@ -12,6 +12,8 @@
<a href="https://github.com/Gloridust/WechatOnCloud/issues"><img src="https://img.shields.io/github/issues/Gloridust/WechatOnCloud?style=flat-square" alt="issues" /></a>
<img src="https://img.shields.io/badge/arch-amd64%20%7C%20arm64-2496ED?style=flat-square&logo=docker&logoColor=white" alt="arch" />
<img src="https://img.shields.io/badge/PWA-ready-5A0FC8?style=flat-square" alt="pwa" />
<a href="https://x.com/gloridust1024"><img src="https://img.shields.io/badge/Twitter-@gloridust1024-1DA1F2?style=flat-square&logo=x&logoColor=white" alt="twitter" /></a>
<a href="https://t.me/WechatOnCloud"><img src="https://img.shields.io/badge/Telegram-WechatOnCloud-26A5E4?style=flat-square&logo=telegram&logoColor=white" alt="telegram" /></a>
</p>
<p>
@@ -35,6 +37,8 @@
**一句话原理**:每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器;同一实例被多个浏览器连 = 共享同一个微信会话。前面一层自研**面板**是唯一对外入口,经 docker.sock 按需创建/销毁实例并反向代理。
交流群: [@WechatOnCloud](https://t.me/WechatOnCloud)
---
## 核心特性
@@ -58,6 +62,7 @@
|------|------|
| [运行原理与 Docker 指南](doc/运行原理.md) | 工作原理 + 架构图;面向 Docker 新手的逐步拆解、常用命令、架构自动适配 |
| [部署与运维](doc/部署与运维.md) | 数据持久化、常见问题排查、忘记超管密码的离线找回、目录结构 |
| [设备伪装与风控应对](doc/设备伪装.md) | 唯一 machine-id / 真实 hostname / os-release 伪装;账号被微信强制退出循环时怎么办 |
| [发布到 GHCR](doc/发布到GHCR.md) | 用 GitHub Actions 或本机 buildx 把镜像发布到 GHCR |
| [技术方案](doc/技术方案.md) | 完整设计文档与选型权衡 |
@@ -176,6 +181,11 @@ docker compose up -d # 直接从 GHCR 拉取
- [ ] 掉登录时 web 端二维码重扫入口
- [~] 打包成飞牛原生 fpk 分发(工程已就绪见 [fnos/](fnos/),待真实设备验证 docker.sock 权限)
## 交流与关注
- 🐦 Twitter / X[@gloridust1024](https://x.com/gloridust1024) — 更新与动态
- ✈️ Telegram[@WechatOnCloud](https://t.me/WechatOnCloud) — 交流群 / 问题反馈
## 致谢
创意启发自懒猫微服(原 Deepin 团队做的硬件产品),推荐有经济实力、追求稳定运营的朋友尝试!
+22 -2
View File
@@ -1,8 +1,8 @@
# 发布到 GHCR
# 发布镜像
> 返回 [← README](../README.md)
把两个镜像(`woc-panel``wechat-on-cloud`)发布到 GitHub Container Registry,供他人 `docker compose up -d` 直接拉取。两种方式任选其一
把两个镜像(`woc-panel``wechat-on-cloud`)发布到容器仓库,供他人 `docker compose up -d` 直接拉取。默认走 GHCR;可选同步推到 Docker Hub
---
@@ -19,6 +19,26 @@ gh release create v1.0.0 --title v1.0.0 --notes "..."
> 注意:单纯 push tag 只产出 `X.Y.Z / X.Y / X`**不会更新 `latest`**;要更新 `latest` 请改用 **发布 Release** 或在 Actions 里手动 `workflow_dispatch`。
### 可选:同步推到 Docker Hub
GHCR 拉取在国际网络下偶尔被 TLS / DNS 干扰;Docker Hub 覆盖面更广,且公开镜像 `docker pull` 无需先 `docker login`。workflow 已内置 Docker Hub 双推开关:**配齐两个变量即可启用,没配就保持只推 GHCR**(向后兼容)。
**一次性配置**GitHub repo → Settings → Secrets and variables → Actions):
| 类型 | Name | Value |
|---|---|---|
| Variable | `DOCKERHUB_USERNAME` | 你的 Docker Hub 用户名(如 `gloridust` |
| Secret | `DOCKERHUB_TOKEN` | Docker Hub Access Token[hub.docker.com → Account Settings → Personal access tokens](https://hub.docker.com/settings/personal-access-tokens) → New Access Token,权限选 `Read & Write` |
> Variable 和 Secret 是两个不同的 tab;用户名放 Variable 即可(不敏感),Token 必须放 Secret。
> 在 Docker Hub 上**预先建好两个 public repo**`<用户名>/woc-panel`、`<用户名>/wechat-on-cloud`hub.docker.com → Create Repository),否则首次推送会失败(Docker Hub 不会自动建 repo)。
配齐后,下次发版(或 `workflow_dispatch` 手动触发)就会同时推到:
- `ghcr.io/<github-owner>/woc-panel:X.Y.Z`
- `docker.io/<dockerhub-user>/woc-panel:X.Y.Z`
使用者在 `.env``WOC_IMAGE_PREFIX=docker.io/<dockerhub-user>` 即可从 Docker Hub 拉。
---
## 方式 B · 本机 buildx 手动构建并推送(不走 Actions)
+53
View File
@@ -0,0 +1,53 @@
# 设备伪装与风控应对
> 返回 [← README](../README.md)
## 背景
微信 Linux 端会采集**设备指纹**做风控。容器/虚拟化环境若指纹异常(尤其是**大量实例共用同一指纹**),会被判定为"设备农场 / 非真实设备",表现为:**登录后立即被以安全原因强制退出,再次登录仍被踢,循环无法使用**。
> 注意:虚拟化本身不等于风险——高校、企业大量使用云桌面 / 瘦客户端 / 虚拟机,都是虚拟化的真实办公设备。问题出在**指纹不像一台独立的真实设备**(最典型:所有实例共用一个 machine-id)。本项目的目标就是让每个实例看起来像一台普通、独立的 Linux 桌面。
## 本项目默认做了什么
以下措施**默认全部开启**,每个实例自动生效(新建实例 / 升级实例后):
| 措施 | 说明 | 开关 |
|------|------|------|
| **唯一且持久的 machine-id** | 每个实例首启生成专属 machine-id,存入数据卷,重启/升级/重建都不变。解决"全网实例共用镜像里烤死的同一个 machine-id"这一最致命信号。 | 恒开 |
| **真实的 hostname** | 内部主机名伪装成"个人电脑"样式(如 `lenovo-pc-372`),不再是 `woc-wx-<hex>` 这种容器/服务器特征。每实例不同、稳定不变。 | 恒开 |
| **移除 `/.dockerenv`** | 删掉 Docker 注入的容器标记文件。 | 恒开 |
| **真实网卡 MAC** | 用常见网卡厂商 OUIIntel/Realtek 等)+ 由实例 id 稳定派生的后三段,替代容器默认带"本地管理位"的 MAC`02/26/ee…` 开头 = 明显非真实硬件)。每实例不同、稳定不变。 | 恒开 |
| **os-release 伪装成 deepin** | `/etc/os-release` 显示为 deepin 23(微信官方支持的发行版;Deepin 基于 Debian,与本镜像用户态一致,不自相矛盾)。 | `WOC_SPOOF_OS`,默认 1,设 0 恢复 Debian |
实现位置:`docker/woc-identity.sh`(启动钩子 `/custom-cont-init.d/00-woc-identity`,root 身份、在微信启动前执行)+ `panel/server/src/docker.ts`hostname / MAC / 开关透传,建容器时设置)。
## 手动「重置设备 ID」
若某个微信账号**已经被风控标记**、升级后仍登录即被踢,可以给它换一个全新的设备身份(相当于换一台新电脑):
**管理页 → 该实例卡片 →「安全」→「重置设备 ID 并重启」**
会生成一个新的唯一 machine-id 并重启该实例,之后重新扫码登录。仅对已升级到新镜像的实例可用(旧镜像无设备身份模块,会提示先「升级实例」)。
## 已被标记账号的恢复
- 设备指纹是**面向未来**的:换了干净的唯一指纹后,**新的登录**才会以新设备身份示人。
- 已被风控标记的账号可能有**冷却期**,换设备 ID + 重新登录后不一定立刻恢复,需观察一段时间。
- 建议先拿**非主力账号**验证,确认稳定后再迁主号,避免主号反复触发风控。
## 局限与风险(务必知悉)
- **这是"尽力而为",不是保证。** 风控是持续对抗:腾讯会不断增加新的检测维度(如 X 服务器厂商串、无 GPU 软渲染、SMBIOS 缺失、行为特征等),本项目只能覆盖已知的、可控的指纹。
- **有封号风险。** 在非官方环境运行微信本身违反其使用条款;是否使用、用于何种账号,请自行评估。强烈建议**不要用主力/重要账号**承担试验风险。
- 若伪装后仍被频繁踢,可尝试:`WOC_SPOOF_OS=0` 恢复真实 Debian(排除 os 伪装反而被交叉校验的可能),或反馈 issue 一起排查更深层信号。
## 调参
`.env`(复制自 [.env.example](../.env.example)):
```bash
WOC_SPOOF_OS=1 # 1=伪装成 deepin(默认),0=显示真实 Debian
```
machine-id / hostname / dockerenv 三项无开关、恒定开启(它们没有合理的关闭理由)。
+5
View File
@@ -28,10 +28,15 @@ services:
- WOC_INSTANCE_MEM_SOFT_MB=${WOC_INSTANCE_MEM_SOFT_MB:-1500}
- WOC_INSTANCE_MEM_HARD_MB=${WOC_INSTANCE_MEM_HARD_MB:-2500}
- WOC_WATCHDOG_INTERVAL_SEC=${WOC_WATCHDOG_INTERVAL_SEC:-300}
# 设备伪装:os-release 伪装成 deepin(默认开,=0 关恢复 Debian)。详见 .env.example / doc/设备伪装.md。
- WOC_SPOOF_OS=${WOC_SPOOF_OS:-1}
# 面板首个管理员账号(仅首次启动、无账号文件时写入;务必改掉默认密码)
- PANEL_ADMIN_USER=${WOC_USER:-admin}
- PANEL_ADMIN_PASSWORD=${WOC_PASSWORD:-wechat}
- PANEL_DATA=/data/accounts.json
# DNS-rebinding 防护:套 HTTPS 反代部署时把对外域名加进 .env(详见 .env.example)。
# 默认仅放行 loopback + RFC1918 私网,直连 NAS / 局域网无需改动。
- PANEL_ALLOWED_HOSTS=${PANEL_ALLOWED_HOSTS:-}
volumes:
# 面板账号数据(用户、实例元信息、密码哈希)
+6 -1
View File
@@ -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
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/with-contenv bash
# linuxserver 启动钩子(/custom-cont-init.droot 身份,每次启动、在各服务起来之前执行)。
#
# 目的:给每个实例一个【唯一且持久】的设备身份,避免所有实例共用镜像里烤死的同一个 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
# 设备伪装:把 /etc/os-release 改成 deepin(微信官方支持的发行版;Deepin 本就基于 Debian
# 与本镜像的 Debian 用户态一致,不自相矛盾)。面板按 WOC_SPOOF_OS 控制(默认开,=0 关)。
# /etc/os-release 是指向 /usr/lib/os-release 的软链,重定向会写穿到目标,故直接写它即可。
if [ "${WOC_SPOOF_OS:-1}" = "1" ]; then
cat > /etc/os-release <<'OSEOF'
PRETTY_NAME="deepin 23"
NAME="deepin"
VERSION_ID="23"
VERSION="23"
VERSION_CODENAME=beige
ID=deepin
ID_LIKE=debian
HOME_URL="https://www.deepin.org/"
BUG_REPORT_URL="https://bbs.deepin.org/"
OSEOF
fi
echo "[woc-identity] machine-id 已设为本实例专属(持久化于数据卷);os 伪装=${WOC_SPOOF_OS:-1}"
+5
View File
@@ -7,3 +7,8 @@ mkdir -p /config/.config/openbox
cp /defaults/autostart /config/.config/openbox/autostart
chmod +x /config/.config/openbox/autostart
chown "${PUID:-1000}:${PGID:-1000}" /config/.config/openbox/autostart 2>/dev/null || true
# 文件中转目录(桌面):确保归 abc(PUID) 所有。否则曾被 root 建成 755 root:root 时,微信(abc 身份)
# 另存到此处会"保存失败",面板上传也写不进。每次启动强制纠正属主,兼容历史遗留的 root 属主。
mkdir -p /config/Desktop
chown "${PUID:-1000}:${PGID:-1000}" /config/Desktop 2>/dev/null || true
+125 -7
View File
@@ -1,5 +1,6 @@
import { hostname } from 'node:os';
import { existsSync, readdirSync } from 'node:fs';
import http from 'node:http';
import Docker from 'dockerode';
import type { Instance } from './store.js';
@@ -20,6 +21,38 @@ const ENABLE_GPU = process.env.WOC_ENABLE_GPU === '1';
const INSTANCE_MEM_GB = Number(process.env.WOC_INSTANCE_MEM_GB) || 0;
const INSTANCE_MEM = INSTANCE_MEM_GB > 0 ? Math.floor(INSTANCE_MEM_GB * 1024 * 1024 * 1024) : 0;
// 设备伪装:把 /etc/os-release 伪装成 deepin(微信官方支持的发行版,且 Deepin 本就基于 Debian
// 与本镜像的 Debian 用户态一致,不会自相矛盾)。默认开启;设 WOC_SPOOF_OS=0 关闭恢复 Debian。
// 配合 00-woc-identity 钩子里的 machine-id 唯一化 + 真实 hostname,整体让容器更像一台普通 Linux 桌面,
// 降低被腾讯按"非真实设备/设备农场"判风险的概率。注意:尽力而为,非保证;详见 doc/设备伪装.md。
const SPOOF_OS = process.env.WOC_SPOOF_OS !== '0';
// 给实例容器派生一个"像个人电脑"的内部 hostname(替代 woc-wx-<hex> 这种容器/服务器特征)。
// 从 inst.id 稳定派生:同一实例每次重建得到相同名字、不同实例不同。仅作伪装,不参与寻址
// (反代用容器名 containerName,不用此 hostname)。
function realisticHostname(id: string): string {
const words = ['deepin', 'lenovo', 'thinkpad', 'matebook', 'xiaoxin', 'legion', 'dell', 'asus', 'desktop', 'home'];
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
const w = words[h % words.length];
const n = ((h >>> 8) % 900) + 100; // 100-999,避免前导 0
return `${w}-pc-${n}`;
}
// 给实例容器派生一个"像真实有线网卡"的 MAC:常见网卡厂商 OUI 前缀 + 由 id 稳定派生的后三段。
// 容器默认 MAC 带"本地管理位"(第一字节第 2 位为 1,如 02/26/ee 开头),是"非真实硬件"的明显特征;
// 这里用全局管理、单播的真实厂商 OUI,更像一台插了网卡的真机。同一实例每次重建得到相同 MAC。
function realisticMac(id: string): string {
// 常见消费级网卡厂商 OUI(全局管理 + 单播,首字节低两位为 0)
const ouis = ['001b21', '8c1645', '00e04c', '0021cc', '3c970e', '001422', 'b827eb'];
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 131 + id.charCodeAt(i)) >>> 0;
const oui = ouis[h % ouis.length];
const hex = (n: number) => (n & 0xff).toString(16).padStart(2, '0');
const tail = hex(h >>> 3) + hex(h >>> 11) + hex(h >>> 19);
return (oui + tail).match(/.{2}/g)!.join(':');
}
const docker = new Docker(); // 默认连 /var/run/docker.sock
// 面板自身所在的 docker 网络名;新实例都 attach 到它,便于按容器名互访。
@@ -78,6 +111,8 @@ function envList(inst: Instance): string[] {
];
// baseimage 仅检查该变量是否「已设置」(值无关),设上即不再给 Xvnc 加 -hw3d。
if (!ENABLE_GPU) env.push('DISABLE_DRI=1');
// 透传 os 伪装开关给容器内的 00-woc-identity 钩子(决定是否把 /etc/os-release 改成 deepin)。
env.push(`WOC_SPOOF_OS=${SPOOF_OS ? '1' : '0'}`);
return env;
}
@@ -121,14 +156,25 @@ export async function runInstance(inst: Instance): Promise<void> {
hostConfig.GroupAdd = ['video']; // 让容器内 abc 用户能访问 /dev/videoN
console.log(`[docker] 实例 ${inst.id} 挂载摄像头设备: ${vids.join(', ')}`);
}
const container = await docker.createContainer({
// 伪装成真实有线网卡 MAC(厂商 OUI),替代容器默认的本地管理位 MAC。
const mac = realisticMac(inst.id);
const createOpts: Docker.ContainerCreateOptions = {
name: inst.containerName,
Image: WECHAT_IMAGE,
Hostname: inst.containerName,
// 内部 hostname 伪装成"个人电脑"名(不再用 woc-wx-<hex>,那是容器/服务器特征)。
// 反代靠容器名 name 寻址,与此 hostname 无关。
Hostname: realisticHostname(inst.id),
Env: envList(inst),
ExposedPorts: { '3000/tcp': {} },
HostConfig: hostConfig,
});
};
// 自定义网络时,MAC 须写到对应 endpoint 上(新版 docker 弃用顶层 MacAddress);默认网络则用顶层。
if (net) {
createOpts.NetworkingConfig = { EndpointsConfig: { [net]: { MacAddress: mac } as any } };
} else {
(createOpts as any).MacAddress = mac;
}
const container = await docker.createContainer(createOpts);
try {
await container.start();
} catch (e) {
@@ -165,6 +211,26 @@ export async function upgradeInstance(inst: Instance): Promise<void> {
await runInstance(inst);
}
// 重置实例的设备 machine-id:删掉持久化的 .woc-machine-id 后重启,由 00-woc-identity 钩子重新生成
// 一个全新的唯一值(相当于"换一台新设备")。用于某账号被腾讯风控标记后手动滚新设备身份。
// 仅对含身份钩子的新镜像有效;旧镜像(升级前)无钩子,先 throw 提示升级,避免做无用功。
export async function regenInstanceMachineId(inst: Instance): Promise<void> {
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<void> {
try {
@@ -265,6 +331,42 @@ export async function instanceMemoryMB(inst: Instance): Promise<number> {
}
}
// 响应性健康探测:实测发现容器跑久了会出现 I/O / 服务 stall —— 进程没死、面板显示"在线",
// 但读不出 VNC 客户端静态文件(nginx 报 upstream timed out),浏览器永远卡在"正在连接桌面"。
// 这里带注入鉴权请求真正会卡的那条路径(/vnc/index.html,经 nginx→kclient 静态serve),
// 超时即判不健康。无鉴权时 nginx 直接 401(很快),故必须注入鉴权让请求真正打到 kclient 静态层。
export async function instanceHttpHealthy(inst: Instance, timeoutMs = 8000): Promise<boolean> {
const auth = 'Basic ' + Buffer.from(`${inst.kasmUser}:${inst.kasmPassword}`).toString('base64');
return await new Promise<boolean>((resolve) => {
let settled = false;
const done = (ok: boolean) => {
if (settled) return;
settled = true;
resolve(ok);
};
const req = http.get(
{
host: inst.containerName,
port: 3000,
path: '/vnc/index.html',
headers: { authorization: auth },
timeout: timeoutMs,
},
(res) => {
// 拿到响应头即说明 nginx+kclient 静态serve 活着(健康时为 200)。读掉 body 释放连接。
const ok = !!res.statusCode && res.statusCode < 500;
res.resume();
done(ok);
},
);
req.on('timeout', () => {
req.destroy();
done(false); // 超时 = stall,判不健康
});
req.on('error', () => done(false));
});
}
export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
try {
const info = await docker.getContainer(inst.containerName).inspect();
@@ -421,10 +523,26 @@ export async function downloadFromInstance(inst: Instance, name: string): Promis
stream.on('error', reject);
});
const tar = Buffer.concat(chunks);
if (tar.length < 512) return Buffer.alloc(0);
const sizeStr = tar.toString('ascii', 124, 135).replace(/\0/g, '').trim();
const size = parseInt(sizeStr, 8) || 0;
return tar.subarray(512, 512 + size);
// 解析 tar,定位真正的普通文件块。Docker(Go archive/tar) 在 mtime 含纳秒精度等情况下会先写一个
// PAX 扩展头块(typeflag 'x'),旧代码误把它当文件头、读到的是扩展记录长度 → 返回错误长度的数据
// "大小不对")。这里跳过 PAX/全局('x'/'g')与 GNU 长名('L'/'K')等扩展头,找到普通文件('0'/NUL)再取内容。
let off = 0;
while (off + 512 <= tar.length) {
const header = tar.subarray(off, off + 512);
let allZero = true;
for (let i = 0; i < 512; i++) if (header[i] !== 0) { allZero = false; break; }
if (allZero) break; // 归档结束(全零块)
const sizeStr = header.toString('ascii', 124, 136).replace(/[^0-7]/g, '');
const size = sizeStr ? parseInt(sizeStr, 8) : 0;
const typeflag = header[156]; // '0'(0x30) 或 NUL(0) = 普通文件
const dataStart = off + 512;
if (typeflag === 0x30 || typeflag === 0) {
return tar.subarray(dataStart, dataStart + size);
}
// 扩展头/目录等:跳过其数据块(向上对齐 512)后继续
off = dataStart + size + ((512 - (size % 512)) % 512);
}
return Buffer.alloc(0);
}
// 拉取实例容器日志(末尾 N 行),供前端"查看/导出日志"排错。
+99
View File
@@ -0,0 +1,99 @@
// Host-header allowlist for DNS-rebinding protection.
//
// Background: the panel binds 0.0.0.0:8080 and ships default credentials
// (admin / wechat). Without Host-header validation, a malicious site the
// operator visits can use DNS rebinding to point a hostname at the panel's
// LAN/loopback IP and drive every authenticated API from the operator's own
// browser — including the docker.sock-backed admin endpoints. The
// `sameSite: 'lax'` cookie does not stop this: after rebinding, the browser
// treats the attacker hostname as same-origin with the panel and includes
// any cookie it issues. The fix is host-allowlisting at the request edge.
//
// Default allowlist (covers documented deploys without operator action):
// - loopback: localhost / 127.0.0.1 / ::1
// - RFC1918 private LAN: 10/8, 172.16-31/12, 192.168/16
// - link-local IPv4: 169.254/16
// Public hostnames (the recommended reverse-proxy deployment) must be added
// via PANEL_ALLOWED_HOSTS=<comma-separated>.
export function parseHost(headerHost: string | undefined): string {
if (!headerHost) return '';
const trimmed = headerHost.trim();
if (!trimmed) return '';
if (trimmed.startsWith('[')) {
const close = trimmed.indexOf(']');
if (close <= 0) return '';
return trimmed.slice(0, close + 1).toLowerCase();
}
const colon = trimmed.lastIndexOf(':');
const host = colon > 0 ? trimmed.slice(0, colon) : trimmed;
return host.toLowerCase();
}
export function isLoopbackHost(host: string): boolean {
return (
host === 'localhost' ||
host === '127.0.0.1' ||
host === '[::1]' ||
host === '::1'
);
}
export function isPrivateIpv4(host: string): boolean {
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (!m) return false;
const o = [m[1], m[2], m[3], m[4]].map((s) => Number(s));
if (o.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return false;
// 10.0.0.0/8
if (o[0] === 10) return true;
// 172.16.0.0/12
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true;
// 192.168.0.0/16
if (o[0] === 192 && o[1] === 168) return true;
// 169.254.0.0/16 (link-local)
if (o[0] === 169 && o[1] === 254) return true;
return false;
}
export function parseAllowedHosts(raw: string | undefined): string[] {
if (!raw) return [];
const out: string[] = [];
for (const part of raw.split(',')) {
const lower = part.trim().toLowerCase();
if (lower) out.push(lower);
}
return [...new Set(out)];
}
export function isAllowedHost(host: string, allowlist: string[]): boolean {
if (!host) return false;
if (isLoopbackHost(host)) return true;
if (isPrivateIpv4(host)) return true;
for (const entry of allowlist) {
if (entry === host) return true;
// 通配子域:*.example.com 匹配任意子域(a.example.com),但不匹配裸 example.com。
if (entry.startsWith('*.')) {
const suffix = entry.slice(1); // ".example.com"
if (host.length > suffix.length && host.endsWith(suffix)) return true;
}
}
return false;
}
// 反代/CDNCloudflare、nginx、Caddy 等)部署时,真实对外域名可能在 X-Forwarded-Host 里,
// 而 Host 被改写成内部地址。综合判定:Host 或 X-Forwarded-Host 任一在白名单即放行。
// 安全性:DNS-rebinding 攻击者直连面板时,浏览器 fetch 无法设置 X-Forwarded-Host(禁止首部),
// 故该首部只会由可信反代设置,不会被攻击者利用。
export function isRequestHostAllowed(
hostHeader: string | undefined,
forwardedHostHeader: string | string[] | undefined,
allowlist: string[],
): boolean {
if (isAllowedHost(parseHost(hostHeader), allowlist)) return true;
let xfh = Array.isArray(forwardedHostHeader) ? forwardedHostHeader[0] : forwardedHostHeader;
if (xfh) {
xfh = xfh.split(',')[0]; // 多级代理链取第一个(最初的客户端 Host)
if (isAllowedHost(parseHost(xfh), allowlist)) return true;
}
return false;
}
+95 -32
View File
@@ -53,8 +53,11 @@ import {
listOrphanContainers,
removeContainerById,
instanceMemoryMB,
instanceHttpHealthy,
regenInstanceMachineId,
} from './docker.js';
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
import { parseHost, parseAllowedHosts, isRequestHostAllowed } from './host-guard.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -62,6 +65,11 @@ const PORT = Number(process.env.PORT || 8080);
const HOST = process.env.HOST || '0.0.0.0';
const STATIC_DIR = process.env.STATIC_DIR || join(__dirname, '../../web/dist');
const COOKIE = 'woc_sess';
// Public hostnames the panel will accept Host headers for, in addition to the
// always-on loopback + RFC1918 LAN allowlist. Required for HTTPS reverse-proxy
// deploys (Caddy/nginx/飞牛 内置反代) where the public hostname differs from
// the LAN IP. See .env.example.
const ALLOWED_HOSTS = parseAllowedHosts(process.env.PANEL_ALLOWED_HOSTS);
function basicAuth(inst: Instance) {
return 'Basic ' + Buffer.from(`${inst.kasmUser}:${inst.kasmPassword}`).toString('base64');
@@ -70,6 +78,23 @@ function basicAuth(inst: Instance) {
initStore();
const app = Fastify({ logger: true, trustProxy: true });
// DNS-rebinding gate: reject requests whose Host header is neither a loopback /
// RFC1918 LAN address nor in PANEL_ALLOWED_HOSTS. Runs before every route so
// /api/*, /desktop/* and static-file responses are all covered.
app.addHook('onRequest', async (req, reply) => {
if (!isRequestHostAllowed(req.headers.host, req.headers['x-forwarded-host'], ALLOWED_HOSTS)) {
// 把被拒的 Host / X-Forwarded-Host 一起回显,反代调试时可一眼看出"后端实际收到的是什么"
// —— 决定是去白名单加这个 host,还是修反代让它透传 Host。不泄露敏感信息。
reply.code(400).send({
error: 'Host header not allowed',
host: parseHost(req.headers.host) || null,
forwardedHost: req.headers['x-forwarded-host'] || null,
hint: '反代部署请把对外域名加入 PANEL_ALLOWED_HOSTS.env 逗号分隔,支持 *.example.com),改完用 docker compose up -d 重建容器(不是 restart)使其生效',
});
}
});
await app.register(cookie);
// 文件上传走原始二进制(前端以 application/octet-stream 直传 File
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
@@ -372,6 +397,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;
@@ -716,6 +756,12 @@ function parseCookies(header?: string): Record<string, string> {
await app.ready();
app.server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => {
// DNS-rebinding gate for WebSocket upgrades (Fastify's onRequest hook does
// not run on raw upgrades). KasmVNC proxying goes through this path.
if (!isRequestHostAllowed(req.headers.host, req.headers['x-forwarded-host'], ALLOWED_HOSTS)) {
socket.destroy();
return;
}
const parsed = req.url ? parseDesktopUrl(req.url) : null;
if (!parsed) {
socket.destroy();
@@ -780,45 +826,62 @@ function hasActiveSession(id: string): boolean {
if (WATCHDOG_ENABLED) {
const recovering = new Set<string>(); // 防重入:自愈期间跳过本实例
const healthFails = new Map<string, number>(); // id → 连续无响应次数
const HEALTH_FAIL_LIMIT = 2; // 连续 N 次无响应才重启,避免误杀刚启动/瞬时抖动
const recover = async (inst: Instance, reason: string, detail: string) => {
recovering.add(inst.id);
app.log.warn(`[watchdog] ${inst.containerName} ${detail}`);
try {
await stopInstance(inst);
await runInstance(inst);
healthFails.delete(inst.id);
app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason}`);
} catch (e: any) {
app.log.error(`[watchdog] ${inst.containerName} 自愈失败(${reason}: ${e?.message || e}`);
} finally {
recovering.delete(inst.id);
}
};
const tick = async () => {
for (const pub of listInstances()) {
const inst = findInstance(pub.id);
if (!inst || recovering.has(inst.id)) continue;
try {
if ((await instanceRuntime(inst)) !== 'running') continue;
const mb = await instanceMemoryMB(inst);
if (mb === 0) continue;
const { soft, hard } = effectiveLimits(inst);
const active = hasActiveSession(inst.id);
let reason: 'hard' | 'soft' | null = null;
if (hard > 0 && mb >= hard) reason = 'hard';
else if (soft > 0 && mb >= soft && !active) reason = 'soft';
if (!reason) {
if (soft > 0 && mb >= soft && active) {
app.log.info(
`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 但用户在使用(holder=${controlHolders.get(inst.id)?.username}),延后`,
);
}
if ((await instanceRuntime(inst)) !== 'running') {
healthFails.delete(inst.id);
continue;
}
recovering.add(inst.id);
if (reason === 'hard') {
app.log.warn(
`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ hard=${hard}MiB,强制重启(active=${active}`,
);
} else {
app.log.warn(
`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 且无活跃会话,柔和重启`,
);
// 1) 内存阈值自愈(既有):hard 强制 / soft 仅在无人会话时
const mb = await instanceMemoryMB(inst);
if (mb > 0) {
const { soft, hard } = effectiveLimits(inst);
const active = hasActiveSession(inst.id);
if (hard > 0 && mb >= hard) {
await recover(inst, 'hard', `mem=${mb}MiB ≥ hard=${hard}MiB,强制重启(active=${active}`);
continue;
}
if (soft > 0 && mb >= soft && !active) {
await recover(inst, 'soft', `mem=${mb}MiB ≥ soft=${soft}MiB 且无活跃会话,柔和重启`);
continue;
}
if (soft > 0 && mb >= soft && active) {
app.log.info(`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 但用户在使用,延后`);
}
}
try {
await stopInstance(inst);
await runInstance(inst);
app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason}`);
} catch (e: any) {
app.log.error(`[watchdog] ${inst.containerName} 自愈失败(${reason}: ${e?.message || e}`);
} finally {
recovering.delete(inst.id);
// 2) 响应性自愈(新):探测 VNC 是否还能提供页面;连续 N 次无响应 → 重启
// 应对"进程没死、显示在线,但 I/O/服务 stall 读不出 VNC 文件、永远卡在正在连接桌面"。
const healthy = await instanceHttpHealthy(inst);
if (healthy) {
healthFails.delete(inst.id);
continue;
}
const fails = (healthFails.get(inst.id) || 0) + 1;
healthFails.set(inst.id, fails);
app.log.warn(`[watchdog] ${inst.containerName} VNC 无响应(连续 ${fails}/${HEALTH_FAIL_LIMIT}`);
if (fails >= HEALTH_FAIL_LIMIT) {
await recover(inst, 'unresponsive', `VNC 连续 ${fails} 次无响应(疑似 I/O/服务 stall),自愈重启`);
}
} catch (e: any) {
app.log.warn(`[watchdog] ${pub.id} 检查异常: ${e?.message || e}`);
@@ -827,7 +890,7 @@ if (WATCHDOG_ENABLED) {
};
setInterval(() => void tick(), WATCHDOG_INTERVAL_SEC * 1000).unref();
console.log(
`[watchdog] 已启用 · soft=${DEFAULT_SOFT_MB} MiB · hard=${DEFAULT_HARD_MB} MiB · 间隔=${WATCHDOG_INTERVAL_SEC}s`,
`[watchdog] 已启用 · soft=${DEFAULT_SOFT_MB} MiB · hard=${DEFAULT_HARD_MB} MiB · 间隔=${WATCHDOG_INTERVAL_SEC}s · 含响应性探测`,
);
}
+2
View File
@@ -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<MemLimits>(`/api/admin/instances/${id}/mem-limits`),
setInstanceMemLimits: (id: string, soft: number | null | undefined, hard: number | null | undefined) =>
+13
View File
@@ -4,6 +4,19 @@ import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles.css';
// PWA 更新即时生效:vite-plugin-pwa 用 autoUpdate + skipWaiting + clientsClaim,新版本会立即接管,
// 但当前页仍显示已加载的旧资源,需再刷一次才生效——这正是"改了却还看旧界面"的根源。这里监听 SW 接管,
// 在"本来已有 SW 在控制"(即一次更新,而非首次安装)时自动重载一次,让更新一刷即生效。
if ('serviceWorker' in navigator) {
const hadController = !!navigator.serviceWorker.controller;
let reloaded = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloaded || !hadController) return;
reloaded = true;
window.location.reload();
});
}
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
+39
View File
@@ -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<import('../api').MemLimits | null>(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;
<div className="muted small" style={{ marginTop: 6 }}>
1500 MiBsoft 2000hard 宿 3000~4000
</div>
<div className="field-label" style={{ marginTop: 16 }}>machine-id</div>
<div className="muted small" style={{ lineHeight: 1.6 }}>
<b></b>退
ID
</div>
<button
type="button"
className="btn"
style={{ marginTop: 8, alignSelf: 'flex-start' }}
onClick={regenMachineId}
disabled={regenBusy || busy}
>
{regenBusy ? '重置中…' : '↻ 重置设备 ID 并重启'}
</button>
{err && <div className="error">{err}</div>}
</>
)}
+137 -92
View File
@@ -4,6 +4,7 @@ import { api } from '../api';
import { useUI } from '../ui';
import { useAuth } from '../auth';
import { useInstances } from '../AppShell';
import { VncAudio } from '../vncAudio';
// KasmVNC noVNC 页面;反代按实例隔离:/desktop/<id>/* → 对应容器,注入凭据。
function desktopUrl(id: string) {
@@ -39,11 +40,16 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const isAdmin = user?.role === 'admin';
const [frameLoaded, setFrameLoaded] = useState(false);
const [loadStuck, setLoadStuck] = useState(false); // iframe 久未加载出来(疑似实例无响应)
const [dragging, setDragging] = useState(false);
const [showFiles, setShowFiles] = useState(false);
const [files, setFiles] = useState<TFile[]>([]);
const [showClip, setShowClip] = useState(false);
const [clipText, setClipText] = useState('');
// 中文输入条:面板里的真实 textarea(原生客户端输入法 100% 可用),回车经 xclip+xdotool 粘进微信。
const [imeBar, setImeBar] = useState(true); // 默认开(直接在 VNC 里打中文不稳,给一个可靠通道)
const [imeText, setImeText] = useState('');
const [imeSending, setImeSending] = useState(false);
const [uploading, setUploading] = useState(false);
const [starting, setStarting] = useState(false);
const [control, setControl] = useState<{ free: boolean; mine: boolean; holder: string | null } | null>(null);
@@ -52,7 +58,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const frameRef = useRef<HTMLIFrameElement>(null);
const dragDepth = useRef(0);
const lastBeat = useRef(0);
const lastImeError = useRef(0);
const audioRef = useRef<VncAudio | null>(null);
const inst = instances.find((i) => i.id === id);
// 进入实例时,共享列表可能尚未同步(管理页新建/安装后),先按"探测中"显示加载态,
@@ -65,13 +71,24 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
// 切换实例时重置内嵌态
useEffect(() => {
setFrameLoaded(false);
setLoadStuck(false);
setShowFiles(false);
setFiles([]);
setShowClip(false);
setClipText('');
setImeText('');
setProbing(true);
}, [id]);
// 桌面久未加载出来 → 判为"无响应",把无限转圈换成可操作的重试/重启,不让用户干等。
// (实测容器跑久了会 I/O/服务 stall,进程没死、显示在线,但读不出 VNC 文件而永远连接中。)
useEffect(() => {
setLoadStuck(false);
if (!showVnc || frameLoaded) return;
const t = window.setTimeout(() => setLoadStuck(true), 12000);
return () => window.clearTimeout(t);
}, [showVnc, frameLoaded, id, vncNonce]);
// 探测态收敛:找到实例即结束;否则给共享列表一点刷新时间(AppShell 已在导航时拉取),超时仍无则判定不存在。
useEffect(() => {
if (inst) {
@@ -186,6 +203,42 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
};
}, [showVnc, id, frameLoaded]);
// 每次进入/重连桌面前,强制把 KasmVNC 的 enable_ime 设为【关】。
// 原因:开启 IME 模式后,noVNC 用隐藏 textarea + 合成事件还原中文,需要前端拦截/差分,环环相扣极脆,
// 实测会"中文混数字时丢最后两个汉字+首个数字"等损坏。中文输入改由底部「中文输入条」可靠承担
// (面板真实 textarea 原生输入法 → xclip+xdotool 粘贴),故 VNC 直接打字回归纯 keysym:英文/数字/
// 标点都正常、不再损坏;中文直接打不进(请用输入条)。iframe 同源共享 localStorage,加载前设好即生效。
useEffect(() => {
try {
window.localStorage.setItem('enable_ime', 'false');
} catch {
/* 隐私模式等禁用 localStorage:忽略 */
}
}, [id, vncNonce]);
// 音频/麦克风桥接:实例就绪即自动连接 kclient 的音频流(扬声器恒开,无需手动找工具条);
// 仅当本实例处于焦点(标签页可见且窗口聚焦)时出声/收音,失焦立即断开,避免多实例多端串音。
useEffect(() => {
if (!showVnc || !id) return;
const audio = new VncAudio(id);
audioRef.current = audio;
audio.connect();
const isFocused = () => !document.hidden && document.hasFocus();
const sync = () => audio.setActive(isFocused());
sync(); // 初始:若当前已聚焦则立即开声
document.addEventListener('visibilitychange', sync);
window.addEventListener('focus', sync);
window.addEventListener('blur', sync);
return () => {
document.removeEventListener('visibilitychange', sync);
window.removeEventListener('focus', sync);
window.removeEventListener('blur', sync);
audio.destroy();
audioRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showVnc, id]);
if (!id) {
nav('/', { replace: true });
return null;
@@ -270,92 +323,6 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
}
};
// 中文 IME 输入修复:绕过 VNC XKB keysym 容量限制(~21 个 CJK 字符后 keymap 满,输入全废)。
// 根因:KasmVNC 的 Perl 补丁在 compositionend 发 CJK keysym,但紧随其后的 _handleInput
// diff 逻辑会发 Backspace 清拼音,把刚发的字也删了。必须在捕获阶段拦截,阻止 Perl 补丁执行,
// 手动重置内部状态(防止 _handleInput 发 Backspace),然后通过 API 用 xdotool 粘贴文字。
const patchVncIme = () => {
try {
const doc = frameRef.current?.contentDocument;
if (!doc || doc.getElementById('woc-ime-patch')) return;
const ta = doc.getElementById('noVNC_keyboardinput') as HTMLTextAreaElement | null;
if (!ta) return;
const win = frameRef.current?.contentWindow as any;
let imeComposing = false;
let swallowInputUntil = 0;
const keyboard = () => {
const cv = doc.querySelector('canvas') as any;
return win?.UI?.rfb?.keyboard || cv?._rfb?.keyboard || null;
};
const installKeyboardGuard = () => {
const kb = keyboard() as any;
if (!kb || kb._wocImeGuard || typeof kb._sendKeyEvent !== 'function') return;
const original = kb._sendKeyEvent.bind(kb);
kb._wocImeGuard = true;
if (typeof kb._wocImeSuppressUnicode !== 'boolean') kb._wocImeSuppressUnicode = false;
kb._sendKeyEvent = (keysym: number, ...args: any[]) => {
if (kb._wocImeSuppressUnicode && typeof keysym === 'number' && keysym >= 0x01000000) return;
return original(keysym, ...args);
};
};
const syncKeyboardInput = (value: string) => {
try {
installKeyboardGuard();
const kb = keyboard();
if (kb) {
kb._imeInProgress = false;
kb._imeHold = false;
kb._lastKeyboardInput = value;
if (kb._rfbKeyQueue) kb._rfbKeyQueue.length = 0;
}
} catch { /* ignore */ }
};
const swallowNoVncInput = (e: Event) => {
if (!imeComposing && Date.now() > swallowInputUntil) return;
e.stopImmediatePropagation();
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
};
ta.addEventListener('compositionstart', (e) => {
imeComposing = true;
const kb = keyboard() as any;
if (kb) kb._wocImeSuppressUnicode = true;
e.stopImmediatePropagation();
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
}, true);
ta.addEventListener('beforeinput', swallowNoVncInput, true);
ta.addEventListener('input', swallowNoVncInput, true);
ta.addEventListener('compositionend', (e) => {
const text = (e as CompositionEvent).data;
if (!text || !id) return;
imeComposing = false;
swallowInputUntil = Date.now() + 300;
e.stopImmediatePropagation(); // 阻止 KasmVNC 原生 IME 路径再发一遍 keysym
const kb = keyboard() as any;
if (kb) kb._wocImeSuppressUnicode = true;
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
window.setTimeout(() => {
ta.value = '';
syncKeyboardInput('');
const kb = keyboard() as any;
if (kb) kb._wocImeSuppressUnicode = false;
}, 0);
// 通过面板 API → xdotool 在容器内粘贴,完全绕过 VNC keysym
api.typeInInstance(id, text).catch((err) => {
const now = Date.now();
if (now - lastImeError.current > 3000) {
lastImeError.current = now;
toast(err?.message || '中文输入失败,请确认实例镜像包含 xclip/xdotool', 'error');
}
});
}, true); // capture:先于 Perl 补丁的 bubble handler
const mark = doc.createElement('meta');
mark.id = 'woc-ime-patch';
(doc.head || doc.documentElement).appendChild(mark);
} catch {
/* ignore */
}
};
// 跨设备剪贴板(文本):通过同源 iframe 直接喂给 KasmVNC 自带的剪贴板 textarea 并触发其发送逻辑
// (内部走 RFB.clipboardPasteFrom → clientCutText)。不依赖浏览器异步剪贴板 API,故 http/局域网 IP 下也可用,
// 规避了"非安全上下文禁用 navigator.clipboard 导致粘贴失败"的问题。文本会进入容器系统剪贴板,
@@ -386,6 +353,22 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
}
};
// 中文输入条发送:把本框文本经 xclip+xdotool 直接粘进微信当前聚焦的输入框(绕开 VNC IME)。
// 在面板的真实 textarea 里用原生输入法打字,100% 可靠,不依赖 VNC 的 enable_ime / 合成事件。
const sendImeText = async () => {
const t = imeText;
if (!t.trim() || !id) return;
setImeSending(true);
try {
await api.typeInInstance(id, t);
setImeText('');
} catch (e: any) {
toast(e?.message || '发送失败:请确认实例已「升级实例」(镜像含 xclip/xdotool', 'error');
} finally {
setImeSending(false);
}
};
// 读取容器(微信侧)当前剪贴板内容到本框,便于把容器内复制的文字带回本地
const pullClipboardFromRemote = () => {
try {
@@ -465,6 +448,13 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
>
</button>
<button
className={'ws-action' + (imeBar ? ' on' : '')}
title="底部中文输入条:用本机输入法打中文,回车送进微信(最可靠)"
onClick={() => setImeBar((v) => !v)}
>
</button>
<button
className="ws-action"
title="把文本发送到容器剪贴板(局域网 http 下也可用)"
@@ -548,7 +538,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
</div>
</div>
) : (
<div className="iv-stage">
<div className="iv-stage iv-stage--vnc">
<div className="iv-canvas">
<iframe
key={`${id}:${vncNonce}`}
ref={frameRef}
@@ -559,25 +550,53 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
onLoad={() => {
setFrameLoaded(true);
setTimeout(() => {
focusFrame(); // 加载完把键盘焦点交给 VNC(宿主机输入法)
focusFrame(); // 加载完把键盘焦点交给 VNC
injectVncStyle(); // 让原生控制条在深色背景下可见
patchVncIme(); // 修复中文 IME 吞字(绕过 VNC XKB keysym 限制)
// 注意:不再调用 patchVncIme —— enable_ime 已关,直接打字走纯 keysym(英文/数字正常);
// 中文由底部「中文输入条」承担。那套合成拦截既脆弱又会损坏混合输入,已弃用。
}, 500);
}}
/>
{!frameLoaded && (
{!frameLoaded && !loadStuck && (
<div className="iv-loading">
<div className="spinner" />
<div className="iv-loading-text"></div>
<div className="iv-loading-sub"></div>
<div className="iv-loading-sub">/</div>
<div className="iv-loading-sub"></div>
{!window.isSecureContext && (
<div className="iv-loading-warn"> HTTPS 访</div>
)}
</div>
)}
{!frameLoaded && loadStuck && (
<div className="iv-loading">
<div className="iv-loading-text"></div>
<div className="iv-loading-sub"></div>
<div className="iv-stuck-actions">
<button
className="btn btn-primary"
onClick={() => {
setLoadStuck(false);
setFrameLoaded(false);
setVncNonce((n) => n + 1); // 强制 iframe 重挂、重新请求
}}
>
</button>
{isAdmin && (
<button className="btn" onClick={restartInstance}>
</button>
)}
</div>
<div className="iv-loading-sub" style={{ marginTop: 8 }}>
</div>
</div>
)}
{dragging && (
<div className="iv-drop" onDrop={onDrop} onDragOver={(e) => e.preventDefault()}>
<div className="drop-card">
@@ -669,6 +688,32 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
</div>
</div>
)}
</div>
{imeBar && (
<div className="iv-imebar">
<textarea
className="iv-imebar-input"
value={imeText}
onChange={(e) => setImeText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendImeText();
}
}}
placeholder="中文输入这里 → 回车送进微信(先点好微信的输入框)。Shift+回车换行。"
rows={1}
/>
<button
className="btn btn-primary iv-imebar-send"
disabled={imeSending || !imeText.trim()}
onClick={sendImeText}
>
{imeSending ? '发送中' : '发送'}
</button>
</div>
)}
</div>
)}
</div>
+58
View File
@@ -748,6 +748,11 @@ button {
font-size: 13px;
line-height: 1.4;
}
.iv-stuck-actions {
display: flex;
gap: 10px;
margin-top: 14px;
}
.files-hint {
font-size: 12px;
color: var(--muted);
@@ -1427,6 +1432,10 @@ button {
.ws-action:active {
transform: scale(0.96);
}
.ws-action.on {
background: var(--wx-green, #07c160);
color: #fff;
}
.ws-page .content {
flex: 1;
min-height: 0;
@@ -1510,6 +1519,18 @@ button {
justify-content: center;
background: var(--base);
}
/* VNC 分支:纵向 flex —— 画面区(.iv-canvas, flex:1) 在上,中文输入条在下,互不重叠。
输入条占用布局空间 → iframe 区域变小 → resize=remote 让远端桌面同步缩小,微信画面完整可见、不被遮。 */
.iv-stage--vnc {
display: flex;
flex-direction: column;
}
.iv-canvas {
position: relative;
flex: 1;
min-height: 0;
background: #000;
}
.iv-frame {
position: absolute;
inset: 0;
@@ -1518,6 +1539,43 @@ button {
border: none;
display: block;
}
.iv-imebar {
flex: 0 0 auto;
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
/* 与顶部 nav 栏同款牛奶布艺浅色主题 + 顶部细折痕分隔 */
background: var(--base);
box-shadow: inset 0 1px 0 rgba(var(--shadow) / 0.07);
box-sizing: border-box;
}
.iv-imebar-input {
flex: 1;
height: 38px;
max-height: 38px;
resize: none;
padding: 9px 12px;
border-radius: 10px;
border: none;
outline: none;
font-size: 14px;
font-family: inherit;
line-height: 1.3;
/* 牛奶布艺凹槽输入框:与「剪贴板」文本框一致 */
background: var(--trough);
color: var(--text);
box-shadow: inset 0 1px 3px rgba(var(--shadow) / 0.16);
box-sizing: border-box;
}
.iv-imebar-input:focus {
box-shadow: inset 0 1px 3px rgba(var(--shadow) / 0.16), 0 0 0 2px rgba(7, 193, 96, 0.35);
}
.iv-imebar-send {
height: 38px;
flex: 0 0 auto;
padding: 0 18px;
}
.iv-loading,
.iv-drop {
position: absolute;
+269
View File
@@ -0,0 +1,269 @@
// VNC 音频/麦克风桥接(扬声器 + 麦克风)。
//
// 背景:linuxserver KasmVNC 的音频不在我们内嵌的原生 noVNC 客户端里,而在它外层的 kclient
// (容器内 nginx :3000 / → kclient :6900)通过 socket.io(路径 audio/socket.io)提供:
// - 扬声器:服务端把 PulseAudio sink 的 PCM 通过 'audio' 事件推下来,前端用 Web Audio 播放;
// - 麦克风:前端采集 Int16 通过 'micdata' 事件上传,服务端灌进 PulseAudio。
// 我们没有内嵌 kclient(会破坏对原生客户端的 IME / 剪贴板 / 控制条定制),故在面板父页面直接
// 复刻它的音频客户端,连到经面板反代的 /desktop/<id>/audio/socket.io。这样还能精确控制:
// - 「强制开启」:实例就绪即自动连接、首个用户手势后开始播放(浏览器自动播放策略所限);
// - 「焦点不在该实例时断开」:标签页隐藏 / 失焦 / 离开页面时关闭,避免多实例多端互相串音。
//
// 麦克风需要「安全上下文」(HTTPS 或 localhost) 才有 getUserMedia;局域网 http 下浏览器禁用,
// 此时自动跳过麦克风、只保留扬声器。
// kclient 服务端用的 socket.io 版本未知,为避免协议不匹配,动态加载它自带的 socket.io.js
// (经反代取 /desktop/<id>/audio/socket.io/socket.io.js),用全局 io,而非打包我们自己的版本。
function loadIo(id: string): Promise<any> {
const w = window as any;
if (w.io) return Promise.resolve(w.io);
const existing = document.getElementById('woc-socketio') as HTMLScriptElement | null;
if (existing && (existing as any)._wocPromise) return (existing as any)._wocPromise;
const p = new Promise<any>((resolve, reject) => {
const s = document.createElement('script');
s.id = 'woc-socketio';
s.src = `/desktop/${encodeURIComponent(id)}/audio/socket.io/socket.io.js`;
s.onload = () => ((window as any).io ? resolve((window as any).io) : reject(new Error('io 未就绪')));
s.onerror = () => reject(new Error('加载 socket.io 失败'));
document.head.appendChild(s);
(s as any)._wocPromise = p;
});
return p;
}
// PCM 播放器:忠实复刻 kclient 的解码/调度(Int16 立体声 @ 44100 → Web Audio),
// 这套参数与服务端音频格式匹配,改动易出杂音,故照搬。
class PcmPlayer {
audioCtx: AudioContext | null = null;
private gain: GainNode | null = null;
private startTime = 0;
private buffer: Float32Array = new Float32Array(0);
private playing = false;
private lock = false;
private resetTimer: number | undefined;
init() {
const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext;
this.audioCtx = new Ctx({ sampleRate: 44100 });
this.audioCtx!.resume().catch(() => {});
this.gain = this.audioCtx!.createGain();
this.gain.gain.value = 1;
this.gain.connect(this.audioCtx!.destination);
this.startTime = this.audioCtx!.currentTime;
// 与 kclient 一致:100ms 内无新数据则清空缓冲,避免拖尾/堆积
this.resetTimer = window.setInterval(() => {
if (this.playing) {
if (!this.lock) {
this.buffer = new Float32Array(0);
this.playing = false;
}
this.lock = false;
}
}, 100);
}
feed(data: ArrayBuffer) {
if (!this.audioCtx) return;
this.lock = true;
const i16 = new Int16Array(data);
const f32 = Float32Array.from(i16, (x) => x / 32767);
const merged = new Float32Array(this.buffer.length + f32.length);
merged.set(this.buffer);
merged.set(f32, this.buffer.length);
this.buffer = merged;
const frames = this.buffer.length / 2; // 立体声
const duration = frames / 44100 / 2; // 与 kclient 的 buffAudio.duration/2 等价
if (duration > 0.05 || this.playing) {
this.playing = true;
const buffAudio = this.audioCtx.createBuffer(2, this.buffer.length, 44100);
const left = buffAudio.getChannelData(0);
const right = buffAudio.getChannelData(1);
let bc = 0;
let off = 1;
for (let i = 0; i < frames; i++) {
left[i] = this.buffer[bc];
bc += 2;
right[i] = this.buffer[off];
off += 2;
}
this.buffer = new Float32Array(0);
if (this.startTime < this.audioCtx.currentTime) this.startTime = this.audioCtx.currentTime;
const src = this.audioCtx.createBufferSource();
src.buffer = buffAudio;
src.connect(this.gain!);
src.start(this.startTime);
this.startTime += buffAudio.duration / 2;
}
}
destroy() {
if (this.resetTimer) window.clearInterval(this.resetTimer);
this.resetTimer = undefined;
this.buffer = new Float32Array(0);
this.playing = false;
try {
this.audioCtx?.close();
} catch {
/* ignore */
}
this.audioCtx = null;
this.gain = null;
}
}
export class VncAudio {
private id: string;
private socket: any = null;
private player: PcmPlayer | null = null;
private active = false; // 当前实例是否处于"焦点中"(应出声)
private opened = false; // 是否已对服务端 emit('open')
private micStream: MediaStream | null = null;
private micCtx: AudioContext | null = null;
private micNode: ScriptProcessorNode | null = null;
private micSource: MediaStreamAudioSourceNode | null = null;
private gestureBound = false;
private destroyed = false;
constructor(id: string) {
this.id = id;
}
// 建立 socket 连接(不自动出声,由 setActive 控制)。
async connect() {
if (this.socket || this.destroyed) return;
const io = await loadIo(this.id);
if (this.destroyed) return;
this.socket = io(window.location.origin, {
path: `/desktop/${this.id}/audio/socket.io`,
transports: ['websocket', 'polling'],
withCredentials: true,
reconnection: true,
});
this.socket.on('audio', (data: ArrayBuffer) => {
if (this.active && this.player) this.player.feed(data);
});
this.socket.on('connect', () => {
if (this.active) this.open();
});
}
// 焦点变化时调用:true=本实例获得焦点(出声+收音),false=失焦(断开设备)。
setActive(on: boolean) {
if (this.destroyed) return;
this.active = on;
if (on) {
this.open();
this.startMic();
} else {
this.close();
this.stopMic();
}
}
private open() {
if (!this.socket || !this.socket.connected) return;
if (!this.opened) {
this.socket.emit('open', '');
this.opened = true;
}
if (!this.player) {
this.player = new PcmPlayer();
this.player.init();
}
this.ensureResumeOnGesture();
}
private close() {
if (this.socket && this.opened) {
try {
this.socket.emit('close', '');
} catch {
/* ignore */
}
}
this.opened = false;
this.player?.destroy();
this.player = null;
}
// 浏览器自动播放策略:AudioContext 常被挂起,需用户手势恢复。绑定一次性手势监听,
// 用户点进画面/按键时自动 resume,实现"无需手动点工具条即可出声"。
private ensureResumeOnGesture() {
const ctx = this.player?.audioCtx;
if (!ctx) return;
if (ctx.state !== 'suspended' || this.gestureBound) return;
this.gestureBound = true;
const resume = () => {
this.player?.audioCtx?.resume().catch(() => {});
window.removeEventListener('pointerdown', resume, true);
window.removeEventListener('keydown', resume, true);
this.gestureBound = false;
};
window.addEventListener('pointerdown', resume, true);
window.addEventListener('keydown', resume, true);
}
private async startMic() {
// 麦克风需安全上下文(HTTPS / localhost);http 局域网下静默跳过,只保留扬声器。
if (this.micCtx || !this.socket) return;
const md = navigator.mediaDevices;
if (!window.isSecureContext || !md || !md.getUserMedia) return;
try {
const stream = await md.getUserMedia({ audio: true });
if (this.destroyed || !this.active) {
stream.getTracks().forEach((t) => t.stop());
return;
}
this.micStream = stream;
const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext;
this.micCtx = new Ctx();
this.micSource = this.micCtx!.createMediaStreamSource(stream);
this.micNode = this.micCtx!.createScriptProcessor(512, 1, 1);
this.micSource.connect(this.micNode);
this.micNode.connect(this.micCtx!.destination);
this.micNode.onaudioprocess = (e) => {
if (!this.active || !this.socket) return;
const input = e.inputBuffer.getChannelData(0);
// 简单能量门限:近乎静音不上传,省带宽(替代 kclient 的 JSON.size 启发式)
let peak = 0;
for (let i = 0; i < input.length; i++) {
const a = input[i] < 0 ? -input[i] : input[i];
if (a > peak) peak = a;
}
if (peak < 0.01) return;
const i16 = Int16Array.from(input, (x) => Math.max(-32768, Math.min(32767, x * 32767)));
this.socket.emit('micdata', i16.buffer);
};
} catch {
this.stopMic();
}
}
private stopMic() {
try {
if (this.micNode) this.micNode.onaudioprocess = null as any;
this.micNode?.disconnect();
this.micSource?.disconnect();
this.micStream?.getTracks().forEach((t) => t.stop());
this.micCtx?.close();
} catch {
/* ignore */
}
this.micNode = null;
this.micSource = null;
this.micStream = null;
this.micCtx = null;
}
destroy() {
this.destroyed = true;
this.close();
this.stopMic();
try {
this.socket?.disconnect();
} catch {
/* ignore */
}
this.socket = null;
}
}