Compare commits
32 Commits
@@ -4,10 +4,20 @@
|
||||
WOC_USER=admin
|
||||
WOC_PASSWORD=wechat
|
||||
|
||||
# 镜像版本:默认 latest;上线后建议钉到具体版本(如 v1.0.0)以便可控升级。
|
||||
# 镜像版本:默认 latest;上线后建议钉到具体版本(如 1.0.0,注意不带 v)以便可控升级。
|
||||
# 同时作用于面板镜像和新建微信实例所用镜像。
|
||||
WOC_VERSION=latest
|
||||
|
||||
# 镜像源前缀(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。
|
||||
WOC_IMAGE_PREFIX=ghcr.io/gloridust
|
||||
|
||||
# 宿主用户 uid/gid(飞牛上用 `id` 命令查看;单用户 NAS 一般是 1000)。
|
||||
# 透传给每个微信实例容器,决定面板数据与微信数据卷的属主。
|
||||
WOC_PUID=1000
|
||||
@@ -19,3 +29,82 @@ WOC_TZ=Asia/Shanghai
|
||||
# 面板对外端口(宿主侧,默认用冷门端口避免冲突;容器内固定 8080)。
|
||||
# 面板是唯一对外入口;微信实例不直接对宿主暴露,由面板反向代理。
|
||||
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=
|
||||
|
||||
# ── 音频 / 麦克风 / 摄像头 ───────────────────────────────────
|
||||
# 音频(听):**自动开启**,进入桌面、点一下画面(浏览器自动播放策略要求一次手势)即可出声,
|
||||
# 无需手动找工具条。仅当前聚焦的实例出声,切走/切到别的实例会自动断开,避免多端串音。
|
||||
# 麦克风(说) / 摄像头(视频):浏览器要求"安全上下文",即必须通过 HTTPS 访问面板
|
||||
# (或 localhost)。生产环境务必给面板套 HTTPS(反代/证书),否则浏览器会禁用麦克风与摄像头。
|
||||
#
|
||||
# 摄像头还需要宿主提供一个虚拟视频设备(v4l2loopback):
|
||||
# 1) 宿主安装并加载内核模块:
|
||||
# Ubuntu/Debian: sudo apt install v4l2loopback-dkms && sudo modprobe v4l2loopback
|
||||
# (飞牛/其它发行版若自带 v4l2loopback 同理 modprobe;没有则摄像头不可用,其余功能正常)
|
||||
# 2) 确认出现了 /dev/videoN 设备。
|
||||
# 3) 二选一让实例容器拿到该设备:
|
||||
# a) 自动:保留 compose 里的 `/dev:/host-dev:ro` 挂载,面板会自动探测并映射;
|
||||
# b) 显式:在下面列出设备(逗号分隔),并可删掉那条 /host-dev 挂载。
|
||||
# 留空 = 不映射摄像头(音频/麦克风不受影响)。
|
||||
WOC_VIDEO_DEVICES=
|
||||
|
||||
# ── 实例资源 / 稳定性 ───────────────────────────────────────
|
||||
# GPU 硬件编码:baseimage 检测到 /dev/dri/renderD* 时会给 Xvnc 加 -hw3d(GPU 加速编码)。
|
||||
# 在 WSL2 / 虚拟 GPU(如 Docker Desktop on Windows)下,该路径会导致 Xvnc 内存持续膨胀
|
||||
# (实测 21 小时涨到 ~9GB)。本项目已强制软件渲染(LIBGL_ALWAYS_SOFTWARE=1),hw3d 对微信
|
||||
# 这类静态界面收益甚微,故默认关闭。仅在你有真实可用的 GPU 且确认无内存问题时再设为 1 启用。
|
||||
WOC_ENABLE_GPU=
|
||||
|
||||
# 每个微信实例容器的内存上限(GiB)。默认空 = 不限制(与旧版一致)。
|
||||
# 作为兜底:万一某进程异常增长,命中上限时容器内 OOM 杀进程、由 s6 自动重启 VNC,
|
||||
# 避免拖垮整台宿主。常规使用单实例约需 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
|
||||
# 内存(= docker stats 显示值),按两档阈值(这里设全局默认;每个实例可在「管理 → 实例卡片
|
||||
# → 安全」按钮里单独覆盖)触发主动 stop + run(聊天数据在卷里、登录态本来也会定期失效,
|
||||
# 可控时机自愈优于撑到 OOM):
|
||||
#
|
||||
# soft 超过且【当前没人在远程会话】才重启 → 柔和自愈,不打扰使用者
|
||||
# hard 超过即重启(无视会话),防止 OOM 拖垮宿主
|
||||
#
|
||||
# WOC_INSTANCE_MEM_SOFT_MB soft 阈值(MiB),默认 1500
|
||||
# WOC_INSTANCE_MEM_HARD_MB hard 阈值(MiB),默认 2500(兼容旧名 WOC_INSTANCE_MEM_LIMIT_MB)
|
||||
# WOC_WATCHDOG_INTERVAL_SEC 巡检间隔秒,默认 300(5 分钟);最小 60;0 = 关闭整个 watchdog
|
||||
#
|
||||
# 调参建议:日常活跃单实例约 1500 MiB;soft 应略高于此(如 2000);hard 远低于宿主可用内存。
|
||||
WOC_INSTANCE_MEM_SOFT_MB=1500
|
||||
WOC_INSTANCE_MEM_HARD_MB=2500
|
||||
WOC_WATCHDOG_INTERVAL_SEC=300
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Telegram 命令机器人(轮询版)——跑在 GitHub Actions cron 上,无需任何服务器。
|
||||
//
|
||||
// 工作方式:每次运行调用 getUpdates 拉取「自上次确认以来」的待处理更新,逐条处理命令并回复,
|
||||
// 最后用 offset=最后一条+1 再调一次 getUpdates 向 Telegram「确认」(这些更新随即被服务端清掉,
|
||||
// 下次不再返回)。Telegram 自己保存 offset 状态(未确认的更新保留 24h),因此本脚本无需任何持久化存储。
|
||||
//
|
||||
// 支持私聊与群组(群组里以 /命令 形式发送即可,命令不受 bot 隐私模式影响)。
|
||||
// 命令:/help /releases /release <tag> /issues /issue <编号>
|
||||
//
|
||||
// 局限:受 GitHub cron 最小 5 分钟间隔限制,命令有延迟(非实时)。要实时需改用 webhook(需 serverless 端点)。
|
||||
|
||||
const TG = process.env.TG_TOKEN;
|
||||
const GH = process.env.GH_TOKEN || '';
|
||||
const REPO = process.env.REPO; // owner/repo
|
||||
if (!TG) {
|
||||
console.log('TELEGRAM_BOT_TOKEN 未配置,跳过');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const tgUrl = (method) => `https://api.telegram.org/bot${TG}/${method}`;
|
||||
const ghHeaders = {
|
||||
accept: 'application/vnd.github+json',
|
||||
'user-agent': 'woc-telegram-bot',
|
||||
...(GH ? { authorization: `Bearer ${GH}` } : {}),
|
||||
};
|
||||
|
||||
async function tg(method, params) {
|
||||
const r = await fetch(tgUrl(method), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
return r.json();
|
||||
}
|
||||
async function gh(path) {
|
||||
const r = await fetch(`https://api.github.com/repos/${REPO}${path}`, { headers: ghHeaders });
|
||||
if (!r.ok) throw new Error(`GitHub ${path} → ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
const trunc = (s, n) => (s && s.length > n ? s.slice(0, n) + '…' : s || '');
|
||||
const send = (chatId, text) => tg('sendMessage', { chat_id: chatId, text, disable_web_page_preview: true });
|
||||
|
||||
const HELP = [
|
||||
'🤖 云微 WechatOnCloud 机器人命令:',
|
||||
'',
|
||||
'/releases — 最近发布列表',
|
||||
'/release <tag> — 某版本详情(省略 = 最新)',
|
||||
'/issues — 打开中的 issue 列表',
|
||||
'/issue <编号> — issue 详情',
|
||||
'/help — 显示本帮助',
|
||||
'',
|
||||
'(轮询版,命令可能有几分钟延迟)',
|
||||
].join('\n');
|
||||
|
||||
async function handle(cmd, arg, chatId) {
|
||||
switch (cmd) {
|
||||
case '/start':
|
||||
case '/help':
|
||||
return send(chatId, HELP);
|
||||
|
||||
case '/releases': {
|
||||
const rels = await gh('/releases?per_page=8');
|
||||
if (!rels.length) return send(chatId, '暂无 release');
|
||||
const lines = rels.map(
|
||||
(r) =>
|
||||
`• ${r.tag_name}${r.name && r.name !== r.tag_name ? ' — ' + r.name : ''} (${(r.published_at || '').slice(0, 10)})`,
|
||||
);
|
||||
return send(chatId, '📦 最近发布:\n' + lines.join('\n') + '\n\n用 /release <tag> 看某版详情');
|
||||
}
|
||||
|
||||
case '/release': {
|
||||
const rel = arg ? await gh(`/releases/tags/${encodeURIComponent(arg)}`) : await gh('/releases/latest');
|
||||
const title = `${rel.tag_name}${rel.name && rel.name !== rel.tag_name ? ' · ' + rel.name : ''}`;
|
||||
return send(
|
||||
chatId,
|
||||
`📦 ${title}\n发布于 ${(rel.published_at || '').slice(0, 10)}\n\n${trunc(rel.body, 2500)}\n\n🔗 ${rel.html_url}`,
|
||||
);
|
||||
}
|
||||
|
||||
case '/issues': {
|
||||
const items = (await gh('/issues?state=open&per_page=10&sort=updated')).filter((i) => !i.pull_request);
|
||||
if (!items.length) return send(chatId, '🎉 当前没有打开的 issue');
|
||||
const lines = items.map((i) => `• #${i.number} ${trunc(i.title, 60)}`);
|
||||
return send(chatId, `🐛 打开中的 issue(${items.length}):\n` + lines.join('\n') + '\n\n用 /issue <编号> 看详情');
|
||||
}
|
||||
|
||||
case '/issue': {
|
||||
if (!arg) return send(chatId, '用法:/issue <编号>');
|
||||
const i = await gh(`/issues/${encodeURIComponent(arg)}`);
|
||||
if (i.pull_request) return send(chatId, `#${arg} 是个 PR,不是 issue`);
|
||||
return send(
|
||||
chatId,
|
||||
`🐛 #${i.number} ${i.title}\n状态:${i.state} · by ${i.user?.login}\n\n${trunc(i.body, 2500)}\n\n🔗 ${i.html_url}`,
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return; // 未知命令静默忽略,避免群里刷屏
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// 短轮询拉取待处理更新(只要 message)
|
||||
const res = await (
|
||||
await fetch(tgUrl('getUpdates') + '?timeout=0&allowed_updates=' + encodeURIComponent('["message"]'))
|
||||
).json();
|
||||
if (!res.ok) {
|
||||
console.error('getUpdates 失败:', JSON.stringify(res));
|
||||
process.exit(res.error_code === 409 ? 0 : 1); // 409 = 设了 webhook,与轮询冲突,直接退出
|
||||
}
|
||||
const updates = res.result || [];
|
||||
let maxId = 0;
|
||||
for (const u of updates) {
|
||||
maxId = Math.max(maxId, u.update_id);
|
||||
const m = u.message;
|
||||
if (!m || !m.text) continue;
|
||||
const text = m.text.trim();
|
||||
if (!text.startsWith('/')) continue;
|
||||
const parts = text.split(/\s+/);
|
||||
const cmd = parts[0].split('@')[0].toLowerCase(); // 去掉 @botname 后缀
|
||||
const arg = parts.slice(1).join(' ').trim();
|
||||
try {
|
||||
await handle(cmd, arg, m.chat.id);
|
||||
} catch (e) {
|
||||
await send(m.chat.id, '⚠️ 出错了:' + (e?.message || e));
|
||||
}
|
||||
}
|
||||
// 向 Telegram 确认已处理(清掉这些更新,下次不再返回)
|
||||
if (maxId) {
|
||||
await fetch(tgUrl('getUpdates') + `?offset=${maxId + 1}&timeout=0`);
|
||||
}
|
||||
console.log(`processed ${updates.length} update(s)`);
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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 Token(hub.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:每行一个目标 registry,build-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}}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: telegram-bot
|
||||
|
||||
# Telegram 命令机器人(轮询版)——无需服务器,仅靠 GitHub Actions cron。
|
||||
# 私聊 / 群组都可用命令查询:/help /releases /release <tag> /issues /issue <编号>。
|
||||
#
|
||||
# 启用(一次性):
|
||||
# 1) 已配置 telegram-notify 用到的 TELEGRAM_BOT_TOKEN(Secret)即可复用;
|
||||
# 2) 仓库 Settings → Secrets and variables → Actions → Variables 新建
|
||||
# TELEGRAM_BOT_ENABLED = true (未设为 true 则本工作流不运行,避免空跑)
|
||||
# 3) 把机器人拉进群组 / 在私聊里 /start。
|
||||
#
|
||||
# 局限:cron 最小 5 分钟且可能再延后 → 命令非实时;GitHub 会在仓库 60 天无活动时暂停定时任务。
|
||||
# 想立即处理一次:Actions → telegram-bot → Run workflow(workflow_dispatch)。
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/5 * * * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
# 避免轮询任务并发重叠重复处理;新触发取消正在跑的(残留未确认更新下次重处理,无副作用)
|
||||
concurrency:
|
||||
group: telegram-bot
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
poll:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ vars.TELEGRAM_BOT_ENABLED == 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Poll Telegram & handle commands
|
||||
env:
|
||||
TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: node .github/scripts/telegram-bot.mjs
|
||||
@@ -0,0 +1,69 @@
|
||||
name: telegram-notify
|
||||
|
||||
# 通过 Telegram Bot(@WechatOnCloudBot)把「新版本发布 / 新 issue」推送到群组。
|
||||
# 跑在 GitHub Actions 上,无需任何服务器。
|
||||
#
|
||||
# 一次性配置(未配置则自动跳过,不影响 fork):
|
||||
# 1) 把 @WechatOnCloudBot 拉进目标群组(群里发言需要时设为管理员)。
|
||||
# 2) 取群组 chat id:把 bot 拉进群后在群里随便发条消息,浏览器打开
|
||||
# https://api.telegram.org/bot<BOT_TOKEN>/getUpdates ,找 result[].message.chat.id
|
||||
# (群组通常是形如 -1001234567890 的负数)。
|
||||
# 3) 仓库 Settings → Secrets and variables → Actions:
|
||||
# · Variables 选项卡 → 新建 TELEGRAM_CHAT_ID = 上面的 chat id
|
||||
# · Secrets 选项卡 → 新建 TELEGRAM_BOT_TOKEN = @BotFather 给的 bot token
|
||||
# (chat id 放 Variable 是为了能在下面的 if 条件里判断是否已配置;token 必须放 Secret。)
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
# 限制权限:只读元数据,足够本工作流所需。
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
# 仅当配置了群组 chat id 时才运行(未配置的 fork 会安全跳过)
|
||||
if: ${{ vars.TELEGRAM_CHAT_ID != '' }}
|
||||
steps:
|
||||
- name: Compose & send Telegram message
|
||||
env:
|
||||
TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TG_CHAT: ${{ vars.TELEGRAM_CHAT_ID }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
# 经 env 传入任意文本,避免把 ${{ }} 直接拼进脚本造成命令注入
|
||||
R_TAG: ${{ github.event.release.tag_name }}
|
||||
R_NAME: ${{ github.event.release.name }}
|
||||
R_URL: ${{ github.event.release.html_url }}
|
||||
R_BODY: ${{ github.event.release.body }}
|
||||
I_NUM: ${{ github.event.issue.number }}
|
||||
I_TITLE: ${{ github.event.issue.title }}
|
||||
I_URL: ${{ github.event.issue.html_url }}
|
||||
I_USER: ${{ github.event.issue.user.login }}
|
||||
I_BODY: ${{ github.event.issue.body }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${TG_TOKEN:-}" ]; then
|
||||
echo "::warning::TELEGRAM_BOT_TOKEN 未配置,跳过"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$EVENT" = "release" ]; then
|
||||
title="🚀 云微 WechatOnCloud ${R_TAG} 已发布"
|
||||
if [ -n "${R_NAME:-}" ] && [ "${R_NAME}" != "${R_TAG}" ]; then
|
||||
title="${title} — ${R_NAME}"
|
||||
fi
|
||||
# 发布说明截断到 ~3000 字符(Telegram 单条上限 4096)
|
||||
body="$(printf '%s' "${R_BODY:-}" | head -c 3000)"
|
||||
msg="$(printf '%s\n\n%s\n\n🔗 完整说明:%s\n⬆️ 升级: docker compose pull && docker compose up -d' "$title" "$body" "$R_URL")"
|
||||
else
|
||||
body="$(printf '%s' "${I_BODY:-}" | head -c 600)"
|
||||
msg="$(printf '🐛 新反馈 Issue #%s:%s\n👤 by %s\n\n%s\n\n🔗 %s' "$I_NUM" "$I_TITLE" "$I_USER" "$body" "$I_URL")"
|
||||
fi
|
||||
# --data-urlencode 负责转义,纯文本发送(不用 markdown parse_mode,避免特殊字符破坏解析)
|
||||
curl -sS --fail-with-body -X POST \
|
||||
"https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TG_CHAT}" \
|
||||
--data-urlencode "text=${msg}"
|
||||
@@ -140,3 +140,4 @@ dist
|
||||
# WechatOnCloud: 面板账号数据(含密码哈希,勿提交)
|
||||
/data-panel/
|
||||
/.claude
|
||||
.DS_Store
|
||||
|
||||
@@ -1,116 +1,76 @@
|
||||
# WechatOnCloud
|
||||
<div align="center">
|
||||
|
||||
在飞牛 NAS(x86_64 / arm64)上运行服务端微信:可管理**多个**微信实例,每个实例是一个独立的微信会话;多个 web 用户通过浏览器访问被授权的实例,实现跨设备消息同步、多端共享。
|
||||
<img src="doc/img/icon-192.png" width="88" height="88" alt="云微 logo" />
|
||||
|
||||
> 设计与选型详见 [技术方案.md](技术方案.md)。
|
||||
> 部署形态:拉取 GHCR 预构建多架构镜像(或本地自构建),面板按需动态创建微信实例容器。不熟悉 Docker?直接看 [Docker 运行模式详解](#docker-运行模式详解新手向)。
|
||||
<h1>云微 · WechatOnCloud</h1>
|
||||
|
||||
<p><b>在自己的 NAS / 服务器上运行「服务端微信」,多端浏览器共享同一个微信会话</b></p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/Gloridust/WechatOnCloud/stargazers"><img src="https://img.shields.io/github/stars/Gloridust/WechatOnCloud?style=flat-square&logo=github" alt="stars" /></a>
|
||||
<a href="https://github.com/Gloridust/WechatOnCloud/releases"><img src="https://img.shields.io/github/v/release/Gloridust/WechatOnCloud?style=flat-square" alt="release" /></a>
|
||||
<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>
|
||||
<a href="#快速开始">快速开始</a> ·
|
||||
<a href="#核心特性">核心特性</a> ·
|
||||
<a href="doc/运行原理.md">运行原理</a> ·
|
||||
<a href="#安全须知必读">安全须知</a> ·
|
||||
<a href="doc/技术方案.md">技术方案</a>
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="doc/img/Screenshot-1.png" alt="云微 · 面板主界面" /></td>
|
||||
<td width="50%"><img src="doc/img/Screenshot-2.png" alt="云微 · 实例桌面" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
在飞牛 NAS(x86_64 / arm64)或任意 Docker 主机上运行服务端微信:可管理**多个**微信实例,每个实例是一个独立的微信会话;多个 web 用户通过浏览器访问被授权的实例,实现跨设备消息同步、多端共享。**不修改微信客户端。**
|
||||
|
||||
**一句话原理**:每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器;同一实例被多个浏览器连 = 共享同一个微信会话。前面一层自研**面板**是唯一对外入口,经 docker.sock 按需创建/销毁实例并反向代理。
|
||||
|
||||
交流群: [@WechatOnCloud](https://t.me/WechatOnCloud)
|
||||
|
||||
---
|
||||
|
||||
## 工作原理(一句话)
|
||||
## 核心特性
|
||||
|
||||
每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器。同一实例被多个浏览器连 = 共享同一个微信会话。**不修改微信客户端**。
|
||||
|
||||
前面一层自研 **面板(panel)** 是唯一对外入口:负责账号登录、子账号与**实例权限**管理,经 docker 引擎**按需创建/销毁**微信实例容器,并反向代理到对应实例——浏览器只和面板打交道,KasmVNC 的凭据由面板在服务端注入,不下发前端。
|
||||
|
||||
```
|
||||
浏览器 ──▶ panel(:36080) ──┬─ / 面板 SPA(登录 / 实例网格 / 子账号 / 进入桌面)
|
||||
cookie 鉴权 ├─ /api/* 账号、实例、权限接口
|
||||
└─ /desktop/:id/* 反代 → 对应实例 KasmVNC(注入 Basic 鉴权)
|
||||
|
||||
panel ──(docker.sock)──▶ docker 引擎 ──▶ 按需创建/销毁微信实例容器 woc-wx-<id>
|
||||
每个实例 = 独立容器 + 独立数据卷 + 独立微信会话
|
||||
实例只在 docker 网络内暴露,不直连宿主
|
||||
```
|
||||
- 🗂️ **多实例** — 一个面板管理多个独立微信会话,每个实例独立容器 + 独立数据卷,互不干扰。
|
||||
- 👥 **多端共享 + 权限** — 多浏览器 / 设备共享同一会话;子账号体系,按账号分配可访问的实例(RBAC)。
|
||||
- 🖥️ **微信 PC 式界面** — 左侧实例栏 + 右侧内嵌桌面,侧栏可折叠,移动端自动转抽屉。
|
||||
- 📦 **微信本体运行时下载** — 镜像不打包微信,面板一键「下载安装 / 更新」带进度条;按 CPU 架构自动取包。
|
||||
- 🔁 **实例生命周期** — 启动 / 停止 / 重启 / 升级(拉新镜像重建、保留聊天记录),均在面板内一键完成。
|
||||
- 📎 **文件传输 + 文本剪贴板** — 拖拽上传 + 下载 + 删除,直达微信桌面 `~/Desktop`;文本可经剪贴板中转送进微信(局域网 http 下也可用)。
|
||||
- 🧩 **多端协作软锁** — 同一实例多人操作时自动只读 + 申请接管,避免键鼠打架。
|
||||
- 🔒 **安全优先** — 面板为唯一入口,KasmVNC 凭据服务端注入、永不下发前端;docker.sock 仅管理员可触达。
|
||||
- 📱 **PWA** — iOS「添加到主屏幕」、桌面 Chrome「安装」当原生 App。
|
||||
- 🏗️ **多架构** — amd64 / arm64 预构建镜像(GHCR + GitHub Actions 自动发布)。
|
||||
|
||||
---
|
||||
|
||||
## Docker 运行模式详解(新手向)
|
||||
## 文档
|
||||
|
||||
如果你对 Docker 不熟,这一节把本项目「怎么跑起来的」讲透。读完你就能看懂上面的图。
|
||||
|
||||
### 0. 先认识 5 个 Docker 概念
|
||||
|
||||
| 概念 | 一句话理解 | 在本项目里是什么 |
|
||||
|------|-----------|------------------|
|
||||
| **镜像 Image** | 只读的「软件安装包」,里面装好了程序和依赖 | `woc-panel`(面板)、`wechat-on-cloud`(微信实例) |
|
||||
| **容器 Container** | 镜像「运行起来」的实例,相当于「正在跑的程序」。一个镜像能跑出多个容器 | `woc-panel` 容器、多个 `woc-wx-<id>` 容器 |
|
||||
| **卷 Volume** | 容器之外的持久磁盘。容器删了,卷里的数据还在 | 每个微信实例一个卷 `woc-data-<id>`,存登录态和消息 |
|
||||
| **网络 Network** | 容器之间互通的「虚拟局域网」,容器之间可用**容器名**当域名互访 | 面板和所有实例在同一网络里,面板用 `http://woc-wx-<id>:3000` 找实例 |
|
||||
| **docker.sock** | Docker 引擎的「遥控器」(宿主上的一个特殊文件 `/var/run/docker.sock`)。谁拿到它,谁就能指挥 Docker 创建/删除容器 | 挂进面板容器,面板才能「动态造微信实例」 |
|
||||
|
||||
> **Compose** 则是「用一个 `docker-compose.yml` 文件描述要跑哪些容器,`docker compose up -d` 一条命令拉起」。本项目的 compose 里**只有面板一个服务**。
|
||||
|
||||
### 1. 本项目有两类容器(运行角色不同)
|
||||
|
||||
这是本项目最容易迷惑的地方:**不是所有容器都写在 `docker-compose.yml` 里。**
|
||||
|
||||
| | ① 面板容器 | ② 微信实例容器 |
|
||||
|---|-----------|---------------|
|
||||
| 容器名 | `woc-panel`(固定一个) | `woc-wx-<随机id>`(可有多个) |
|
||||
| 用哪个镜像 | `woc-panel` | `wechat-on-cloud` |
|
||||
| 谁来启动 | **你** 执行 `docker compose up -d` | **面板**:你在网页点「新建微信实例」时,面板通过 docker.sock 自动 `docker run` |
|
||||
| 写在 compose 里吗 | 是 | **否**(运行期动态创建,compose 里看不到) |
|
||||
| 对外暴露端口 | 是,宿主 `36080` → 容器 `8080` | 否,只在 docker 网络内,由面板反代 |
|
||||
| 数据存哪 | 宿主目录 `./data-panel` | 各自的命名卷 `woc-data-<id>` |
|
||||
| 生命周期 | 常驻 | 你在面板「删除实例」时销毁(默认保留卷) |
|
||||
|
||||
一句话:**你只手动管面板这一个容器;微信实例是面板帮你按需开关的。** 这就是为什么面板要挂 `docker.sock`——它需要「遥控」Docker 去开关微信实例容器。
|
||||
|
||||
### 2. 镜像从哪来:两种「构建/获取」模式
|
||||
|
||||
容器要跑,先得有镜像。本项目的两个镜像有两条获取途径,**任选其一**([快速开始](#快速开始)对应方式 A / B):
|
||||
|
||||
| | 方式 A · 本地自构建 | 方式 B · 拉取官方镜像 |
|
||||
|---|--------------------|----------------------|
|
||||
| 怎么做 | `./scripts/build-local.sh`(用本仓库 Dockerfile 在你机器上造镜像) | `docker compose up -d`(自动从 GHCR 下载现成镜像) |
|
||||
| 适合谁 | 官方还没发布镜像时 / 想自己改代码 / 内网无法访问 GHCR | 普通用户,开箱即用 |
|
||||
| 前提 | 本机能拉到基础镜像(node、KasmVNC base) | GHCR 上已发布且包为公开(见[发布到 GHCR](#发布到-ghcr)) |
|
||||
| 产物 | 本地镜像,标签和 compose 里写的一模一样 | 同名镜像,来自云端 |
|
||||
|
||||
> compose 的拉取策略是默认值(`missing`):**本地已有同名镜像就直接用,没有才去 GHCR 拉**。所以方式 A 构建完,`docker compose up -d` 会直接用你的本地镜像,不会再联网。想升级到 GHCR 最新版:`docker compose pull && docker compose up -d`。
|
||||
|
||||
> 第三个「镜像」其实是**微信本体**:它**不打进任何镜像**,而是你在面板点「下载并安装」时,由实例容器实时从腾讯官方 CDN 下到自己的卷里(见[数据持久化](#数据持久化))。
|
||||
|
||||
### 3. 从零到能用,整体发生了什么
|
||||
|
||||
```
|
||||
你: docker compose up -d
|
||||
└─▶ Docker 读取 docker-compose.yml
|
||||
└─▶ 拉起【面板容器 woc-panel】,挂上 ./data-panel 和 docker.sock,暴露 36080 端口
|
||||
|
||||
你: 浏览器开 http://NAS:36080 → 登录 → 点「新建微信实例」
|
||||
└─▶ 面板通过 docker.sock 指挥 Docker:
|
||||
├─ docker run 一个【微信实例容器 woc-wx-xxx】
|
||||
├─ 给它挂一个新卷 woc-data-xxx(存登录态/消息)
|
||||
└─ 接到同一个 docker 网络(面板才能反代到它)
|
||||
|
||||
你: 进入该实例 → 点「下载并安装」
|
||||
└─▶ 面板 docker exec 进实例容器,触发脚本从腾讯 CDN 下载微信、解压到卷
|
||||
|
||||
你: 点「进入电脑版微信」→ 手机扫码
|
||||
└─▶ 浏览器 ⇄ 面板(反代+注入鉴权) ⇄ 实例容器的 KasmVNC ⇄ 微信窗口
|
||||
```
|
||||
|
||||
### 4. 常用命令速查
|
||||
|
||||
```bash
|
||||
docker compose up -d # 启动面板(首次会拉/用镜像)
|
||||
docker compose down # 停止并删除面板容器(不动数据卷和微信实例)
|
||||
docker compose pull # 把面板/微信镜像更新到 GHCR 最新
|
||||
docker ps # 看正在运行的容器(能看到 woc-panel 和各 woc-wx-*)
|
||||
docker logs -f woc-panel # 看面板日志
|
||||
docker logs -f woc-wx-<id> # 看某个微信实例日志
|
||||
docker volume ls | grep woc # 看所有微信实例的数据卷
|
||||
```
|
||||
|
||||
> ⚠️ 微信实例容器请**始终在面板网页里增删**,不要手动 `docker rm` 它们——否则面板的实例登记和真实容器会对不上。
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [运行原理与 Docker 指南](doc/运行原理.md) | 工作原理 + 架构图;面向 Docker 新手的逐步拆解、常用命令、架构自动适配 |
|
||||
| [部署与运维](doc/部署与运维.md) | 数据持久化、常见问题排查、忘记超管密码的离线找回、目录结构 |
|
||||
| [设备伪装与风控应对](doc/设备伪装.md) | 唯一 machine-id / 真实 hostname / os-release 伪装;账号被微信强制退出循环时怎么办 |
|
||||
| [发布到 GHCR](doc/发布到GHCR.md) | 用 GitHub Actions 或本机 buildx 把镜像发布到 GHCR |
|
||||
| [技术方案](doc/技术方案.md) | 完整设计文档与选型权衡 |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
> 需已安装 Docker(含 Compose 插件)。x86_64 / arm64 均可。
|
||||
> 需已安装 Docker(含 Compose 插件)。x86_64 / arm64 均可。不熟悉 Docker?先读 [运行原理与 Docker 指南](doc/运行原理.md)。
|
||||
|
||||
`docker-compose.yml` 引用的是 GHCR 上的镜像 `ghcr.io/gloridust/{woc-panel,wechat-on-cloud}`。
|
||||
**这两个镜像需先存在**——要么官方已发布(你能直接拉取),要么你在本地自行构建。二选一:
|
||||
@@ -118,7 +78,7 @@ docker volume ls | grep woc # 看所有微信实例的数据卷
|
||||
**方式 A · 本地自构建(官方尚未发布镜像时用这个)**
|
||||
|
||||
```bash
|
||||
git clone <this-repo> WechatOnCloud
|
||||
git clone https://github.com/Gloridust/WechatOnCloud.git WechatOnCloud
|
||||
cd WechatOnCloud
|
||||
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
|
||||
./scripts/build-local.sh # 构建面板 + 微信实例镜像,打成 compose 用的同名标签
|
||||
@@ -128,13 +88,13 @@ docker compose up -d # compose 默认优先用本地镜像,不会
|
||||
**方式 B · 拉取官方镜像(已发布到 GHCR 后)**
|
||||
|
||||
```bash
|
||||
git clone <this-repo> WechatOnCloud
|
||||
git clone https://github.com/Gloridust/WechatOnCloud.git WechatOnCloud
|
||||
cd WechatOnCloud
|
||||
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
|
||||
docker compose up -d # 直接从 GHCR 拉取
|
||||
```
|
||||
|
||||
> 报错 `error from registry: denied`?说明 GHCR 上还没有该镜像(或包是私有的)。用方式 A 本地构建,或见下方[「发布到 GHCR」](#发布到-ghcr)。
|
||||
> 报错 `error from registry: denied`?说明 GHCR 上还没有该镜像(或包是私有的)。用方式 A 本地构建,或见 [发布到 GHCR](doc/发布到GHCR.md)。
|
||||
|
||||
无论哪种方式,都会拉起面板容器 `woc-panel`(唯一对外服务)。浏览器访问 `http://<NAS_IP>:36080`:
|
||||
|
||||
@@ -145,7 +105,7 @@ docker compose up -d # 直接从 GHCR 拉取
|
||||
|
||||
之后被授权的用户换任意设备打开同一地址登录面板,看到自己有权访问的实例,进入即是**同一个**微信会话。
|
||||
|
||||
> 宿主只对外暴露面板的 `36080` 一个端口;微信实例容器仅在 docker 网络内、由面板反代,不直连宿主。要改端口/版本见 `.env`。
|
||||
> 宿主只对外暴露面板的 `36080` 一个端口;微信实例容器仅在 docker 网络内、由面板反代,不直连宿主。要改端口/版本/账号见 `.env`(可配置项见 [.env.example](.env.example))。镜像会按 CPU 架构自动适配([详见文档](doc/运行原理.md#架构自动适配))。
|
||||
|
||||
### 面板能做什么
|
||||
|
||||
@@ -155,128 +115,56 @@ docker compose up -d # 直接从 GHCR 拉取
|
||||
| 实例权限分配 | 管理员 | 在实例上改「可访问账户」,或在账户上改「可访问实例」,双向管理 |
|
||||
| 下载并安装 / 更新微信 | 管理员 | 对某实例一键下载官方微信 Linux 版到其数据卷、解压安装;带进度条;后续可一键「更新到最新版」 |
|
||||
| 进入电脑版微信 | 被授权用户 | 在浏览器里操作对应实例的微信,扫码登录、收发消息 |
|
||||
| 文件 / 文本传输 | 被授权用户 | 拖拽上传 / 下载文件;文本经剪贴板中转送进微信 |
|
||||
| 修改密码 | 所有人 | 改自己的登录密码 |
|
||||
| 子账号管理 | 管理员 | 创建 / 禁用 / 重置 / 删除子账号,并分配实例访问权限 |
|
||||
| 安装为 App | 所有人 | iOS Safari「添加到主屏幕」、桌面 Chrome「安装」当原生 App(PWA) |
|
||||
|
||||
> 子账号是**访问这套面板的身份**,不是另开一个微信。管理员隐式拥有全部实例访问权;子账号只能看到被授权的实例。
|
||||
|
||||
> 微信本体**不打进镜像**,而是新建实例后在面板点「下载并安装」时下载到该实例的数据卷,所以镜像很小、构建快、不依赖腾讯 CDN。
|
||||
|
||||
### 架构自动适配
|
||||
|
||||
镜像本身多架构(amd64/arm64);下载微信时容器内**运行时再自动检测 CPU 架构**(`dpkg --print-architecture`)取对应官方包:
|
||||
|
||||
| 运行机器 | 架构 | 自动下载 |
|
||||
|----------|------|----------|
|
||||
| Intel/AMD NAS、x86 服务器 | amd64 | `WeChatLinux_x86_64.deb` |
|
||||
| ARM NAS、Apple Silicon Mac | arm64 | `WeChatLinux_arm64.deb` |
|
||||
|
||||
到飞牛上(无论 x64 还是 arm)`docker compose up -d` 同一条命令,无需改任何架构相关配置。
|
||||
|
||||
### 自定义配置(可选)
|
||||
|
||||
复制 `.env.example` 为 `.env` 后按需修改,可配置项见 [.env.example](.env.example):管理员账号密码、镜像版本(`WOC_VERSION`,建议上线后钉到具体版本)、PUID/PGID、时区、端口。
|
||||
> 微信本体**不打进镜像**,而是新建实例后在面板点「下载并安装」时下载到该实例的数据卷,所以镜像很小、构建快。
|
||||
|
||||
---
|
||||
|
||||
## 发布到 GHCR
|
||||
## 资源占用
|
||||
|
||||
仓库自带 GitHub Actions([.github/workflows/release.yml](.github/workflows/release.yml)),在你**推送 `vX.Y.Z` 标签或发布 Release** 时,自动构建多架构(amd64+arm64)镜像并推到 GHCR:
|
||||
实测(8 核 / 8 GiB 宿主,实例均已登录微信、含 Chromium 内核的 WeChatAppEx):
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0 # 触发 Actions,产出 ghcr.io/<owner>/woc-panel:1.0.0 等标签
|
||||
```
|
||||
| 状态 | CPU | 内存(RSS) |
|
||||
|------|-----|------------|
|
||||
| 单实例 · 空闲(已登录、无人观看) | ~0.1–0.2 核 | ~0.6 GiB |
|
||||
| 单实例 · 活跃(有人在浏览器操作 / 刷消息) | ~0.5–1 核(可突发) | ~1–1.5 GiB |
|
||||
| 面板本身 | 可忽略 | ~0.12 GiB |
|
||||
|
||||
首次发布后还需把 GHCR 包设为公开,否则别人 `docker compose up -d` 会报 `denied`:
|
||||
- 容器**不设硬性 CPU/内存上限**:空闲时省,活跃时按宿主余量突发;每实例另预留 **1 GiB `/dev/shm`**(微信 Chromium 内核所需,tmpfs,按需占用)。
|
||||
- 估算:**面板 ≈ 0.15 GiB 常驻;每个微信实例按 1 vCPU + 1.5 GiB 内存预留**较从容(轻度使用可更低)。
|
||||
- 参考容量:**2 核 / 2 GiB** 跑 1 个实例(轻度);**4 核 / 8 GiB** 跑 3–4 个实例;视频通话等重负载需再加预留。
|
||||
|
||||
1. 打开 GitHub → 你的头像 → **Packages** → 分别进入 `woc-panel`、`wechat-on-cloud`;
|
||||
2. **Package settings → Change visibility → Public**。
|
||||
|
||||
> 若想保持私有,则使用者需先 `docker login ghcr.io`(用具备 `read:packages` 的 PAT)才能拉取。
|
||||
> 在镜像发布之前,本地用 `./scripts/build-local.sh` 自构建即可,无需等待发布。
|
||||
> 内存是主要瓶颈,CPU 多为短时突发。实例越多越吃内存,按上表线性叠加即可估算。
|
||||
|
||||
---
|
||||
|
||||
## 数据持久化
|
||||
## 安全须知(必读)
|
||||
|
||||
- **面板数据**(用户、实例元信息、密码哈希):容器内 `/data`,映射到宿主 `./data-panel`。
|
||||
- **每个微信实例**:独立的 docker 命名卷 `woc-data-<id>`,挂到该实例容器的 `/config`(微信本体在 `/config/wechat`,登录态与消息缓存在 `/config` 其余位置)。
|
||||
> ⚠️ **这套系统暴露的是已登录的微信,请务必认真阅读本节。**
|
||||
|
||||
要点:
|
||||
- 删除实例**默认保留**其数据卷,下次同名重建可复用;只有显式勾选「彻底清除」才会删卷。
|
||||
- 备份某实例 = 备份对应的 `woc-data-<id>` 卷(`docker volume` 系列命令)。
|
||||
- 卷需支持执行权限(微信本体直接从卷里运行);放在 `noexec` 卷上微信将无法启动。
|
||||
- 备份面板 = 备份 `./data-panel`。
|
||||
|
||||
> **从旧版(单微信容器 + `./data` 绑定挂载)迁移**:旧形态把微信数据放在宿主 `./data`。新版用 docker 命名卷,结构不同,无自动迁移。如需保留旧会话,最简单是新建一个实例、重新扫码登录;或手动把旧 `./data` 内容拷进新实例的 `woc-data-<id>` 卷。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 安全须知(必读)
|
||||
|
||||
这套系统暴露的是**已登录的微信**——能登录面板的人就能看聊天记录、以你身份发消息。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此:
|
||||
能登录面板的人就能看聊天记录、以你身份发消息。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此:
|
||||
|
||||
- **绝不要把面板裸暴露公网**:只在内网访问,或经飞牛远程访问 / VPN / 内网穿透;
|
||||
- 务必改掉默认密码(默认 admin / wechat):`cp .env.example .env` 后改 `WOC_PASSWORD`,或登录后在「修改密码」里改;
|
||||
- 实例的增删、微信安装/更新等触碰 docker 引擎的操作**仅限管理员**;docker API 绝不暴露给前端;
|
||||
- KasmVNC 凭据由面板服务端注入,**浏览器永远拿不到**;实例容器名由内部随机 ID 派生,避免注入;
|
||||
- 面板与外网之间再套一层 HTTPS 反代(飞牛自带反代 / Caddy / Nginx)获得正经 TLS;
|
||||
- 进一步加固(陌生设备验证码、审计日志、并发控制)见 [技术方案.md](技术方案.md) 第 5 节。
|
||||
- 进一步加固(陌生设备验证码、审计日志、并发控制)见 [技术方案.md](doc/技术方案.md) 第 5 节。
|
||||
|
||||
---
|
||||
|
||||
## 中文输入
|
||||
|
||||
**用你本地(客户端)的输入法打中文,容器内无需安装任何 IME。** 镜像已默认开启 KasmVNC 的
|
||||
「IME Input Mode」:拼音联想在你本机完成,只把成品汉字发进容器。直接在微信输入框打字即可。
|
||||
**用你本地(客户端)的输入法打中文,容器内无需安装任何 IME。** 镜像默认开启 KasmVNC 的「IME Input Mode」,并对 noVNC 的 IME 合成逻辑做了修复——**只在输入法「上屏」那一刻把成品汉字整串发进容器**,规避了原生实现逐字符差分带来的丢字 / 卡顿。直接在微信输入框打字即可。
|
||||
|
||||
- 默认值只对**未存过该设置的浏览器**生效。之前手动开/关过的,浏览器 localStorage 值优先;想验证默认效果用无痕窗口。
|
||||
- 已知小毛病:超长拼音串未全部转成汉字就回车,偶尔丢字([issue #97](https://github.com/linuxserver/docker-baseimage-kasmvnc/issues/97)),长句分段输入即可。
|
||||
- 兜底:Chrome/Edge 下本地 `⌘C` → 远端 `Ctrl+V` 无缝粘贴;Firefox 用控制面板的 Clipboard 文本框中转。
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 现象 | 排查 |
|
||||
|------|------|
|
||||
| 新建实例失败 | 多为面板拉不到微信镜像或连不上 docker.sock。确认 `docker.sock` 已挂载、宿主能访问 GHCR;看面板日志 `docker logs woc-panel` |
|
||||
| 界面/消息显示成方块 | 中文字体没装好,确认实例镜像含 `fonts-noto-cjk` |
|
||||
| 微信起不来 / 黑屏 | 看实例日志 `docker logs woc-wx-<id>`;确认 `seccomp=unconfined` 与 `shm_size` 生效。微信 deb 漏声明的运行时依赖已在 Dockerfile 内置 |
|
||||
| 排查缺哪个库 | `docker exec woc-wx-<id> ldd /config/wechat/opt/wechat/wechat`,看 `not found` 项补进 Dockerfile 依赖层 |
|
||||
| 多人同时操作很乱 | 单会话多端共享、键鼠会打架。未做并发控制,建议同一时刻一人操作(见技术方案 6.1) |
|
||||
| 过段时间掉登录 | 微信桌面会话会定期失效,需手机重新扫码(见技术方案 6.2) |
|
||||
| 下载 / 更新微信失败 | 腾讯 CDN 偶发波动,重新点「下载并安装 / 更新」即可;脚本已内置主/备 CDN 自动回退 |
|
||||
| 架构不支持报错 | 微信仅提供 x86_64 / arm64;其他架构下载时会在面板状态里报错 |
|
||||
|
||||
查看面板日志:`docker logs -f woc-panel`;查看某实例日志:`docker logs -f woc-wx-<id>`(实例 ID 可在面板看到,或 `docker ps | grep woc-wx`)。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
WechatOnCloud/
|
||||
├── .github/workflows/
|
||||
│ └── release.yml # 打 tag / 发 Release 时构建多架构镜像并推送 GHCR
|
||||
├── docker/ # 微信实例镜像(ghcr.io/<owner>/wechat-on-cloud)
|
||||
│ ├── Dockerfile # KasmVNC base + 中文字体 + 微信运行时依赖 + 默认开 IME(不打包微信本体)
|
||||
│ ├── wechat-ctl.sh # 运行时下载/解压/更新微信(面板经 docker exec 触发,状态写 /config/.woc-state)
|
||||
│ └── autostart # openbox 会话启动:等待微信就绪 + 常驻拉起(含崩溃自重启)
|
||||
├── panel/ # 自研面板(ghcr.io/<owner>/woc-panel,唯一对外入口)
|
||||
│ ├── Dockerfile # 前端 Vite 打包 + 后端 Fastify 网关(多架构)
|
||||
│ ├── server/ # Fastify:cookie 鉴权 + 账号/实例/权限 API + dockerode 管理实例 + 反代
|
||||
│ └── web/ # React + TS + PWA(牛奶布艺 + 微信绿主题)
|
||||
├── fnos/ # 飞牛 fnOS 应用打包(.fpk 工程 + 构建说明)
|
||||
├── scripts/
|
||||
│ └── build-local.sh # 本地构建面板+微信镜像(发布前自测 / 自托管自构建)
|
||||
├── docker-compose.yml # 单服务:panel(挂 docker.sock,按需创建实例)
|
||||
├── .env.example # 可选配置(账号密码、镜像版本、PUID/PGID、端口、时区)
|
||||
├── 技术方案.md # 完整设计文档
|
||||
└── README.md
|
||||
```
|
||||
|
||||
数据:面板账号(含密码哈希)在 `./data-panel`,各微信实例在 docker 命名卷 `woc-data-<id>`;`./data-panel` 已在 `.gitignore` 中。
|
||||
- **跨设备文本**:实例工具栏的「剪贴板」可把文本送入容器剪贴板,再在微信里 `Ctrl+V` 粘贴——不依赖浏览器异步剪贴板 API,**局域网 http 访问下也可用**。
|
||||
- **文件**:用工具栏「文件」拖拽上传,微信收到的文件另存到桌面即可在此下载。
|
||||
|
||||
---
|
||||
|
||||
@@ -287,7 +175,37 @@ WechatOnCloud/
|
||||
- [x] 微信本体运行时下载到数据卷:面板一键「下载并安装 / 更新」,带进度条
|
||||
- [x] 多实例管理 + 按账号的实例访问权限(RBAC)
|
||||
- [x] 预构建多架构镜像发布到 GHCR + GitHub Actions 自动化
|
||||
- [x] 中文输入修复 + 文本剪贴板中转 + 实例日志导出
|
||||
- [ ] 面板外层 TLS / 陌生设备验证码 / 审计日志
|
||||
- [ ] 多端并发控制(控制权令牌)
|
||||
- [x] 多端并发控制(操作控制权心跳软锁 + 只读遮罩 + 申请接管)
|
||||
- [ ] 掉登录时 web 端二维码重扫入口
|
||||
- [~] 打包成飞牛原生 fpk 分发(工程已就绪见 [fnos/](fnos/),待真实设备验证 docker.sock 权限)
|
||||
|
||||
## 交流与关注
|
||||
|
||||
- 🐦 Twitter / X:[@gloridust1024](https://x.com/gloridust1024) — 更新与动态
|
||||
- ✈️ Telegram:[@WechatOnCloud](https://t.me/WechatOnCloud) — 交流群 / 问题反馈
|
||||
|
||||
## 致谢
|
||||
|
||||
创意启发自懒猫微服(原 Deepin 团队做的硬件产品),推荐有经济实力、追求稳定运营的朋友尝试!
|
||||
|
||||
也感谢每一位 Star / Issue / PR 的朋友——**两天突破 500 ⭐**,是继续打磨的最大动力 🙌
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Gloridust%2FWechatOnCloud&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>如果这个项目帮到了你,欢迎点个 ⭐ Star 支持一下 ·
|
||||
<a href="https://github.com/Gloridust/WechatOnCloud/issues">反馈问题</a> ·
|
||||
<a href="https://github.com/Gloridust/WechatOnCloud/pulls">参与贡献</a></sub>
|
||||
</div>
|
||||
|
||||
|
After Width: | Height: | Size: 905 KiB |
|
After Width: | Height: | Size: 916 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,111 @@
|
||||
# 发布镜像
|
||||
|
||||
> 返回 [← README](../README.md)
|
||||
|
||||
把两个镜像(`woc-panel`、`wechat-on-cloud`)发布到容器仓库,供他人 `docker compose up -d` 直接拉取。默认走 GHCR;可选同步推到 Docker Hub。
|
||||
|
||||
---
|
||||
|
||||
## 方式 A · GitHub Actions(推荐)
|
||||
|
||||
仓库自带 GitHub Actions([.github/workflows/release.yml](../.github/workflows/release.yml)),在你**推送 `vX.Y.Z` 标签或发布 Release** 时,自动构建多架构(amd64+arm64)镜像并推到 GHCR:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0 # 触发 Actions,产出 ghcr.io/<owner>/woc-panel:1.0.0 等标签
|
||||
# 或在 GitHub 上 Publish 一个 Release(会额外打 latest):
|
||||
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)
|
||||
|
||||
适合想立刻出包、或不依赖 CI 的场景。需要 Docker Buildx(Docker Desktop 自带;纯 Linux 跨架构需先装 QEMU:`docker run --privileged --rm tonistiigi/binfmt --install all`)。
|
||||
|
||||
```bash
|
||||
# 1) 登录 GHCR(PAT 需 write:packages 权限)
|
||||
echo <YOUR_GITHUB_PAT> | docker login ghcr.io -u <github 用户名> --password-stdin
|
||||
|
||||
# 2) 首次创建并启用多架构构建器(已建过改用 docker buildx use woc)
|
||||
docker buildx create --name woc --use
|
||||
|
||||
# 3) 构建并推送两个镜像(amd64 + arm64)。VER 与 git tag 保持一致(不带 v)
|
||||
VER=1.0.1
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t ghcr.io/gloridust/woc-panel:$VER -t ghcr.io/gloridust/woc-panel:latest \
|
||||
--push ./panel
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t ghcr.io/gloridust/wechat-on-cloud:$VER -t ghcr.io/gloridust/wechat-on-cloud:latest \
|
||||
--push ./docker
|
||||
```
|
||||
|
||||
> 把 `gloridust` 换成你的 GHCR 命名空间(与 `docker-compose.yml` / `WOC_IMAGE_PREFIX` 一致)。
|
||||
> 只想本机自用、不推 GHCR,用 [`./scripts/build-local.sh`](../scripts/build-local.sh) 构建本机架构单架构镜像即可。
|
||||
|
||||
---
|
||||
|
||||
## 发布后:把包设为公开
|
||||
|
||||
首次发布后还需把 GHCR 包设为公开,否则别人 `docker compose up -d` 会报 `denied`:
|
||||
|
||||
1. 打开 GitHub → 你的头像 → **Packages** → 分别进入 `woc-panel`、`wechat-on-cloud`;
|
||||
2. **Package settings → Change visibility → Public**。
|
||||
|
||||
> 若想保持私有,则使用者需先 `docker login ghcr.io`(用具备 `read:packages` 的 PAT)才能拉取。
|
||||
> 在镜像发布之前,本地用 [`./scripts/build-local.sh`](../scripts/build-local.sh) 自构建即可,无需等待发布。
|
||||
|
||||
---
|
||||
|
||||
## Telegram 发布通知(可选,免服务器)
|
||||
|
||||
仓库内置 [.github/workflows/telegram-notify.yml](../.github/workflows/telegram-notify.yml):**新版本发布** / **新 issue** 时,通过 Telegram Bot 推送到群组。跑在 GitHub Actions 上,无需服务器;未配置则自动跳过。
|
||||
|
||||
一次性配置:
|
||||
|
||||
1. 把机器人(如 `@WechatOnCloudBot`)拉进目标 Telegram 群组;需要发言权限时设为管理员。
|
||||
2. 取群组 chat id:bot 进群后在群里发条消息,浏览器打开 `https://api.telegram.org/bot<BOT_TOKEN>/getUpdates`,找 `result[].message.chat.id`(群组通常是 `-100` 开头的负数)。
|
||||
3. 仓库 **Settings → Secrets and variables → Actions**:
|
||||
- **Variables** 标签 → `TELEGRAM_CHAT_ID` = 上面的 chat id;
|
||||
- **Secrets** 标签 → `TELEGRAM_BOT_TOKEN` = [@BotFather](https://t.me/BotFather) 给的 token。
|
||||
|
||||
之后每次「发布 Release / 新建 issue」都会自动推送。想关掉 issue 推送,删掉 workflow 里 `on:` 下的 `issues:` 即可。
|
||||
|
||||
---
|
||||
|
||||
## Telegram 命令机器人(可选,免服务器,轮询版)
|
||||
|
||||
除了发布通知,仓库还内置 [.github/workflows/telegram-bot.yml](../.github/workflows/telegram-bot.yml) + [.github/scripts/telegram-bot.mjs](../.github/scripts/telegram-bot.mjs):让机器人在**私聊 / 群组**里响应命令,跑在 GitHub Actions cron 上,**不需要服务器**。
|
||||
|
||||
命令:`/help`、`/releases`、`/release <tag>`、`/issues`、`/issue <编号>`。
|
||||
|
||||
启用:
|
||||
|
||||
1. 复用上面的 `TELEGRAM_BOT_TOKEN`(Secret);
|
||||
2. 仓库 **Settings → Secrets and variables → Actions → Variables** 新建 `TELEGRAM_BOT_ENABLED = true`(不设为 `true` 则该工作流不运行);
|
||||
3. 把机器人拉进群组,或在私聊里 `/start`。
|
||||
|
||||
> **原理**:每 5 分钟(cron 最小间隔)调用 `getUpdates` 拉取待处理命令、回复、再用 `offset` 向 Telegram 确认(Telegram 自己保存 offset,**无需任何持久化存储**)。
|
||||
> **局限**:命令**非实时**——受 cron 最小 5 分钟 + GitHub 排队延迟影响;且 GitHub 会在仓库 60 天无活动时暂停定时任务(去 Actions 页手动重启即可)。想立即处理一次:Actions → telegram-bot → **Run workflow**。要真正实时,得改用 webhook(需一个 serverless 端点)。
|
||||
@@ -1,7 +1,6 @@
|
||||
# WechatOnCloud 技术方案
|
||||
|
||||
> 目标:在飞牛 NAS(x86_64)上运行一个服务端微信,多个 web 用户通过浏览器访问同一个微信会话,实现跨设备消息同步、多端共享,解决原生微信"一台电脑一个登录"的痛点。
|
||||
> 参考形态:懒猫微服「云微信」。
|
||||
> 目标:在飞牛 NAS(x86_64)或任何服务器上运行服务端微信,多个 web 用户通过浏览器访问同一个微信会话,实现跨设备消息同步、多端共享,解决原生微信"一台电脑一个登录"的痛点。
|
||||
|
||||
---
|
||||
|
||||
@@ -147,7 +146,6 @@
|
||||
|
||||
```
|
||||
WechatOnCloud/
|
||||
├── 技术方案.md # 本文档
|
||||
├── docker/
|
||||
│ ├── Dockerfile # Debian + Xvfb + 微信deb + KasmVNC
|
||||
│ ├── entrypoint.sh # 启动 Xvfb → WM → 微信 → KasmVNC
|
||||
@@ -183,8 +181,7 @@ WechatOnCloud/
|
||||
|
||||
- [微信 Linux 官网](https://linux.weixin.qq.com/en)
|
||||
- [腾讯上线 Linux 微信官网(x86/Arm/LoongArch)](https://finance.sina.com.cn/tech/roll/2024-11-06/doc-incvceuc0172323.shtml)
|
||||
- [懒猫微服 云微信](https://lazycat.cloud/lcmd/)
|
||||
- [开源云微信部署方案(低配版懒猫远程微信)](https://linux.do/t/topic/1306818)
|
||||
- [懒猫微服 云微信](https://lazycat.cloud/)
|
||||
- [Xpra 官方文档](https://xpra.org/manual)
|
||||
- [Xpra HTML5 客户端](https://github.com/Xpra-org/xpra-html5)
|
||||
- [飞牛 fpk 框架文档](https://developer.fnnas.com/docs/core-concepts/framework)
|
||||
@@ -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** | 用常见网卡厂商 OUI(Intel/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 三项无开关、恒定开启(它们没有合理的关闭理由)。
|
||||
@@ -0,0 +1,119 @@
|
||||
# 运行原理与 Docker 指南
|
||||
|
||||
> 返回 [← README](../README.md) · 深入设计见 [技术方案.md](技术方案.md)
|
||||
|
||||
本篇把「云微怎么跑起来」讲透:先一句话原理 + 架构图,再面向 Docker 新手的逐步拆解。
|
||||
|
||||
---
|
||||
|
||||
## 工作原理(一句话)
|
||||
|
||||
每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器。同一实例被多个浏览器连 = 共享同一个微信会话。**不修改微信客户端**。
|
||||
|
||||
前面一层自研 **面板(panel)** 是唯一对外入口:负责账号登录、子账号与**实例权限**管理,经 docker 引擎**按需创建/销毁**微信实例容器,并反向代理到对应实例——浏览器只和面板打交道,KasmVNC 的凭据由面板在服务端注入,不下发前端。
|
||||
|
||||
```
|
||||
浏览器 ──▶ panel(:36080) ──┬─ / 面板 SPA(登录 / 实例网格 / 子账号 / 进入桌面)
|
||||
cookie 鉴权 ├─ /api/* 账号、实例、权限接口
|
||||
└─ /desktop/:id/* 反代 → 对应实例 KasmVNC(注入 Basic 鉴权)
|
||||
|
||||
panel ──(docker.sock)──▶ docker 引擎 ──▶ 按需创建/销毁微信实例容器 woc-wx-<id>
|
||||
每个实例 = 独立容器 + 独立数据卷 + 独立微信会话
|
||||
实例只在 docker 网络内暴露,不直连宿主
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker 运行模式详解(新手向)
|
||||
|
||||
如果你对 Docker 不熟,这一节把本项目「怎么跑起来的」讲透。读完你就能看懂上面的图。
|
||||
|
||||
### 0. 先认识 5 个 Docker 概念
|
||||
|
||||
| 概念 | 一句话理解 | 在本项目里是什么 |
|
||||
|------|-----------|------------------|
|
||||
| **镜像 Image** | 只读的「软件安装包」,里面装好了程序和依赖 | `woc-panel`(面板)、`wechat-on-cloud`(微信实例) |
|
||||
| **容器 Container** | 镜像「运行起来」的实例,相当于「正在跑的程序」。一个镜像能跑出多个容器 | `woc-panel` 容器、多个 `woc-wx-<id>` 容器 |
|
||||
| **卷 Volume** | 容器之外的持久磁盘。容器删了,卷里的数据还在 | 每个微信实例一个卷 `woc-data-<id>`,存登录态和消息 |
|
||||
| **网络 Network** | 容器之间互通的「虚拟局域网」,容器之间可用**容器名**当域名互访 | 面板和所有实例在同一网络里,面板用 `http://woc-wx-<id>:3000` 找实例 |
|
||||
| **docker.sock** | Docker 引擎的「遥控器」(宿主上的一个特殊文件 `/var/run/docker.sock`)。谁拿到它,谁就能指挥 Docker 创建/删除容器 | 挂进面板容器,面板才能「动态造微信实例」 |
|
||||
|
||||
> **Compose** 则是「用一个 `docker-compose.yml` 文件描述要跑哪些容器,`docker compose up -d` 一条命令拉起」。本项目的 compose 里**只有面板一个服务**。
|
||||
|
||||
### 1. 本项目有两类容器(运行角色不同)
|
||||
|
||||
这是本项目最容易迷惑的地方:**不是所有容器都写在 `docker-compose.yml` 里。**
|
||||
|
||||
| | ① 面板容器 | ② 微信实例容器 |
|
||||
|---|-----------|---------------|
|
||||
| 容器名 | `woc-panel`(固定一个) | `woc-wx-<随机id>`(可有多个) |
|
||||
| 用哪个镜像 | `woc-panel` | `wechat-on-cloud` |
|
||||
| 谁来启动 | **你** 执行 `docker compose up -d` | **面板**:你在网页点「新建微信实例」时,面板通过 docker.sock 自动 `docker run` |
|
||||
| 写在 compose 里吗 | 是 | **否**(运行期动态创建,compose 里看不到) |
|
||||
| 对外暴露端口 | 是,宿主 `36080` → 容器 `8080` | 否,只在 docker 网络内,由面板反代 |
|
||||
| 数据存哪 | 宿主目录 `./data-panel` | 各自的命名卷 `woc-data-<id>` |
|
||||
| 生命周期 | 常驻 | 你在面板「删除实例」时销毁(默认保留卷) |
|
||||
|
||||
一句话:**你只手动管面板这一个容器;微信实例是面板帮你按需开关的。** 这就是为什么面板要挂 `docker.sock`——它需要「遥控」Docker 去开关微信实例容器。
|
||||
|
||||
### 2. 镜像从哪来:两种「构建/获取」模式
|
||||
|
||||
容器要跑,先得有镜像。本项目的两个镜像有两条获取途径,**任选其一**([README 快速开始](../README.md#快速开始)对应方式 A / B):
|
||||
|
||||
| | 方式 A · 本地自构建 | 方式 B · 拉取官方镜像 |
|
||||
|---|--------------------|----------------------|
|
||||
| 怎么做 | `./scripts/build-local.sh`(用本仓库 Dockerfile 在你机器上造镜像) | `docker compose up -d`(自动从 GHCR 下载现成镜像) |
|
||||
| 适合谁 | 官方还没发布镜像时 / 想自己改代码 / 内网无法访问 GHCR | 普通用户,开箱即用 |
|
||||
| 前提 | 本机能拉到基础镜像(node、KasmVNC base) | GHCR 上已发布且包为公开(见[发布到 GHCR](发布到GHCR.md)) |
|
||||
| 产物 | 本地镜像,标签和 compose 里写的一模一样 | 同名镜像,来自云端 |
|
||||
|
||||
> compose 的拉取策略是默认值(`missing`):**本地已有同名镜像就直接用,没有才去 GHCR 拉**。所以方式 A 构建完,`docker compose up -d` 会直接用你的本地镜像,不会再联网。想升级到 GHCR 最新版:`docker compose pull && docker compose up -d`。
|
||||
|
||||
> 第三个「镜像」其实是**微信本体**:它**不打进任何镜像**,而是你在面板点「下载并安装」时,由实例容器实时从腾讯官方 CDN 下到自己的卷里(见[部署与运维 · 数据持久化](部署与运维.md#数据持久化))。
|
||||
|
||||
### 3. 从零到能用,整体发生了什么
|
||||
|
||||
```
|
||||
你: docker compose up -d
|
||||
└─▶ Docker 读取 docker-compose.yml
|
||||
└─▶ 拉起【面板容器 woc-panel】,挂上 ./data-panel 和 docker.sock,暴露 36080 端口
|
||||
|
||||
你: 浏览器开 http://NAS:36080 → 登录 → 点「新建微信实例」
|
||||
└─▶ 面板通过 docker.sock 指挥 Docker:
|
||||
├─ docker run 一个【微信实例容器 woc-wx-xxx】
|
||||
├─ 给它挂一个新卷 woc-data-xxx(存登录态/消息)
|
||||
└─ 接到同一个 docker 网络(面板才能反代到它)
|
||||
|
||||
你: 进入该实例 → 点「下载并安装」
|
||||
└─▶ 面板 docker exec 进实例容器,触发脚本从腾讯 CDN 下载微信、解压到卷
|
||||
|
||||
你: 点「进入电脑版微信」→ 手机扫码
|
||||
└─▶ 浏览器 ⇄ 面板(反代+注入鉴权) ⇄ 实例容器的 KasmVNC ⇄ 微信窗口
|
||||
```
|
||||
|
||||
### 4. 常用命令速查
|
||||
|
||||
```bash
|
||||
docker compose up -d # 启动面板(首次会拉/用镜像)
|
||||
docker compose down # 停止并删除面板容器(不动数据卷和微信实例)
|
||||
docker compose pull # 把面板/微信镜像更新到 GHCR 最新
|
||||
docker ps # 看正在运行的容器(能看到 woc-panel 和各 woc-wx-*)
|
||||
docker logs -f woc-panel # 看面板日志
|
||||
docker logs -f woc-wx-<id> # 看某个微信实例日志
|
||||
docker volume ls | grep woc # 看所有微信实例的数据卷
|
||||
```
|
||||
|
||||
> ⚠️ 微信实例容器请**始终在面板网页里增删**,不要手动 `docker rm` 它们——否则面板的实例登记和真实容器会对不上。
|
||||
|
||||
---
|
||||
|
||||
## 架构自动适配
|
||||
|
||||
镜像本身多架构(amd64/arm64);下载微信时容器内**运行时再自动检测 CPU 架构**(`dpkg --print-architecture`)取对应官方包:
|
||||
|
||||
| 运行机器 | 架构 | 自动下载 |
|
||||
|----------|------|----------|
|
||||
| Intel/AMD NAS、x86 服务器 | amd64 | `WeChatLinux_x86_64.deb` |
|
||||
| ARM NAS、Apple Silicon Mac | arm64 | `WeChatLinux_arm64.deb` |
|
||||
|
||||
到飞牛上(无论 x64 还是 arm)`docker compose up -d` 同一条命令,无需改任何架构相关配置。
|
||||
@@ -0,0 +1,101 @@
|
||||
# 部署与运维
|
||||
|
||||
> 返回 [← README](../README.md)
|
||||
|
||||
数据持久化、常见问题排查、忘记超管密码的离线找回,以及仓库目录结构。
|
||||
|
||||
---
|
||||
|
||||
## 数据持久化
|
||||
|
||||
- **面板数据**(用户、实例元信息、密码哈希):容器内 `/data`,映射到宿主 `./data-panel`。
|
||||
- **每个微信实例**:独立的 docker 命名卷 `woc-data-<id>`,挂到该实例容器的 `/config`(微信本体在 `/config/wechat`,登录态与消息缓存在 `/config` 其余位置)。
|
||||
|
||||
要点:
|
||||
- 删除实例**默认保留**其数据卷,下次同名重建可复用;只有显式勾选「彻底清除」才会删卷。
|
||||
- 备份某实例 = 备份对应的 `woc-data-<id>` 卷(`docker volume` 系列命令)。
|
||||
- 卷需支持执行权限(微信本体直接从卷里运行);放在 `noexec` 卷上微信将无法启动。
|
||||
- 备份面板 = 备份 `./data-panel`。
|
||||
|
||||
> **从旧版(单微信容器 + `./data` 绑定挂载)迁移**:旧形态把微信数据放在宿主 `./data`。新版用 docker 命名卷,结构不同,无自动迁移。如需保留旧会话,最简单是新建一个实例、重新扫码登录;或手动把旧 `./data` 内容拷进新实例的 `woc-data-<id>` 卷。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 现象 | 排查 |
|
||||
|------|------|
|
||||
| 新建实例失败 | 多为面板拉不到微信镜像或连不上 docker.sock。确认 `docker.sock` 已挂载、宿主能访问 GHCR;看面板日志 `docker logs woc-panel` |
|
||||
| 界面/消息显示成方块 | 中文字体没装好,确认实例镜像含 `fonts-noto-cjk` |
|
||||
| 微信起不来 / 黑屏 | 看实例日志 `docker logs woc-wx-<id>`;确认 `seccomp=unconfined` 与 `shm_size` 生效。微信 deb 漏声明的运行时依赖已在 Dockerfile 内置 |
|
||||
| 排查缺哪个库 | `docker exec woc-wx-<id> ldd /config/wechat/opt/wechat/wechat`,看 `not found` 项补进 Dockerfile 依赖层 |
|
||||
| 多人同时操作很乱 | 已内置「操作控制权」软锁:当前操作者每数秒心跳续约,其余端自动转为**只读遮罩**(仍可看画面),空闲超时(约 10s)自动释放,他人可点「申请控制」接管。仍建议同一时刻一人操作 |
|
||||
| 过段时间掉登录 | 微信桌面会话会定期失效,需手机重新扫码(见技术方案 6.2) |
|
||||
| 下载 / 更新微信失败 | 腾讯 CDN 偶发波动,重新点「下载并安装 / 更新」即可;脚本已内置主/备 CDN 自动回退 |
|
||||
| 架构不支持报错 | 微信仅提供 x86_64 / arm64;其他架构下载时会在面板状态里报错 |
|
||||
| 忘记超管密码 | 见下方「重置超管密码」离线找回 |
|
||||
|
||||
查看面板日志:`docker logs -f woc-panel`;查看某实例日志:`docker logs -f woc-wx-<id>`(实例 ID 可在面板看到,或 `docker ps | grep woc-wx`)。也可在面板的「管理」页或实例页点「查看日志」直接导出实例容器日志。
|
||||
|
||||
---
|
||||
|
||||
## 重置超管密码(离线找回)
|
||||
|
||||
管理员密码无法被他人重置,忘记时按以下步骤离线找回:
|
||||
|
||||
```bash
|
||||
docker compose stop panel # 1) 先停面板,避免覆盖你的手动修改
|
||||
```
|
||||
|
||||
2) 编辑 `./data-panel/accounts.json`,给对应用户对象加一行 `"resetPassword": true`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...", "username": "admin", "role": "admin",
|
||||
"passwordHash": "...", "disabled": false,
|
||||
"resetPassword": true
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d # 3) 启动,面板初始化时会重置该账号
|
||||
```
|
||||
|
||||
> ⚠️ 重置逻辑只在面板**进程启动**时执行。若你没先 `stop` 而面板仍在运行,直接 `docker compose up -d` 会因「容器无变化」而空操作(输出 `Running` 而非 `Started`),重置不会发生。此时执行 **`docker compose restart panel`**(或 `docker restart woc-panel`)强制重启即可生效。
|
||||
|
||||
重启后该账号密码被重置为 `PANEL_ADMIN_PASSWORD`(即 `.env` 的 `WOC_PASSWORD`,默认 `wechat`),并自动**解禁**、清除该标记;用此密码登录后请立即在「修改密码」改掉。日志会打印 `[store] 已重置用户 '<用户名>' 的密码`。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
WechatOnCloud/
|
||||
├── .github/workflows/
|
||||
│ └── release.yml # 打 tag / 发 Release 时构建多架构镜像并推送 GHCR
|
||||
├── docker/ # 微信实例镜像(ghcr.io/<owner>/wechat-on-cloud)
|
||||
│ ├── Dockerfile # KasmVNC base + 中文字体 + 微信运行时依赖 + xdotool + IME 补丁(不打包微信本体)
|
||||
│ ├── woc-www-patch.sh # 构建期补丁:开启 KasmVNC IME 模式 + 修复 noVNC 中文输入(配合 woc-ime.pl)
|
||||
│ ├── woc-ime.pl # noVNC 键盘 IME 逻辑的 perl 补丁(合成提交时整串发送,规避丢字)
|
||||
│ ├── wechat-ctl.sh # 运行时下载/解压/更新微信(面板经 docker exec 触发,状态写 /config/.woc-state)
|
||||
│ ├── autostart # openbox 会话启动:常驻拉起微信(崩溃自重启)+ 最小化窗口自动复原看守
|
||||
│ └── woc-update-autostart # 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本
|
||||
├── panel/ # 自研面板(ghcr.io/<owner>/woc-panel,唯一对外入口)
|
||||
│ ├── Dockerfile # 前端 Vite 打包 + 后端 Fastify 网关(多架构)
|
||||
│ ├── server/ # Fastify:cookie 鉴权 + 账号/实例/权限/生命周期 API + dockerode + 反代
|
||||
│ └── web/ # React + TS + PWA(微信 PC 式布局,牛奶布艺 + 微信绿主题)
|
||||
├── fnos/ # 飞牛 fnOS 应用打包(.fpk 工程 + 构建说明)
|
||||
├── scripts/
|
||||
│ └── build-local.sh # 本地构建面板+微信镜像(发布前自测 / 自托管自构建)
|
||||
├── doc/ # 文档与素材
|
||||
│ ├── 技术方案.md # 完整设计文档
|
||||
│ ├── 运行原理.md # 工作原理 + Docker 运行模式详解(新手向)
|
||||
│ ├── 发布到GHCR.md # 镜像发布到 GHCR 的两种方式
|
||||
│ ├── 部署与运维.md # 本文档:数据持久化 / 常见问题 / 密码找回 / 目录结构
|
||||
│ └── img/ # logo 与界面截图
|
||||
├── docker-compose.yml # 单服务:panel(挂 docker.sock,按需创建实例)
|
||||
├── .env.example # 可选配置(账号密码、镜像版本、PUID/PGID、端口、时区)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
数据:面板账号(含密码哈希)在 `./data-panel`,各微信实例在 docker 命名卷 `woc-data-<id>`;`./data-panel` 已在 `.gitignore` 中。
|
||||
@@ -3,7 +3,8 @@
|
||||
# 镜像全部从 GHCR 拉取,无需本地构建。要改配置:复制 .env.example 为 .env 后修改。
|
||||
services:
|
||||
panel:
|
||||
image: ghcr.io/gloridust/woc-panel:${WOC_VERSION:-latest}
|
||||
# 镜像源前缀默认 GHCR;大陆网络拉不动时改 .env 的 WOC_IMAGE_PREFIX 切到国内反代(见 .env.example)。
|
||||
image: ${WOC_IMAGE_PREFIX:-ghcr.io/gloridust}/woc-panel:${WOC_VERSION:-latest}
|
||||
container_name: woc-panel
|
||||
# pull_policy 用默认(missing):本地已有同名镜像就直接用,没有才去 GHCR 拉。
|
||||
# 这样「发布前本地自构建」与「线上拉取」都能用同一份 compose。
|
||||
@@ -11,16 +12,31 @@ services:
|
||||
|
||||
environment:
|
||||
- PORT=8080
|
||||
# 新建微信实例时使用的镜像(多架构,amd64/arm64 自动匹配)
|
||||
- WOC_WECHAT_IMAGE=ghcr.io/gloridust/wechat-on-cloud:${WOC_VERSION:-latest}
|
||||
# 新建微信实例时使用的镜像(多架构,amd64/arm64 自动匹配);前缀同样跟随 WOC_IMAGE_PREFIX。
|
||||
- WOC_WECHAT_IMAGE=${WOC_IMAGE_PREFIX:-ghcr.io/gloridust}/wechat-on-cloud:${WOC_VERSION:-latest}
|
||||
# 透传给每个微信实例容器(KasmVNC 基础镜像用它们降权运行)
|
||||
- PUID=${WOC_PUID:-1000}
|
||||
- PGID=${WOC_PGID:-1000}
|
||||
- TZ=${WOC_TZ:-Asia/Shanghai}
|
||||
# 摄像头直通:逗号分隔的宿主视频设备(如 /dev/video0)。留空则自动探测(见下方 /host-dev 挂载)或禁用摄像头。
|
||||
# 启用前需在宿主加载 v4l2loopback 内核模块。详见 .env.example。
|
||||
- WOC_VIDEO_DEVICES=${WOC_VIDEO_DEVICES:-}
|
||||
# 实例资源相关(见 .env.example):GPU 硬件编码默认关闭(避免 WSL2/虚拟 GPU 下 Xvnc 内存暴涨);
|
||||
# 可选给每个实例设内存上限(GiB),0=不限制;watchdog 双阈值(soft 柔和自愈 / hard 强制重启)+ 巡检间隔。
|
||||
- WOC_ENABLE_GPU=${WOC_ENABLE_GPU:-}
|
||||
- WOC_INSTANCE_MEM_GB=${WOC_INSTANCE_MEM_GB:-}
|
||||
- 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:
|
||||
# 面板账号数据(用户、实例元信息、密码哈希)
|
||||
@@ -28,6 +44,9 @@ services:
|
||||
# 面板经 docker 引擎创建/启动/删除微信实例容器、exec 触发下载、读取进度。
|
||||
# 注意:docker.sock 等同宿主 root 权限,故实例增删仅限管理员,docker API 绝不暴露给前端。
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# 摄像头自动探测(可选):把宿主 /dev 只读挂进来,面板据此发现 /dev/videoN 并映射给实例。
|
||||
# 不想自动探测就删掉此行,改用上面的 WOC_VIDEO_DEVICES 显式指定。
|
||||
- /dev:/host-dev:ro
|
||||
|
||||
ports:
|
||||
- "${WOC_HTTP_PORT:-36080}:8080" # 面板 = 唯一对外入口
|
||||
|
||||
@@ -14,7 +14,8 @@ RUN set -eux; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates locales dpkg \
|
||||
fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \
|
||||
libnss3 libgbm1 libasound2 libxss1; \
|
||||
libnss3 libgbm1 libasound2 libxss1 \
|
||||
xdotool xclip; \
|
||||
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
|
||||
locale-gen; \
|
||||
apt-get clean; \
|
||||
@@ -53,28 +54,32 @@ ENV LANG=zh_CN.UTF-8 \
|
||||
LC_ALL=zh_CN.UTF-8 \
|
||||
LIBGL_ALWAYS_SOFTWARE=1
|
||||
|
||||
# 让 KasmVNC web 客户端默认开启 IME 输入模式:
|
||||
# 用户用本地(客户端)输入法打中文,拼音联想在本地完成、只把成品汉字发进容器,无需容器内装 IME。
|
||||
# 默认值仅在浏览器未存过该设置时生效,不会覆盖用户手动改过的偏好。
|
||||
# 改 KasmVNC web 客户端的 webpack 产物 dist/*.bundle.js:
|
||||
# (1) 默认开启 IME 输入模式:本地(客户端)输入法打中文,拼音联想在本地完成、只把成品汉字
|
||||
# 发进容器,无需容器内装 IME。默认值仅在浏览器未存过该设置时生效,不覆盖用户手动改过的偏好。
|
||||
# (2) 修复 noVNC 中文 IME 输入:原实现靠隐藏 textarea 差分逐字符重发 keysym,会泄漏中间拼音、
|
||||
# 累积不 reset、退格风暴,导致大量丢字 / ~21 字卡住 / 跨浏览器不稳。改为只在 compositionend
|
||||
# 用 e.data 直发成品字符串(详见 woc-www-patch.sh / woc-ime.pl)。
|
||||
# 注意:实际加载的是 webpack 产物 dist/main.bundle.js(app/ui.js 是未打包源码、运行时不加载),故必须改 bundle。
|
||||
# 末尾的 grep 作为断言:若 base 镜像换了打包结构、改不到任何文件,则构建直接失败而非静默放过。
|
||||
RUN set -eux; \
|
||||
patched=0; \
|
||||
for f in /usr/share/kasmvnc/www/dist/*.bundle.js /usr/local/share/kasmvnc/www/dist/*.bundle.js; do \
|
||||
if [ -f "$f" ] && grep -q "initSetting('enable_ime', false)" "$f"; then \
|
||||
sed -i "s/initSetting('enable_ime', false)/initSetting('enable_ime', true)/g" "$f"; \
|
||||
patched=1; \
|
||||
fi; \
|
||||
done; \
|
||||
[ "$patched" = "1" ]
|
||||
COPY woc-www-patch.sh woc-ime.pl /woc/
|
||||
RUN chmod +x /woc/woc-www-patch.sh && /woc/woc-www-patch.sh
|
||||
|
||||
# 微信下载/解压控制脚本(运行时由面板经 docker exec 触发,状态写入数据卷 /config/.woc-state)
|
||||
COPY wechat-ctl.sh /woc/wechat-ctl.sh
|
||||
RUN chmod +x /woc/wechat-ctl.sh
|
||||
|
||||
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信
|
||||
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 + 最小化自动复原看守
|
||||
COPY autostart /defaults/autostart
|
||||
RUN chmod +x /defaults/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
|
||||
|
||||
# 3000 = HTTP web 客户端, 3001 = HTTPS
|
||||
EXPOSE 3000 3001
|
||||
|
||||
@@ -10,6 +10,19 @@ WECHAT_BIN=/config/wechat/opt/wechat/wechat
|
||||
# 容器内无 GPU,强制软件渲染
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
|
||||
# 防“最小化后丢失”:本桌面(openbox)无任务栏,微信被最小化就无处恢复 → 黑屏。
|
||||
# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。
|
||||
(
|
||||
export DISPLAY="${DISPLAY:-:1}"
|
||||
while sleep 2; do
|
||||
all=$(xdotool search --name '.+' 2>/dev/null) || continue
|
||||
vis=$(xdotool search --onlyvisible --name '.+' 2>/dev/null)
|
||||
for w in ${all}; do
|
||||
printf '%s\n' "${vis}" | grep -qx "${w}" || xdotool windowactivate "${w}" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
) &
|
||||
|
||||
# 1) 等待微信安装就绪(首次需在面板点「下载并安装」)
|
||||
notified=0
|
||||
while [ ! -x "${WECHAT_BIN}" ]; do
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/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
|
||||
|
||||
# 设备伪装:把 /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}"
|
||||
@@ -0,0 +1,41 @@
|
||||
# perl -0777 -pe 补丁脚本(被 woc-www-patch.sh 引用)。
|
||||
# 对 dist/*.bundle.js 里 noVNC 键盘 IME 逻辑做两处替换,全程字面匹配(\Q..\E)。
|
||||
#
|
||||
# 背景:noVNC 原实现靠"隐藏 textarea 差分→逐字符重发 keysym"还原 IME 输入,会在合成过程中
|
||||
# 把中间拼音也发给远端、且永不 reset 导致累积+退格风暴 → 大量丢字 / 卡住 / 跨浏览器不稳。
|
||||
#
|
||||
# 改法:彻底不靠 textarea 差分或 VNC keysym 还原中文。
|
||||
# - 合成进行中(input 事件):只同步 _lastKeyboardInput、不发送(避免中间拼音泄漏 / 丢字)。
|
||||
# - 提交时(compositionend):只同步 _lastKeyboardInput 并返回,不再逐字发 keysym。
|
||||
# 成品文本由面板前端捕获后通过 xclip/xdotool 粘贴进远端窗口,绕开 KasmVNC XKB
|
||||
# keysym 容量限制,也避免和粘贴路径重复上屏。
|
||||
# - 若个别浏览器在 compositionend 后还补发一次"提交 input":此时 isComposing/_imeHold 均为假,
|
||||
# 落到非 IME 差分分支,但 newValue 与刚同步的 _lastKeyboardInput 相等 → 差分为空 → 不重复发送。
|
||||
|
||||
# (A) _handleCompositionEnd:提交时只同步 _lastKeyboardInput,文本由面板粘贴路径负责。
|
||||
s~\Q if (this._enableIME) {
|
||||
this._imeInProgress = false;
|
||||
}
|
||||
|
||||
if (isChromiumBased()) {
|
||||
this._imeHold = false;
|
||||
}\E~ if (this._enableIME) { // WOC-IME
|
||||
this._imeInProgress = false;
|
||||
this._imeHold = false;
|
||||
this._lastKeyboardInput = e.target.value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isChromiumBased()) {
|
||||
this._imeHold = false;
|
||||
}~;
|
||||
|
||||
# (B) _handleInput 顶部守卫:合成进行中只同步值、不发送;提交已在 compositionend 完成。
|
||||
s~\Q if (this._enableIME && this._imeHold) {
|
||||
Debug("IME input change, sending differential");\E~ if (this._enableIME && (e.isComposing || this._imeHold || this._imeInProgress)) { // WOC-IME
|
||||
this._lastKeyboardInput = e.target.value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._enableIME && this._imeHold) {
|
||||
Debug("IME input change, sending differential");~;
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
# linuxserver 启动钩子(/custom-cont-init.d,root 身份,每次启动执行)。
|
||||
# 作用:始终用镜像内最新的 /defaults/autostart 覆盖数据卷里的副本。
|
||||
# 原因:baseimage 的 init-kasmvnc-config 只在 /config/.config/openbox/autostart "缺失时"才拷贝,
|
||||
# 导致旧实例(卷里已有旧 autostart)升级镜像后仍跑旧逻辑(如缺少"最小化自动复原"看守)。
|
||||
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
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# 构建期补丁:改 KasmVNC web 客户端的 webpack 产物 dist/*.bundle.js
|
||||
# (1) 默认开启 IME 输入模式(本地输入法打中文,成品汉字发进容器,容器内不装 IME)
|
||||
# (2) 修复 noVNC 的中文 IME 输入:原实现靠"隐藏 textarea 差分→逐字符重发 keysym",
|
||||
# 会在合成过程中把中间拼音也发给远端、且永不 reset 导致累积+退格风暴;
|
||||
# 改为合成期间和提交时都只同步内部 textarea 状态,不再发送中文 keysym。
|
||||
# 最终成品文本由面板前端捕获后通过 xclip/xdotool 粘贴,绕过 KasmVNC XKB keysym 限制。
|
||||
# 末尾断言:若 base 镜像换了打包结构、一个文件都没改到,则构建失败而非静默放过。
|
||||
set -euo pipefail
|
||||
|
||||
PATCH_PL="$(dirname "$0")/woc-ime.pl"
|
||||
patched=0
|
||||
|
||||
for f in /usr/share/kasmvnc/www/dist/*.bundle.js /usr/local/share/kasmvnc/www/dist/*.bundle.js; do
|
||||
[ -f "$f" ] || continue
|
||||
changed=0
|
||||
|
||||
# (1) enable_ime 默认开启
|
||||
if grep -q "initSetting('enable_ime', false)" "$f"; then
|
||||
sed -i "s/initSetting('enable_ime', false)/initSetting('enable_ime', true)/g" "$f"
|
||||
changed=1
|
||||
fi
|
||||
|
||||
# (2) IME 差分逻辑修复(仅含 noVNC 键盘逻辑的 bundle)
|
||||
# 幂等:/usr/share/kasmvnc 是 /usr/local/share/kasmvnc 的软链,两个 glob 会命中同一 inode,
|
||||
# 故已含 _imeJustCommitted 的文件直接跳过,避免重复注入守卫块。
|
||||
if grep -q "IME input change, sending differential" "$f" && ! grep -q "WOC-IME" "$f"; then
|
||||
perl -0777 -i -pe "$(cat "$PATCH_PL")" "$f"
|
||||
after="$(grep -c "WOC-IME" "$f" || true)"
|
||||
# 断言两处替换都命中(compositionend 标记 1 + _handleInput 守卫标记 1 = 2 行)
|
||||
if [ "$after" -ne 2 ]; then
|
||||
echo "FATAL: IME patch mismatch on $f (markers=$after, expect 2)" >&2
|
||||
exit 1
|
||||
fi
|
||||
changed=1
|
||||
fi
|
||||
|
||||
[ "$changed" = "1" ] && { echo "woc-www-patch: patched $f"; patched=1; }
|
||||
done
|
||||
|
||||
[ "$patched" = "1" ] || { echo "FATAL: no bundle patched" >&2; exit 1; }
|
||||
echo "woc-www-patch: done"
|
||||
@@ -1,4 +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';
|
||||
|
||||
@@ -8,6 +10,49 @@ const PGID = process.env.PGID || '1000';
|
||||
const TZ = process.env.TZ || 'Asia/Shanghai';
|
||||
const SHM_SIZE = 1024 * 1024 * 1024; // 1gb
|
||||
|
||||
// 默认关闭 KasmVNC 的 GPU 硬件编码(baseimage 检测到 /dev/dri/renderD* 时会给 Xvnc 加 -hw3d):
|
||||
// 在 WSL2 / 虚拟 GPU 环境下该路径会导致 Xvnc 内存持续膨胀(实测反馈 21h 涨到 ~9GB)。
|
||||
// 我们已设 LIBGL_ALWAYS_SOFTWARE=1 走软件渲染,hw3d 对微信这类静态界面收益甚微。
|
||||
// 真实可用 GPU 想启用硬件编码:面板侧设 WOC_ENABLE_GPU=1。
|
||||
const ENABLE_GPU = process.env.WOC_ENABLE_GPU === '1';
|
||||
|
||||
// 可选:给每个实例容器设内存上限(GiB),作为 Xvnc 等异常增长时的兜底,避免拖垮宿主。
|
||||
// 默认 0 = 不限制(保持原行为)。命中上限时容器内 OOM 杀进程、由 s6 自动重启 VNC。
|
||||
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 到它,便于按容器名互访。
|
||||
@@ -30,14 +75,45 @@ export async function ensureNetwork(): Promise<string | null> {
|
||||
return networkName;
|
||||
}
|
||||
|
||||
// 摄像头直通:把宿主的 v4l2 视频设备映射进实例容器
|
||||
// (浏览器摄像头 → KasmVNC → 容器内 /dev/videoN(v4l2loopback) → 微信)。
|
||||
// 来源优先级:
|
||||
// 1) WOC_VIDEO_DEVICES 显式指定(逗号分隔,如 /dev/video0,/dev/video1)——Ubuntu/无法自动探测时用;
|
||||
// 2) 自动探测:把宿主 /dev 以只读挂到面板的 /host-dev(compose 可选),扫描其中的 videoN。
|
||||
// 一个都找不到则返回空:音频/麦克风不受影响,仅摄像头不可用(优雅降级)。
|
||||
function videoDevices(): string[] {
|
||||
const explicit = (process.env.WOC_VIDEO_DEVICES || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (explicit.length) return explicit;
|
||||
for (const dir of ['/host-dev', '/dev']) {
|
||||
try {
|
||||
if (!existsSync(dir)) continue;
|
||||
const vids = readdirSync(dir)
|
||||
.filter((n) => /^video\d+$/.test(n))
|
||||
.map((n) => `/dev/${n}`); // 宿主侧设备路径
|
||||
if (vids.length) return vids;
|
||||
} catch {
|
||||
/* 无权限/不可读,忽略 */
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function envList(inst: Instance): string[] {
|
||||
return [
|
||||
const env = [
|
||||
`PUID=${PUID}`,
|
||||
`PGID=${PGID}`,
|
||||
`TZ=${TZ}`,
|
||||
`CUSTOM_USER=${inst.kasmUser}`,
|
||||
`PASSWORD=${inst.kasmPassword}`,
|
||||
];
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 确保微信镜像在本地存在;缺失则从 GHCR 拉取(首次新建实例时镜像通常还没拉过)。
|
||||
@@ -62,21 +138,55 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
} catch {
|
||||
/* 不存在,正常 */
|
||||
}
|
||||
const container = await docker.createContainer({
|
||||
// 摄像头设备(探测不到则为空数组 → 仅摄像头不可用,音频/麦克风照常)
|
||||
const vids = videoDevices();
|
||||
const hostConfig: Docker.HostConfig = {
|
||||
Binds: [`${inst.volumeName}:/config`],
|
||||
NetworkMode: net || undefined,
|
||||
SecurityOpt: ['seccomp=unconfined'],
|
||||
ShmSize: SHM_SIZE,
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
};
|
||||
if (INSTANCE_MEM > 0) {
|
||||
hostConfig.Memory = INSTANCE_MEM;
|
||||
hostConfig.MemorySwap = INSTANCE_MEM; // 禁止 swap 膨胀:限制即为硬上限
|
||||
}
|
||||
if (vids.length) {
|
||||
hostConfig.Devices = vids.map((d) => ({ PathOnHost: d, PathInContainer: d, CgroupPermissions: 'rwm' }));
|
||||
hostConfig.GroupAdd = ['video']; // 让容器内 abc 用户能访问 /dev/videoN
|
||||
console.log(`[docker] 实例 ${inst.id} 挂载摄像头设备: ${vids.join(', ')}`);
|
||||
}
|
||||
// 伪装成真实有线网卡 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: {
|
||||
Binds: [`${inst.volumeName}:/config`],
|
||||
NetworkMode: net || undefined,
|
||||
SecurityOpt: ['seccomp=unconfined'],
|
||||
ShmSize: SHM_SIZE,
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
},
|
||||
});
|
||||
await container.start();
|
||||
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) {
|
||||
// 启动失败但容器已被创建出来(Created 状态),不清理的话会成为"幽灵容器"——
|
||||
// 它仍占着卷名 woc-data-<id>,让后续删卷报 409。修复 #23 时发现 4 个此类残留。
|
||||
try {
|
||||
await container.remove({ force: true });
|
||||
} catch {
|
||||
/* 容器已被外部移走或正在被清理,忽略 */
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保实例容器在运行:缺失则按需创建(不会重建已有卷),停止则启动。
|
||||
@@ -90,6 +200,46 @@ export async function ensureRunning(inst: Instance): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 升级实例:拉取最新微信镜像后重建容器(保留数据卷 → 登录态不丢)。
|
||||
// 拉取失败(本地自构建 / 离线 / 仓库不可达)则用本地现有镜像重建,不阻断。
|
||||
export async function upgradeInstance(inst: Instance): Promise<void> {
|
||||
try {
|
||||
await pullImage();
|
||||
} catch (e: any) {
|
||||
console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e);
|
||||
}
|
||||
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 {
|
||||
await docker.getContainer(inst.containerName).stop({ t: 5 } as any);
|
||||
} catch {
|
||||
/* 已停止或不存在 */
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeInstance(inst: Instance, purgeVolume: boolean): Promise<void> {
|
||||
try {
|
||||
const c = docker.getContainer(inst.containerName);
|
||||
@@ -106,6 +256,117 @@ export async function removeInstance(inst: Instance, purgeVolume: boolean): Prom
|
||||
}
|
||||
}
|
||||
|
||||
// 列出"未被任何容器引用的 woc-data-* 数据卷"。判定改为 docker 真实视角(不再仅看 store),
|
||||
// 否则 Created 状态的"幽灵容器"会让卷被误判为孤儿,删除时撞 409(real-world issue:
|
||||
// 早期 runInstance 启动失败漏清残留容器,留下 4 个 Created 容器各占一个卷名)。
|
||||
export async function listOrphanVolumes(referencedVolumes: Set<string>): Promise<
|
||||
Array<{ name: string; createdAt?: string; sizeBytes?: number }>
|
||||
> {
|
||||
// 容器视角:扫所有容器(含已停止 / Created),收集它们挂载的 woc-data-* 卷名
|
||||
const allContainers = await docker.listContainers({ all: true });
|
||||
const containerRefs = new Set<string>();
|
||||
for (const c of allContainers) {
|
||||
for (const m of c.Mounts || []) {
|
||||
if (typeof m.Name === 'string' && m.Name.startsWith('woc-data-')) containerRefs.add(m.Name);
|
||||
}
|
||||
}
|
||||
// 与 store 视角并集:取两者都未引用的卷
|
||||
const referenced = new Set<string>([...referencedVolumes, ...containerRefs]);
|
||||
|
||||
const { Volumes } = (await (docker as any).listVolumes()) || { Volumes: [] };
|
||||
if (!Array.isArray(Volumes)) return [];
|
||||
return Volumes
|
||||
.filter((v: any) => typeof v?.Name === 'string' && v.Name.startsWith('woc-data-') && !referenced.has(v.Name))
|
||||
.map((v: any) => ({
|
||||
name: v.Name,
|
||||
createdAt: v.CreatedAt,
|
||||
// UsageData 仅在 docker engine 启用 -v size=true 时返回,常见情况下没有;缺失就不展示
|
||||
sizeBytes: typeof v?.UsageData?.Size === 'number' && v.UsageData.Size >= 0 ? v.UsageData.Size : undefined,
|
||||
}))
|
||||
.sort((a, b) => (a.createdAt && b.createdAt ? (a.createdAt < b.createdAt ? 1 : -1) : 0));
|
||||
}
|
||||
|
||||
// 显式删除一个数据卷(管理员清理孤儿卷用)。调用方负责确认它不被现存实例引用。
|
||||
export async function removeVolume(name: string): Promise<void> {
|
||||
await docker.getVolume(name).remove({ force: true } as any);
|
||||
}
|
||||
|
||||
// 列出"残留的 woc-wx-* 容器":在 docker 里存在但 store 没登记的(多为 runInstance 失败时
|
||||
// 留下的 Created 状态容器,或用户手动 docker run 出来的)。给管理员一键清理。
|
||||
export async function listOrphanContainers(
|
||||
knownContainerNames: Set<string>,
|
||||
): Promise<Array<{ id: string; name: string; status: string; volumeName?: string }>> {
|
||||
const all = await docker.listContainers({ all: true });
|
||||
const out: Array<{ id: string; name: string; status: string; volumeName?: string }> = [];
|
||||
for (const c of all) {
|
||||
const name = (c.Names || []).map((n) => n.replace(/^\//, '')).find((n) => n.startsWith('woc-wx-'));
|
||||
if (!name) continue;
|
||||
if (knownContainerNames.has(name)) continue;
|
||||
const vol = (c.Mounts || []).map((m) => m.Name).find((n) => typeof n === 'string' && n.startsWith('woc-data-'));
|
||||
out.push({ id: c.Id, name, status: c.Status || c.State || '', volumeName: vol });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 强制删除一个残留容器(按短/全 id 或容器名都行)。
|
||||
export async function removeContainerById(idOrName: string): Promise<void> {
|
||||
await docker.getContainer(idOrName).remove({ force: true });
|
||||
}
|
||||
|
||||
// 取实例容器的"working set"内存(MB):等同 docker stats 显示值 = usage - inactive_file。
|
||||
// 用于 watchdog 检测 KasmVNC/Xvnc 长跑泄漏(21 小时可涨到 ~9 GiB),无法读取时返回 0(视为"暂未知",
|
||||
// 不触发自愈,避免容器刚启动 stats 不可用就被误杀)。一次性 stats、不订阅 stream。
|
||||
export async function instanceMemoryMB(inst: Instance): Promise<number> {
|
||||
try {
|
||||
const c = docker.getContainer(inst.containerName);
|
||||
const s: any = await c.stats({ stream: false } as any);
|
||||
const usage = Number(s?.memory_stats?.usage) || 0;
|
||||
const inactive = Number(
|
||||
s?.memory_stats?.stats?.inactive_file ?? s?.memory_stats?.stats?.total_inactive_file,
|
||||
) || 0;
|
||||
const bytes = Math.max(0, usage - inactive);
|
||||
return Math.round(bytes / 1024 / 1024);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应性健康探测:实测发现容器跑久了会出现 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();
|
||||
@@ -115,7 +376,7 @@ export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
|
||||
}
|
||||
}
|
||||
|
||||
// 在实例容器内执行命令,返回 stdout(demux 后只取标准输出)。
|
||||
// 在实例容器内执行命令,返回 stdout;若命令失败,把 stderr 透出给调用方。
|
||||
async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
|
||||
const c = docker.getContainer(inst.containerName);
|
||||
const exec = await c.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true, Tty: false, User: 'abc' });
|
||||
@@ -126,7 +387,18 @@ async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
|
||||
const stdout = { write: (b: Buffer) => { out += b.toString('utf8'); } } as any;
|
||||
const stderr = { write: (b: Buffer) => { err += b.toString('utf8'); } } as any;
|
||||
docker.modem.demuxStream(stream, stdout, stderr);
|
||||
stream.on('end', () => resolve(out || err));
|
||||
stream.on('end', async () => {
|
||||
try {
|
||||
const info = await exec.inspect();
|
||||
if (info.ExitCode && info.ExitCode !== 0) {
|
||||
reject(new Error((err || out || `命令执行失败,退出码 ${info.ExitCode}`).trim()));
|
||||
return;
|
||||
}
|
||||
resolve(out || err);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -178,6 +450,134 @@ export async function pullImage(onProgress?: (line: any) => void): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 文件中转(上传/下载) ----------
|
||||
// 中转目录 = abc 家目录下的 Desktop(/config 持久卷)。上传落这里,微信文件选择器可直接选到;
|
||||
// 反向:把微信收到的文件另存到桌面,即可在面板里下载。
|
||||
const TRANSFER_DIR = '/config/Desktop';
|
||||
|
||||
// 极简单文件 tar 编码(putArchive 需要 tar;避免引入第三方依赖)。
|
||||
function tarSingleFile(name: string, content: Buffer): Buffer {
|
||||
const h = Buffer.alloc(512, 0);
|
||||
h.write(name.slice(0, 100), 0, 'utf8'); // name
|
||||
h.write('0000644\0', 100); // mode
|
||||
h.write('0001750\0', 108); // uid 1000(octal 1750)
|
||||
h.write('0001750\0', 116); // gid 1000
|
||||
h.write(content.length.toString(8).padStart(11, '0') + '\0', 124); // size
|
||||
h.write('00000000000\0', 136); // mtime
|
||||
h.write(' ', 148); // checksum 占位(8 空格)
|
||||
h.write('0', 156); // typeflag 普通文件
|
||||
h.write('ustar\0', 257);
|
||||
h.write('00', 263);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 512; i++) sum += h[i];
|
||||
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148); // 真实校验和
|
||||
const pad = (512 - (content.length % 512)) % 512;
|
||||
return Buffer.concat([h, content, Buffer.alloc(pad, 0), Buffer.alloc(1024, 0)]);
|
||||
}
|
||||
|
||||
// 校验文件名为安全 basename(防路径穿越)。
|
||||
function safeName(name: string): boolean {
|
||||
return !!name && name.length <= 200 && !name.includes('/') && !name.includes('\0') && name !== '.' && name !== '..';
|
||||
}
|
||||
|
||||
export async function uploadToInstance(inst: Instance, name: string, content: Buffer): Promise<void> {
|
||||
if (!safeName(name)) throw new Error('文件名不合法');
|
||||
await execCapture(inst, ['sh', '-c', `mkdir -p ${TRANSFER_DIR}`]); // abc 家目录可写
|
||||
const c = docker.getContainer(inst.containerName);
|
||||
await c.putArchive(tarSingleFile(name, content), { path: TRANSFER_DIR });
|
||||
}
|
||||
|
||||
export interface TransferFile {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
export async function listInstanceFiles(inst: Instance): Promise<TransferFile[]> {
|
||||
const out = await execCapture(inst, [
|
||||
'sh',
|
||||
'-c',
|
||||
`find ${TRANSFER_DIR} -maxdepth 1 -type f -printf '%f\\t%s\\n' 2>/dev/null`,
|
||||
]);
|
||||
return out
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [name, size] = line.split('\t');
|
||||
return { name, size: Number(size) || 0 };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteInstanceFile(inst: Instance, name: string): Promise<void> {
|
||||
if (!safeName(name)) throw new Error('文件名不合法');
|
||||
// argv 数组直传,不经 shell;safeName 已排除路径穿越
|
||||
await execCapture(inst, ['rm', '-f', `${TRANSFER_DIR}/${name}`]);
|
||||
}
|
||||
|
||||
export async function downloadFromInstance(inst: Instance, name: string): Promise<Buffer> {
|
||||
if (!safeName(name)) throw new Error('文件名不合法');
|
||||
const c = docker.getContainer(inst.containerName);
|
||||
const stream = (await c.getArchive({ path: `${TRANSFER_DIR}/${name}` })) as NodeJS.ReadableStream;
|
||||
const chunks: Buffer[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on('data', (d: Buffer) => chunks.push(d));
|
||||
stream.on('end', () => resolve());
|
||||
stream.on('error', reject);
|
||||
});
|
||||
const tar = Buffer.concat(chunks);
|
||||
// 解析 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 行),供前端"查看/导出日志"排错。
|
||||
export async function instanceLogs(inst: Instance, tail = 600): Promise<string> {
|
||||
const c = docker.getContainer(inst.containerName);
|
||||
const buf = (await c.logs({ stdout: true, stderr: true, tail, timestamps: true })) as unknown as Buffer;
|
||||
// docker 非 TTY 日志为多路复用流:每帧 8 字节头([stream,0,0,0,size BE])+ 负载;解出纯文本。
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i + 8 <= buf.length) {
|
||||
const size = buf.readUInt32BE(i + 4);
|
||||
if (size < 0 || i + 8 + size > buf.length) break;
|
||||
out += buf.subarray(i + 8, i + 8 + size).toString('utf8');
|
||||
i += 8 + size;
|
||||
}
|
||||
return out || buf.toString('utf8'); // 兜底:TTY 模式非多路复用
|
||||
}
|
||||
|
||||
// 通过 xdotool 在实例容器内输入文字(绕过 VNC keysym 限制,解决中文 IME 吞字问题)。
|
||||
// 用 base64 传递文本避免 shell 转义问题,xclip 写入剪贴板后 xdotool 模拟 Ctrl+V 粘贴。
|
||||
export async function typeInInstance(inst: Instance, text: string): Promise<void> {
|
||||
const b64 = Buffer.from(text, 'utf8').toString('base64');
|
||||
const cmd = [
|
||||
'set -e',
|
||||
'display="${DISPLAY:-}"',
|
||||
'if [ -z "$display" ]; then for x in /tmp/.X11-unix/X*; do [ -e "$x" ] || continue; display=":${x##*X}"; break; done; fi',
|
||||
'export DISPLAY="${display:-:1}"',
|
||||
'command -v xclip >/dev/null 2>&1 || { echo "xclip not installed in instance image" >&2; exit 127; }',
|
||||
'command -v xdotool >/dev/null 2>&1 || { echo "xdotool not installed in instance image" >&2; exit 127; }',
|
||||
`echo '${b64}' | base64 -d | xclip -selection clipboard -i`,
|
||||
'xdotool key --clearmodifiers ctrl+v',
|
||||
].join('; ');
|
||||
await execCapture(inst, ['bash', '-c', cmd]);
|
||||
}
|
||||
|
||||
// 实例容器名(供反代构造 target)。
|
||||
export function instanceTarget(inst: Instance): string {
|
||||
return `http://${inst.containerName}:3000`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 反代/CDN(Cloudflare、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;
|
||||
}
|
||||
@@ -20,10 +20,12 @@ import {
|
||||
setUserInstances,
|
||||
listInstances,
|
||||
findInstance,
|
||||
setInstanceMemLimits,
|
||||
userInstances,
|
||||
userCanAccess,
|
||||
createInstance,
|
||||
removeInstance as removeInstanceRecord,
|
||||
renameInstance,
|
||||
setInstanceUsers,
|
||||
publicInstance,
|
||||
type User,
|
||||
@@ -33,13 +35,29 @@ import {
|
||||
ensureNetwork,
|
||||
ensureRunning,
|
||||
runInstance,
|
||||
stopInstance,
|
||||
upgradeInstance,
|
||||
removeInstance as removeInstanceContainer,
|
||||
instanceRuntime,
|
||||
triggerWechat,
|
||||
wechatStatus,
|
||||
instanceTarget,
|
||||
uploadToInstance,
|
||||
listInstanceFiles,
|
||||
downloadFromInstance,
|
||||
deleteInstanceFile,
|
||||
instanceLogs,
|
||||
typeInInstance,
|
||||
listOrphanVolumes,
|
||||
removeVolume,
|
||||
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));
|
||||
|
||||
@@ -47,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');
|
||||
@@ -55,7 +78,26 @@ 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));
|
||||
|
||||
// ---------- 鉴权辅助 ----------
|
||||
function currentUser(req: FastifyRequest): User | null {
|
||||
@@ -218,12 +260,23 @@ app.get('/api/instances', async (req, reply) => {
|
||||
app.post('/api/admin/instances', async (req, reply) => {
|
||||
const admin = requireAdmin(req, reply);
|
||||
if (!admin) return;
|
||||
const { name } = (req.body as any) ?? {};
|
||||
const { name, reuseVolume } = (req.body as any) ?? {};
|
||||
const allowedUserIds = Array.isArray((req.body as any)?.allowedUserIds) ? (req.body as any).allowedUserIds : [];
|
||||
if (!name || String(name).trim().length === 0 || String(name).length > 30) {
|
||||
return reply.code(400).send({ error: '实例名称为 1-30 个字符' });
|
||||
}
|
||||
const inst = createInstance(String(name), admin.id, allowedUserIds);
|
||||
// 复用卷:必须以 woc-data- 开头,且不能被现存实例占用。后端先校验,避免坏名穿透到 docker run。
|
||||
let reuseVolumeName: string | undefined;
|
||||
if (reuseVolume) {
|
||||
if (typeof reuseVolume !== 'string' || !/^woc-data-[0-9a-zA-Z._-]{1,64}$/.test(reuseVolume)) {
|
||||
return reply.code(400).send({ error: '复用卷名不合法' });
|
||||
}
|
||||
if (listInstances().some((i) => i.volumeName === reuseVolume)) {
|
||||
return reply.code(409).send({ error: '该数据卷已被另一个实例占用' });
|
||||
}
|
||||
reuseVolumeName = reuseVolume;
|
||||
}
|
||||
const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName);
|
||||
try {
|
||||
await runInstance(inst);
|
||||
} catch (e: any) {
|
||||
@@ -233,6 +286,132 @@ app.post('/api/admin/instances', async (req, reply) => {
|
||||
return { instance: publicInstance(inst) };
|
||||
});
|
||||
|
||||
// 列出"未被任何实例引用的 woc-data-* 数据卷"。删除实例时默认保留卷(聊天记录),但 panel 里
|
||||
// 看不到这些孤儿卷;本接口让管理员在新建实例时复用旧卷(同微信号扫码可继承聊天记录),
|
||||
// 或在不需要时彻底删除。
|
||||
app.get('/api/admin/orphan-volumes', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const referenced = new Set(listInstances().map((i) => i.volumeName));
|
||||
try {
|
||||
const volumes = await listOrphanVolumes(referenced);
|
||||
return { volumes };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: e?.message || '读取数据卷失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 列出"残留的 woc-wx-* 容器":docker 里存在但 store 没登记。多为 runInstance 启动失败遗留
|
||||
// 的 Created 容器,会占着 woc-data-<id> 卷名让删卷报 409。提供给管理员一键清理。
|
||||
app.get('/api/admin/orphan-containers', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const known = new Set(listInstances().map((i) => i.containerName));
|
||||
try {
|
||||
const containers = await listOrphanContainers(known);
|
||||
return { containers };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: e?.message || '读取容器失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 强制删除一个残留容器。仅当它不在 store 的已知容器集中(防误删正在用的实例)。
|
||||
app.delete('/api/admin/orphan-containers/:idOrName', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const idOrName = (req.params as any).idOrName;
|
||||
if (!idOrName || typeof idOrName !== 'string') return reply.code(400).send({ error: '参数不合法' });
|
||||
if (listInstances().some((i) => i.containerName === idOrName)) {
|
||||
return reply.code(409).send({ error: '该容器属于现存实例,不能在此删除' });
|
||||
}
|
||||
try {
|
||||
await removeContainerById(idOrName);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: e?.message || '删除容器失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 显式删除一个未使用的数据卷。被现存实例占用时拒绝(避免误删聊天记录)。
|
||||
app.delete('/api/admin/orphan-volumes/:name', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const name = (req.params as any).name;
|
||||
if (!name || typeof name !== 'string' || !name.startsWith('woc-data-')) {
|
||||
return reply.code(400).send({ error: '卷名不合法' });
|
||||
}
|
||||
if (listInstances().some((i) => i.volumeName === name)) {
|
||||
return reply.code(409).send({ error: '该数据卷正被某个实例使用,不能删除' });
|
||||
}
|
||||
try {
|
||||
await removeVolume(name);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: e?.message || '删除数据卷失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 查/改单实例的内存安全阀(soft / hard)。前端"实例卡片 → 安全"弹窗用。
|
||||
// GET 返回 per-instance 当前覆盖值 + 全局默认 + 实时内存(用于弹窗里展示)。
|
||||
// PUT 接受 {soft, hard},每项可为正整数 / null(null = 恢复默认)。
|
||||
app.get('/api/admin/instances/:id/mem-limits', 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: '实例不存在' });
|
||||
let currentMB = 0;
|
||||
try {
|
||||
if ((await instanceRuntime(inst)) === 'running') currentMB = await instanceMemoryMB(inst);
|
||||
} catch {
|
||||
/* ignore:未运行时为 0 */
|
||||
}
|
||||
return {
|
||||
soft: inst.memSoftLimitMB ?? null,
|
||||
hard: inst.memHardLimitMB ?? null,
|
||||
defaultSoft: DEFAULT_SOFT_MB,
|
||||
defaultHard: DEFAULT_HARD_MB,
|
||||
currentMB,
|
||||
watchdogEnabled: WATCHDOG_ENABLED,
|
||||
intervalSec: WATCHDOG_INTERVAL_SEC,
|
||||
};
|
||||
});
|
||||
app.put('/api/admin/instances/:id/mem-limits', 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: '实例不存在' });
|
||||
const body = (req.body as any) ?? {};
|
||||
// 允许 number / null;其它类型都视为"未提供"(保持原值)
|
||||
const norm = (v: any): number | null | undefined =>
|
||||
v === null ? null : typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : undefined;
|
||||
const s = norm(body.soft);
|
||||
const h = norm(body.hard);
|
||||
// 取最终生效值(写入前校验)
|
||||
const finalSoft = s === undefined ? inst.memSoftLimitMB ?? null : s;
|
||||
const finalHard = h === undefined ? inst.memHardLimitMB ?? null : h;
|
||||
try {
|
||||
const pub = setInstanceMemLimits(
|
||||
id,
|
||||
finalSoft,
|
||||
finalHard,
|
||||
);
|
||||
return { instance: pub };
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '阈值不合法' });
|
||||
}
|
||||
});
|
||||
|
||||
// 重置实例的设备 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;
|
||||
@@ -242,9 +421,74 @@ app.delete('/api/admin/instances/:id', async (req, reply) => {
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
await removeInstanceContainer(inst, purge);
|
||||
removeInstanceRecord(id);
|
||||
controlHolders.delete(id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// 重命名实例(仅管理员):只改显示名,不动容器/卷。
|
||||
app.post('/api/admin/instances/:id/rename', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const { name } = (req.body as any) ?? {};
|
||||
try {
|
||||
return { instance: renameInstance((req.params as any).id, String(name ?? '')) };
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 启动实例容器(仅管理员):容器停止或被删后,一键拉起(不重建数据卷)。
|
||||
app.post('/api/admin/instances/:id/start', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await ensureRunning(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: '启动失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// 停止实例容器(仅管理员):保留容器与数据卷。
|
||||
app.post('/api/admin/instances/:id/stop', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await stopInstance(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: '停止失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// 重启实例容器(仅管理员):按当前本地镜像重建(保留数据卷 → 登录态不丢;快速,不联网拉取)。
|
||||
app.post('/api/admin/instances/:id/restart', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await runInstance(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: '重启失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// 升级实例(仅管理员):拉取最新微信镜像后重建(保留数据卷)。用于把旧实例更新到新版镜像
|
||||
// (如修复"最小化丢失"等),类似「更新微信」但更新的是实例容器镜像本身。
|
||||
app.post('/api/admin/instances/:id/upgrade', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await upgradeInstance(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: '升级失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// 实例侧:设置该实例可被哪些账户访问
|
||||
app.post('/api/admin/instances/:id/users', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
@@ -258,6 +502,142 @@ app.post('/api/admin/instances/:id/users', async (req, reply) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 文件中转(有访问权限即可用;走面板鉴权,不额外暴露) ----------
|
||||
// 上传:原始二进制直传,落到实例 ~/Desktop,微信文件选择器可直接选到。
|
||||
app.post('/api/instances/:id/upload', { bodyLimit: 512 * 1024 * 1024 }, async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
if (!u) return;
|
||||
const id = (req.params as any).id;
|
||||
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
|
||||
const name = String((req.query as any)?.name || '').trim();
|
||||
const body = req.body as Buffer;
|
||||
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' });
|
||||
try {
|
||||
await uploadToInstance(findInstance(id)!, name, body);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 列出可下载的中转文件
|
||||
app.get('/api/instances/:id/files', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
if (!u) return;
|
||||
const id = (req.params as any).id;
|
||||
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
|
||||
try {
|
||||
return { files: await listInstanceFiles(findInstance(id)!) };
|
||||
} catch {
|
||||
return { files: [] };
|
||||
}
|
||||
});
|
||||
|
||||
// 删除某个中转文件(有访问权限即可)
|
||||
app.delete('/api/instances/:id/files', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
if (!u) return;
|
||||
const id = (req.params as any).id;
|
||||
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
|
||||
const name = String((req.query as any)?.name || '').trim();
|
||||
try {
|
||||
await deleteInstanceFile(findInstance(id)!, name);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '删除失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 下载某个中转文件
|
||||
app.get('/api/instances/:id/download', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
if (!u) return;
|
||||
const id = (req.params as any).id;
|
||||
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
|
||||
const name = String((req.query as any)?.name || '').trim();
|
||||
try {
|
||||
const buf = await downloadFromInstance(findInstance(id)!, name);
|
||||
reply.header('content-type', 'application/octet-stream');
|
||||
reply.header('content-disposition', `attachment; filename*=UTF-8''${encodeURIComponent(name)}`);
|
||||
return reply.send(buf);
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '下载失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 多端协作:操作控制权(心跳软锁,避免多人同时操作打架) ----------
|
||||
// 同一实例被多个浏览器连的是同一会话,键鼠会互相打架。这里用"心跳持锁":
|
||||
// 当前操作者每隔几秒 beat 续约;TTL 内他人只读(前端盖只读遮罩)。空闲超 TTL 自动释放。
|
||||
const CONTROL_TTL = 10_000; // ms:超过则视为已空闲,可被接管
|
||||
const controlHolders = new Map<string, { userId: string; username: string; at: number }>();
|
||||
|
||||
// 续约/认领:无人持有、已超时、或本来就是我 → 我成为操作者;否则返回当前操作者。
|
||||
app.post('/api/instances/:id/control/beat', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
if (!u) return;
|
||||
const id = (req.params as any).id;
|
||||
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
|
||||
const now = Date.now();
|
||||
const h = controlHolders.get(id);
|
||||
if (!h || now - h.at > CONTROL_TTL || h.userId === u.id) {
|
||||
controlHolders.set(id, { userId: u.id, username: u.username, at: now });
|
||||
return { mine: true, holder: u.username };
|
||||
}
|
||||
return { mine: false, holder: h.username };
|
||||
});
|
||||
|
||||
// 只读查询当前操作者(前端轮询;不认领)。超 TTL 视为空闲。
|
||||
app.get('/api/instances/:id/control', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
if (!u) return;
|
||||
const id = (req.params as any).id;
|
||||
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
|
||||
const h = controlHolders.get(id);
|
||||
if (!h || Date.now() - h.at > CONTROL_TTL) return { free: true, mine: false, holder: null };
|
||||
return { free: false, mine: h.userId === u.id, holder: h.username };
|
||||
});
|
||||
|
||||
// 主动接管("申请控制"):强制把操作权抢过来。
|
||||
app.post('/api/instances/:id/control/take', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
if (!u) return;
|
||||
const id = (req.params as any).id;
|
||||
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
|
||||
controlHolders.set(id, { userId: u.id, username: u.username, at: Date.now() });
|
||||
return { mine: true, holder: u.username };
|
||||
});
|
||||
|
||||
// 通过 xdotool 在实例容器内输入文字(绕过 VNC XKB keysym 容量限制,修复中文 IME 吞字)
|
||||
app.post('/api/instances/:id/type', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
if (!u) return;
|
||||
const id = (req.params as any).id;
|
||||
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
|
||||
const { text } = (req.body as any) ?? {};
|
||||
if (!text || typeof text !== 'string' || text.length > 500) return reply.code(400).send({ error: '文字为空或过长' });
|
||||
try {
|
||||
await typeInInstance(findInstance(id)!, text);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: e?.message || '输入失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 查看实例容器日志(仅管理员):排查"无法进入/未安装/卡死"等。inline 文本,浏览器可直接看/另存。
|
||||
app.get('/api/admin/instances/:id/logs', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
const text = await instanceLogs(inst);
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
return reply.send(text || '(暂无日志)');
|
||||
} catch (e: any) {
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
return reply.send('获取日志失败:' + (e?.message || e));
|
||||
}
|
||||
});
|
||||
|
||||
// 该实例的微信安装状态(有访问权限即可看)
|
||||
app.get('/api/instances/:id/wechat/status', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
@@ -376,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();
|
||||
@@ -404,5 +790,109 @@ for (const pub of listInstances()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Watchdog:KasmVNC/Xvnc 长跑会泄漏(实测 24h 可达 ~9 GiB),小内存机器会被拖垮。
|
||||
// 两档阈值,按"是否有人在用"决定时机:
|
||||
// soft:mem >= soft 且当前无活跃会话 → 主动重启(柔和自愈,不打扰)
|
||||
// hard:mem >= hard → 无视会话强制重启(防止 OOM)
|
||||
// 优先级 hard > soft。两档阈值可在面板"管理 → 实例卡片 → 安全"按钮里单实例覆盖;缺省走 env。
|
||||
//
|
||||
// env 默认(可被 per-instance 覆盖):
|
||||
// WOC_INSTANCE_MEM_SOFT_MB soft 阈值;默认 1500
|
||||
// WOC_INSTANCE_MEM_HARD_MB hard 阈值;默认 2500(也兼容旧名 WOC_INSTANCE_MEM_LIMIT_MB)
|
||||
// WOC_WATCHDOG_INTERVAL_SEC 巡检间隔秒;默认 300(5 分钟),最小 60;0 关闭整个 watchdog
|
||||
const DEFAULT_SOFT_MB = Math.max(0, Number(process.env.WOC_INSTANCE_MEM_SOFT_MB ?? 1500));
|
||||
const DEFAULT_HARD_MB = Math.max(
|
||||
0,
|
||||
Number(process.env.WOC_INSTANCE_MEM_HARD_MB ?? process.env.WOC_INSTANCE_MEM_LIMIT_MB ?? 2500),
|
||||
);
|
||||
const WATCHDOG_INTERVAL_SEC = Math.max(60, Number(process.env.WOC_WATCHDOG_INTERVAL_SEC ?? 300));
|
||||
const WATCHDOG_ENABLED = WATCHDOG_INTERVAL_SEC > 0 && (DEFAULT_SOFT_MB > 0 || DEFAULT_HARD_MB > 0);
|
||||
|
||||
// 单实例生效阈值:per-instance 覆盖优先;为 undefined 则用 env 默认。
|
||||
function effectiveLimits(inst: Instance): { soft: number; hard: number } {
|
||||
return {
|
||||
soft: inst.memSoftLimitMB ?? DEFAULT_SOFT_MB,
|
||||
hard: inst.memHardLimitMB ?? DEFAULT_HARD_MB,
|
||||
};
|
||||
}
|
||||
|
||||
// "当前有人在远程会话" 启发式判定:复用控制权心跳。前端在用户鼠标/键盘/滚轮交互时 2.5s 节流 beat,
|
||||
// 故 holder 在 TTL 内即视为"有人在主动操作"。只看屏(不交互)超过 TTL 后会被判为空闲——这是有意的,
|
||||
// 软自愈宁愿在"看似空闲"时短暂打扰,也不要拖到 hard 强制重启。
|
||||
function hasActiveSession(id: string): boolean {
|
||||
const h = controlHolders.get(id);
|
||||
return !!h && Date.now() - h.at <= CONTROL_TTL;
|
||||
}
|
||||
|
||||
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') {
|
||||
healthFails.delete(inst.id);
|
||||
continue;
|
||||
}
|
||||
// 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 但用户在使用,延后`);
|
||||
}
|
||||
}
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
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 · 含响应性探测`,
|
||||
);
|
||||
}
|
||||
|
||||
await app.listen({ port: PORT, host: HOST });
|
||||
console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)`);
|
||||
|
||||
@@ -14,8 +14,17 @@ export interface User {
|
||||
createdAt: string;
|
||||
// 该账户可访问的微信实例 id 列表。admin 隐式全部,忽略此字段。
|
||||
allowedInstances: string[];
|
||||
// 仍在使用初始默认密码时为 true,前端据此提示尽快改密;任意一次改密/重置后清除。
|
||||
mustChangePassword?: boolean;
|
||||
// 离线密码找回:在 accounts.json 手动把某用户置为 true,重启面板即重置其密码并清除此标记。
|
||||
// 兼容下划线写法 reset_password。
|
||||
resetPassword?: boolean;
|
||||
reset_password?: boolean;
|
||||
}
|
||||
|
||||
// 初始默认管理员密码;管理员仍在用它时强烈提示改密。
|
||||
const DEFAULT_ADMIN_PASSWORD = 'wechat';
|
||||
|
||||
export interface Instance {
|
||||
id: string; // 短 id,用于容器/卷命名
|
||||
name: string; // 显示名
|
||||
@@ -25,6 +34,11 @@ export interface Instance {
|
||||
kasmPassword: string;
|
||||
createdAt: string;
|
||||
createdBy: string; // userId
|
||||
// 自愈 watchdog 的"安全阀",per-instance 覆盖全局默认;缺省时使用 env / 内置默认。
|
||||
// soft:内存超此值时,仅在"当前没有用户在远程会话"才主动重启(柔和自愈);
|
||||
// hard:内存超此值时,无论是否有人在会话都重启(防止 OOM 拖垮宿主)。
|
||||
memSoftLimitMB?: number;
|
||||
memHardLimitMB?: number;
|
||||
}
|
||||
|
||||
interface Data {
|
||||
@@ -68,9 +82,32 @@ export function initStore() {
|
||||
}
|
||||
if (!data.users.some((u) => u.role === 'admin')) {
|
||||
const username = process.env.PANEL_ADMIN_USER || 'admin';
|
||||
const password = process.env.PANEL_ADMIN_PASSWORD || 'wechat';
|
||||
data.users.push(makeUser(username, password, 'admin'));
|
||||
const password = process.env.PANEL_ADMIN_PASSWORD || DEFAULT_ADMIN_PASSWORD;
|
||||
const admin = makeUser(username, password, 'admin');
|
||||
// 用默认密码初始化时标记,提醒尽快改密
|
||||
if (password === DEFAULT_ADMIN_PASSWORD) admin.mustChangePassword = true;
|
||||
data.users.push(admin);
|
||||
console.log(`[store] 已初始化管理员账号 '${username}'`);
|
||||
} else {
|
||||
// 兼容旧账号文件:管理员若仍能用默认密码登录,补打"需改密"标记
|
||||
for (const u of data.users) {
|
||||
if (u.role === 'admin' && u.mustChangePassword === undefined) {
|
||||
u.mustChangePassword = bcrypt.compareSync(DEFAULT_ADMIN_PASSWORD, u.passwordHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 离线密码找回:忘记超管密码时,停掉面板 → 在 accounts.json 给该用户加 "resetPassword": true
|
||||
// → 重启面板。这里把其密码重置为 PANEL_ADMIN_PASSWORD(默认 wechat)、解禁,并清除标记。
|
||||
for (const u of data.users) {
|
||||
if ((u as any).resetPassword === true || (u as any).reset_password === true) {
|
||||
const pw = process.env.PANEL_ADMIN_PASSWORD || DEFAULT_ADMIN_PASSWORD;
|
||||
u.passwordHash = bcrypt.hashSync(pw, 10);
|
||||
u.mustChangePassword = pw === DEFAULT_ADMIN_PASSWORD; // 重置成默认密码则提示尽快改密
|
||||
u.disabled = false;
|
||||
delete (u as any).resetPassword;
|
||||
delete (u as any).reset_password;
|
||||
console.log(`[store] 已重置用户 '${u.username}' 的密码(resetPassword 标记,密码=PANEL_ADMIN_PASSWORD 或默认 wechat)`);
|
||||
}
|
||||
}
|
||||
persist();
|
||||
}
|
||||
@@ -84,6 +121,7 @@ export function publicUser(u: User) {
|
||||
disabled: u.disabled,
|
||||
createdAt: u.createdAt,
|
||||
allowedInstances: u.role === 'admin' ? [] : u.allowedInstances,
|
||||
mustChangePassword: !!u.mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +166,7 @@ export function resetPassword(id: string, password: string) {
|
||||
const u = findById(id);
|
||||
if (!u) throw new Error('用户不存在');
|
||||
u.passwordHash = bcrypt.hashSync(password, 10);
|
||||
u.mustChangePassword = false; // 改过密就不再提示
|
||||
persist();
|
||||
return publicUser(u);
|
||||
}
|
||||
@@ -156,7 +195,39 @@ function sanitizeInstanceIds(ids: string[]): string[] {
|
||||
}
|
||||
|
||||
export function publicInstance(i: Instance) {
|
||||
return { id: i.id, name: i.name, createdAt: i.createdAt, createdBy: i.createdBy };
|
||||
return {
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
createdAt: i.createdAt,
|
||||
createdBy: i.createdBy,
|
||||
memSoftLimitMB: i.memSoftLimitMB,
|
||||
memHardLimitMB: i.memHardLimitMB,
|
||||
};
|
||||
}
|
||||
|
||||
// 设置/清除某实例的 mem 安全阀。传 null 表示恢复默认(从对象上删字段)。
|
||||
// 校验:正整数;soft < hard;上限 20480 MiB(20 GiB)。
|
||||
export function setInstanceMemLimits(
|
||||
id: string,
|
||||
softMB: number | null,
|
||||
hardMB: number | null,
|
||||
) {
|
||||
const inst = findInstance(id);
|
||||
if (!inst) throw new Error('实例不存在');
|
||||
const norm = (v: number | null): number | undefined => {
|
||||
if (v == null) return undefined;
|
||||
if (!Number.isFinite(v) || !Number.isInteger(v) || v < 1 || v > 20480) {
|
||||
throw new Error('阈值需为 1-20480 之间的整数(MiB)');
|
||||
}
|
||||
return v;
|
||||
};
|
||||
const s = norm(softMB);
|
||||
const h = norm(hardMB);
|
||||
if (s != null && h != null && s >= h) throw new Error('soft 阈值需小于 hard 阈值');
|
||||
inst.memSoftLimitMB = s;
|
||||
inst.memHardLimitMB = h;
|
||||
persist();
|
||||
return publicInstance(inst);
|
||||
}
|
||||
|
||||
export function listInstances() {
|
||||
@@ -179,13 +250,34 @@ export function userCanAccess(u: User, instanceId: string) {
|
||||
return u.allowedInstances.includes(instanceId) && !!findInstance(instanceId);
|
||||
}
|
||||
|
||||
export function createInstance(name: string, createdBy: string, allowedUserIds: string[] = []) {
|
||||
const id = randomBytes(5).toString('hex'); // 10 hex chars
|
||||
// 复用旧卷时:从 woc-data-<id> 解析回 id,让新实例的 containerName / volumeName 都对齐旧卷的
|
||||
// id(避免出现"卷叫 woc-data-abc,但实例 id 是 def"这种命名错配)。若旧 id 与现存实例冲突或卷名
|
||||
// 非标准前缀,则退回新生成 id,仅卷名指向旧卷。
|
||||
function parseIdFromVolume(volumeName: string): string | null {
|
||||
const m = /^woc-data-([0-9a-f]{10})$/.exec(volumeName);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
export function createInstance(
|
||||
name: string,
|
||||
createdBy: string,
|
||||
allowedUserIds: string[] = [],
|
||||
reuseVolumeName?: string,
|
||||
) {
|
||||
let id = randomBytes(5).toString('hex'); // 10 hex chars
|
||||
let volumeName = `woc-data-${id}`;
|
||||
if (reuseVolumeName) {
|
||||
const reusedId = parseIdFromVolume(reuseVolumeName);
|
||||
if (reusedId && !findInstance(reusedId)) {
|
||||
id = reusedId;
|
||||
}
|
||||
volumeName = reuseVolumeName; // 始终指向旧卷(即便 id 是新生成的)
|
||||
}
|
||||
const inst: Instance = {
|
||||
id,
|
||||
name: name.trim() || `微信-${id.slice(0, 4)}`,
|
||||
containerName: `woc-wx-${id}`,
|
||||
volumeName: `woc-data-${id}`,
|
||||
volumeName,
|
||||
kasmUser: 'woc',
|
||||
// 用 hex(仅 0-9a-f):容器内 init 脚本以 `openssl passwd -apr1 ${PASSWORD}` 未加引号方式生成 .htpasswd,
|
||||
// base64url 可能含前导 '-' 而被 openssl 当作命令行选项,导致密码哈希为空、所有鉴权失败。hex 不含任何 shell 特殊字符。
|
||||
@@ -205,6 +297,16 @@ export function createInstance(name: string, createdBy: string, allowedUserIds:
|
||||
return inst;
|
||||
}
|
||||
|
||||
export function renameInstance(id: string, name: string) {
|
||||
const inst = findInstance(id);
|
||||
if (!inst) throw new Error('实例不存在');
|
||||
const n = (name || '').trim();
|
||||
if (!n || n.length > 30) throw new Error('实例名称为 1-30 个字符');
|
||||
inst.name = n;
|
||||
persist();
|
||||
return publicInstance(inst);
|
||||
}
|
||||
|
||||
export function removeInstance(id: string) {
|
||||
const inst = findInstance(id);
|
||||
if (!inst) throw new Error('实例不存在');
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="23" fill="url(#bg)"/>
|
||||
<rect x="3.5" y="3.5" width="93" height="93" rx="20" fill="none" stroke="#ffffff" stroke-opacity="0.18" stroke-width="2"/>
|
||||
<circle cx="22" cy="36" r="3.4" fill="#ffffff" fill-opacity="0.92"/>
|
||||
<g fill="none" stroke="#ffffff" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M34 30 L48 44 L34 58"/>
|
||||
<path d="M56 60 L72 60"/>
|
||||
<path d="M28 24 L42 38 L28 52"/>
|
||||
<path d="M50 54 L66 54"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 715 B After Width: | Height: | Size: 644 B |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 7.9 KiB |
@@ -1,11 +1,14 @@
|
||||
// 生成 PWA / Apple 图标:微信绿圆角方块(纯前端依赖,无需外部工具)。
|
||||
// 想换成更精致的图标,直接用设计稿替换 public/icon-*.png 即可。
|
||||
// 生成 PWA / Apple 图标:绿底 macOS 终端风格(白色 `>_` 提示符 + 左上小圆点)。
|
||||
// 纯 Node 实现,无需外部工具(rsvg/imagemagick),保证本地 / Docker / CI 构建都产出一致图标。
|
||||
// 与 public/favicon.svg 同一视觉;改图标时两边一起改(坐标基于 100×100 视图)。
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const COLOR = [7, 193, 96]; // #07C160
|
||||
const TOP = [0x13, 0xd8, 0x73]; // 顶部亮绿 #13D873
|
||||
const BOT = [0x05, 0xa8, 0x52]; // 底部深绿 #05A852
|
||||
const WHITE = [255, 255, 255];
|
||||
const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
@@ -33,34 +36,68 @@ function chunk(type, data) {
|
||||
return Buffer.concat([len, typeBuf, data, crc]);
|
||||
}
|
||||
|
||||
// 点到线段距离(用于画带圆角端点的笔画)
|
||||
function distToSeg(px, py, ax, ay, bx, by) {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len2 = dx * dx + dy * dy || 1;
|
||||
let t = ((px - ax) * dx + (py - ay) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const cx = ax + t * dx;
|
||||
const cy = ay + t * dy;
|
||||
return Math.hypot(px - cx, py - cy);
|
||||
}
|
||||
|
||||
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
||||
|
||||
function makePng(size) {
|
||||
const radius = Math.round(size * 0.22);
|
||||
const s = size / 100; // 视图 100 → 像素
|
||||
const radius = 23 * s; // 圆角,与 favicon 一致
|
||||
const stroke = (7.5 / 2) * s; // 笔画半宽
|
||||
// 提示符坐标(基于 100 视图)
|
||||
const chevron = [
|
||||
[28, 24],
|
||||
[42, 38],
|
||||
[28, 52],
|
||||
].map(([x, y]) => [x * s, y * s]);
|
||||
const underline = [
|
||||
[50, 54],
|
||||
[66, 54],
|
||||
].map(([x, y]) => [x * s, y * s]);
|
||||
|
||||
const inRounded = (x, y) => {
|
||||
const r = radius;
|
||||
const cx = x < r ? r : x > size - r ? size - r : x;
|
||||
const cy = y < r ? r : y > size - r ? size - r : y;
|
||||
if (cx === x && cy === y) return 1;
|
||||
const d = Math.hypot(x - cx, y - cy);
|
||||
return clamp01(r - d + 0.5); // 边缘 1px 抗锯齿
|
||||
};
|
||||
|
||||
const rowLen = size * 4 + 1;
|
||||
const raw = Buffer.alloc(rowLen * size);
|
||||
const inRounded = (x, y) => {
|
||||
// 圆角:四角之外的像素透明
|
||||
const corners = [
|
||||
[radius, radius],
|
||||
[size - radius, radius],
|
||||
[radius, size - radius],
|
||||
[size - radius, size - radius],
|
||||
];
|
||||
if ((x < radius || x >= size - radius) && (y < radius || y >= size - radius)) {
|
||||
const cx = x < radius ? corners[0][0] : corners[1][0];
|
||||
const cy = y < radius ? corners[0][1] : corners[2][1];
|
||||
return (x - cx) ** 2 + (y - cy) ** 2 <= radius ** 2;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
for (let y = 0; y < size; y++) {
|
||||
raw[y * rowLen] = 0; // filter type 0
|
||||
const gy = y + 0.5;
|
||||
const mix = gy / size; // 竖直渐变系数
|
||||
const bg = [
|
||||
Math.round(TOP[0] + (BOT[0] - TOP[0]) * mix),
|
||||
Math.round(TOP[1] + (BOT[1] - TOP[1]) * mix),
|
||||
Math.round(TOP[2] + (BOT[2] - TOP[2]) * mix),
|
||||
];
|
||||
for (let x = 0; x < size; x++) {
|
||||
const gx = x + 0.5;
|
||||
// 白色提示符覆盖度(取各形状最大值)
|
||||
let cov = 0;
|
||||
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, chevron[0][0], chevron[0][1], chevron[1][0], chevron[1][1]) + 0.5));
|
||||
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, chevron[1][0], chevron[1][1], chevron[2][0], chevron[2][1]) + 0.5));
|
||||
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, underline[0][0], underline[0][1], underline[1][0], underline[1][1]) + 0.5));
|
||||
|
||||
const o = y * rowLen + 1 + x * 4;
|
||||
const on = inRounded(x, y);
|
||||
raw[o] = COLOR[0];
|
||||
raw[o + 1] = COLOR[1];
|
||||
raw[o + 2] = COLOR[2];
|
||||
raw[o + 3] = on ? 255 : 0;
|
||||
raw[o] = Math.round(bg[0] + (WHITE[0] - bg[0]) * cov);
|
||||
raw[o + 1] = Math.round(bg[1] + (WHITE[1] - bg[1]) * cov);
|
||||
raw[o + 2] = Math.round(bg[2] + (WHITE[2] - bg[2]) * cov);
|
||||
raw[o + 3] = Math.round(255 * inRounded(gx, gy));
|
||||
}
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './auth';
|
||||
import { UIProvider } from './ui';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Admin from './pages/Admin';
|
||||
import Desktop from './pages/Desktop';
|
||||
import AppShell from './AppShell';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
function Splash() {
|
||||
@@ -14,11 +13,10 @@ function Splash() {
|
||||
);
|
||||
}
|
||||
|
||||
function RequireAuth({ children, admin }: { children: ReactNode; admin?: boolean }) {
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <Splash />;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (admin && user.role !== 'admin') return <Navigate to="/" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -27,38 +25,23 @@ function Shell() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Dashboard />
|
||||
<AppShell />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RequireAuth admin>
|
||||
<Admin />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/desktop/:id"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Desktop />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Shell />
|
||||
</AuthProvider>
|
||||
<UIProvider>
|
||||
<AuthProvider>
|
||||
<Shell />
|
||||
</AuthProvider>
|
||||
</UIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from './auth';
|
||||
import { useUI, PasswordInput } from './ui';
|
||||
import { api, type InstanceWithStatus } from './api';
|
||||
import InstanceView from './pages/Desktop';
|
||||
import Admin from './pages/Admin';
|
||||
|
||||
const BUSY = ['downloading', 'extracting', 'installing'];
|
||||
|
||||
// ---- 实例数据:侧栏 / 主页 / 实例视图共享,安装中轮询 ----
|
||||
interface InstancesState {
|
||||
instances: InstanceWithStatus[];
|
||||
loaded: boolean;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
const InstancesCtx = createContext<InstancesState>({ instances: [], loaded: false, reload: async () => {} });
|
||||
export const useInstances = () => useContext(InstancesCtx);
|
||||
|
||||
function useInstancesLoader(): InstancesState {
|
||||
const [instances, setInstances] = useState<InstanceWithStatus[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
const reload = async () => {
|
||||
try {
|
||||
const { instances } = await api.listInstances();
|
||||
setInstances(instances);
|
||||
} catch {
|
||||
/* 401 会被 api 层重定向到登录 */
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
reload();
|
||||
return () => window.clearTimeout(timer.current);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
if (instances.some((i) => BUSY.includes(i.wechat.phase))) timer.current = window.setTimeout(reload, 1500);
|
||||
return () => window.clearTimeout(timer.current);
|
||||
}, [instances]);
|
||||
return { instances, loaded, reload };
|
||||
}
|
||||
|
||||
// 实例状态点(颜色 + 文案)
|
||||
export function statusOf(inst: InstanceWithStatus): { cls: string; text: string } {
|
||||
const offline = inst.runtime !== 'running';
|
||||
if (offline) return { cls: 'st-off', text: inst.runtime === 'missing' ? '未创建' : '已停止' };
|
||||
if (BUSY.includes(inst.wechat.phase)) return { cls: 'st-busy', text: '处理中' };
|
||||
if (inst.wechat.installed) return { cls: 'st-on', text: '在线' };
|
||||
return { cls: 'st-warn', text: '待安装' };
|
||||
}
|
||||
|
||||
// ---- 图标 ----
|
||||
const Icon = {
|
||||
home: (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 10.5 12 3l9 7.5" /><path d="M5 9.5V20h14V9.5" /><path d="M9.5 20v-6h5v6" />
|
||||
</svg>
|
||||
),
|
||||
gear: (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3.2" /><path d="M19.4 13a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-2.7-1.1l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0-1.1-2.7H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.1-2.7l-.1-.1A2 2 0 1 1 6.9 4.5l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z" />
|
||||
</svg>
|
||||
),
|
||||
logout: (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="M16 17l5-5-5-5" /><path d="M21 12H9" />
|
||||
</svg>
|
||||
),
|
||||
collapse: (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2.5" /><path d="M9 4v16" />
|
||||
</svg>
|
||||
),
|
||||
menu: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function AppShell() {
|
||||
const state = useInstancesLoader();
|
||||
const { refresh } = useAuth();
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('woc_sb_collapsed') === '1');
|
||||
const [drawer, setDrawer] = useState(false);
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 768px)').matches);
|
||||
const loc = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const m = window.matchMedia('(min-width: 768px)');
|
||||
const h = () => setIsDesktop(m.matches);
|
||||
m.addEventListener('change', h);
|
||||
return () => m.removeEventListener('change', h);
|
||||
}, []);
|
||||
|
||||
useEffect(() => setDrawer(false), [loc.pathname]); // 路由变化关抽屉
|
||||
|
||||
// 路由切换时刷新共享实例列表:管理页用的是独立列表,新建/安装实例后不会动到这个共享 context,
|
||||
// 否则进入实例页 / 回主页都读到陈旧列表(实例缺失),需手动刷新整页才出现。导航即拉一次最新即可。
|
||||
// 不清空旧数据,拉取期间沿用旧列表,无闪烁。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => void state.reload(), [loc.pathname]);
|
||||
|
||||
// 移动端不收成窄栏(改用抽屉);折叠仅桌面生效
|
||||
const railed = collapsed && isDesktop;
|
||||
|
||||
const toggleCollapsed = () =>
|
||||
setCollapsed((c) => {
|
||||
localStorage.setItem('woc_sb_collapsed', c ? '0' : '1');
|
||||
return !c;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') {
|
||||
e.preventDefault();
|
||||
toggleCollapsed();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const openMenu = () => setDrawer(true);
|
||||
const openChangePassword = () => setShowPw(true);
|
||||
|
||||
return (
|
||||
<InstancesCtx.Provider value={state}>
|
||||
<div className={'shell' + (railed ? ' collapsed' : '') + (drawer ? ' drawer-open' : '')}>
|
||||
<Sidebar collapsed={railed} onToggleCollapsed={toggleCollapsed} />
|
||||
<div className="shell-backdrop" onClick={() => setDrawer(false)} />
|
||||
<main className="workspace">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeView onOpenMenu={openMenu} onChangePassword={openChangePassword} />} />
|
||||
<Route path="/admin" element={<Admin onOpenMenu={openMenu} onChangePassword={openChangePassword} />} />
|
||||
<Route path="/i/:id" element={<InstanceView onOpenMenu={openMenu} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
{showPw && <ChangePassword onClose={() => setShowPw(false)} onSaved={() => refresh()} />}
|
||||
</InstancesCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggleCollapsed: () => void }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { confirm } = useUI();
|
||||
const { instances } = useInstances();
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const go = (p: string) => nav(p);
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sb-top">
|
||||
<div className="sb-brand">
|
||||
<img src="/favicon.svg" className="sb-logo" alt="" />
|
||||
{!collapsed && <span className="sb-name">云微</span>}
|
||||
</div>
|
||||
<button className="sb-collapse" title="折叠侧栏 (⌘B)" onClick={onToggleCollapsed}>
|
||||
{Icon.collapse}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="sb-nav">
|
||||
<button className={'sb-item' + (loc.pathname === '/' ? ' on' : '')} onClick={() => go('/')} title="主页">
|
||||
<span className="sb-ic">{Icon.home}</span>
|
||||
{!collapsed && <span className="sb-label">主页</span>}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{!collapsed && <div className="sb-section">微信实例</div>}
|
||||
<div className="sb-list">
|
||||
{instances.length === 0 && !collapsed && <div className="sb-empty">暂无可用实例</div>}
|
||||
{instances.map((inst) => {
|
||||
const on = loc.pathname === `/i/${inst.id}`;
|
||||
const st = statusOf(inst);
|
||||
return (
|
||||
<button key={inst.id} className={'sb-item sb-inst' + (on ? ' on' : '')} onClick={() => go(`/i/${inst.id}`)} title={inst.name}>
|
||||
<span className="sb-avatar">
|
||||
{inst.name.slice(0, 1)}
|
||||
<span className={'sb-dot ' + st.cls} />
|
||||
</span>
|
||||
{!collapsed && <span className="sb-label">{inst.name}</span>}
|
||||
{!collapsed && <span className="sb-stxt">{st.text}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="sb-footer">
|
||||
<button className={'sb-item' + (loc.pathname === '/admin' ? ' on' : '')} onClick={() => go('/admin')} title={isAdmin ? '管理' : '设置'}>
|
||||
<span className="sb-ic">{Icon.gear}</span>
|
||||
{!collapsed && <span className="sb-label">{isAdmin ? '管理' : '设置'}</span>}
|
||||
</button>
|
||||
<button
|
||||
className="sb-item"
|
||||
title="退出"
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '退出登录?', confirmText: '退出' })) logout();
|
||||
}}
|
||||
>
|
||||
<span className="sb-ic">{Icon.logout}</span>
|
||||
{!collapsed && <span className="sb-label">退出</span>}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="sb-user">
|
||||
{user?.username}
|
||||
{isAdmin && ' · 管理员'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; onChangePassword: () => void }) {
|
||||
const { user } = useAuth();
|
||||
const { instances, loaded } = useInstances();
|
||||
const nav = useNavigate();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<div className="ws-page">
|
||||
<header className="ws-head">
|
||||
<button className="ws-menu" onClick={onOpenMenu} aria-label="菜单">
|
||||
{Icon.menu}
|
||||
</button>
|
||||
<span className="ws-title">主页</span>
|
||||
</header>
|
||||
|
||||
<div className="content">
|
||||
<div className="hello">
|
||||
你好,<b>{user?.username}</b>
|
||||
{isAdmin && <span className="tag">管理员</span>}
|
||||
</div>
|
||||
|
||||
{user?.mustChangePassword && (
|
||||
<button className="warn-banner" onClick={onChangePassword}>
|
||||
<span className="warn-icon">!</span>
|
||||
<span className="warn-text">
|
||||
<b>你还在使用默认密码</b>
|
||||
<span>该系统登录着你的微信,请立即修改密码 ›</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="section-row">
|
||||
<span className="section-title">我的微信实例</span>
|
||||
{isAdmin && (
|
||||
<button className="btn-text" onClick={() => nav('/admin')}>
|
||||
管理 ›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loaded && instances.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-blob">
|
||||
<img src="/favicon.svg" alt="" />
|
||||
</div>
|
||||
<div className="empty-title">还没有微信实例</div>
|
||||
<div className="empty-sub">{isAdmin ? '去「管理」新建一个微信实例' : '请联系管理员为你分配实例'}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inst-grid">
|
||||
{instances.map((inst) => {
|
||||
const st = statusOf(inst);
|
||||
const meta = inst.wechat.installed
|
||||
? `微信 ${inst.wechat.version || ''}`.trim()
|
||||
: inst.runtime === 'running'
|
||||
? '待下载安装微信'
|
||||
: '';
|
||||
return (
|
||||
<button key={inst.id} className="home-card" onClick={() => nav(`/i/${inst.id}`)}>
|
||||
<span className="home-card-av">{inst.name.slice(0, 1)}</span>
|
||||
<span className="home-card-main">
|
||||
<span className="home-card-name">{inst.name}</span>
|
||||
<span className="home-card-meta">
|
||||
<span className={'home-card-st ' + st.cls}>● {st.text}</span>
|
||||
{meta && <span className="home-card-ver">{meta}</span>}
|
||||
</span>
|
||||
</span>
|
||||
<span className="enter-arrow">›</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangePassword({ onClose, onSaved }: { onClose: () => void; onSaved?: () => void }) {
|
||||
const [oldPassword, setOld] = useState('');
|
||||
const [newPassword, setNew] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const mismatch = confirm.length > 0 && newPassword !== confirm;
|
||||
const canSubmit = !busy && !!oldPassword && newPassword.length >= 6 && newPassword === confirm;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMsg('');
|
||||
if (newPassword !== confirm) {
|
||||
setMsg('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.changePassword(oldPassword, newPassword);
|
||||
setMsg('修改成功');
|
||||
onSaved?.();
|
||||
setTimeout(onClose, 800);
|
||||
} catch (e: any) {
|
||||
setMsg(e.message || '修改失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h2>修改密码</h2>
|
||||
<PasswordInput placeholder="原密码" autoComplete="current-password" value={oldPassword} onChange={setOld} />
|
||||
<PasswordInput placeholder="新密码(至少 6 位)" autoComplete="new-password" value={newPassword} onChange={setNew} />
|
||||
<PasswordInput placeholder="再次输入新密码" autoComplete="new-password" value={confirm} onChange={setConfirm} />
|
||||
{mismatch && <div className="error">两次输入的新密码不一致</div>}
|
||||
{msg && <div className={msg === '修改成功' ? 'ok' : 'error'}>{msg}</div>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-primary" disabled={!canSubmit}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export interface PanelUser {
|
||||
disabled: boolean;
|
||||
createdAt: string;
|
||||
allowedInstances: string[]; // admin 为空数组(隐式全部)
|
||||
mustChangePassword?: boolean; // 仍在用默认密码时为 true
|
||||
}
|
||||
|
||||
export type WechatPhase = 'idle' | 'downloading' | 'extracting' | 'installing' | 'done' | 'error';
|
||||
@@ -23,6 +24,17 @@ export interface PanelInstance {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
memSoftLimitMB?: number;
|
||||
memHardLimitMB?: number;
|
||||
}
|
||||
export interface MemLimits {
|
||||
soft: number | null;
|
||||
hard: number | null;
|
||||
defaultSoft: number;
|
||||
defaultHard: number;
|
||||
currentMB: number;
|
||||
watchdogEnabled: boolean;
|
||||
intervalSec: number;
|
||||
}
|
||||
export interface InstanceWithStatus extends PanelInstance {
|
||||
runtime: RuntimeState;
|
||||
@@ -38,7 +50,14 @@ async function req<T = any>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
headers,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as any).error || `请求失败 (${res.status})`);
|
||||
if (!res.ok) {
|
||||
// 会话过期:除登录/探测接口外,任意接口收到 401 都说明 cookie 失效,直接回登录页(避免页面卡在错误态)
|
||||
const isAuthProbe = path.includes('/api/auth/login') || path.includes('/api/auth/me');
|
||||
if (res.status === 401 && !isAuthProbe && location.pathname !== '/login') {
|
||||
location.assign('/login');
|
||||
}
|
||||
throw new Error((data as any).error || `请求失败 (${res.status})`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
@@ -67,11 +86,30 @@ export const api = {
|
||||
|
||||
// 微信实例
|
||||
listInstances: () => req<{ instances: InstanceWithStatus[] }>('/api/instances'),
|
||||
createInstance: (name: string, allowedUserIds: string[] = []) =>
|
||||
createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string) =>
|
||||
req<{ instance: PanelInstance }>('/api/admin/instances', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, allowedUserIds }),
|
||||
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) =>
|
||||
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/mem-limits`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ soft, hard }),
|
||||
}),
|
||||
listOrphanVolumes: () =>
|
||||
req<{ volumes: { name: string; createdAt?: string; sizeBytes?: number }[] }>('/api/admin/orphan-volumes'),
|
||||
deleteOrphanVolume: (name: string) =>
|
||||
req(`/api/admin/orphan-volumes/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
listOrphanContainers: () =>
|
||||
req<{ containers: { id: string; name: string; status: string; volumeName?: string }[] }>('/api/admin/orphan-containers'),
|
||||
deleteOrphanContainer: (idOrName: string) =>
|
||||
req(`/api/admin/orphan-containers/${encodeURIComponent(idOrName)}`, { method: 'DELETE' }),
|
||||
renameInstance: (id: string, name: string) =>
|
||||
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/rename`, { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
deleteInstance: (id: string, purge = false) =>
|
||||
req(`/api/admin/instances/${id}${purge ? '?purge=1' : ''}`, { method: 'DELETE' }),
|
||||
setInstanceUsers: (id: string, userIds: string[]) =>
|
||||
@@ -79,4 +117,30 @@ export const api = {
|
||||
instanceWechatStatus: (id: string) => req<{ status: WechatStatus }>(`/api/instances/${id}/wechat/status`),
|
||||
instanceWechatInstall: (id: string) => req(`/api/admin/instances/${id}/wechat/install`, { method: 'POST' }),
|
||||
instanceWechatUpdate: (id: string) => req(`/api/admin/instances/${id}/wechat/update`, { method: 'POST' }),
|
||||
instanceStart: (id: string) => req(`/api/admin/instances/${id}/start`, { method: 'POST' }),
|
||||
instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }),
|
||||
instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }),
|
||||
instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }),
|
||||
instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`,
|
||||
|
||||
// 文件中转
|
||||
listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`),
|
||||
uploadFile: async (id: string, file: File) => {
|
||||
const res = await fetch(`/api/instances/${id}/upload?name=${encodeURIComponent(file.name)}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
body: file,
|
||||
});
|
||||
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as any).error || '上传失败');
|
||||
return res.json();
|
||||
},
|
||||
downloadFileUrl: (id: string, name: string) => `/api/instances/${id}/download?name=${encodeURIComponent(name)}`,
|
||||
deleteFile: (id: string, name: string) => req(`/api/instances/${id}/files?name=${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
|
||||
// 多端协作:操作控制权
|
||||
controlStatus: (id: string) => req<{ free: boolean; mine: boolean; holder: string | null }>(`/api/instances/${id}/control`),
|
||||
controlBeat: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/beat`, { method: 'POST' }),
|
||||
controlTake: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/take`, { method: 'POST' }),
|
||||
typeInInstance: (id: string, text: string) => req(`/api/instances/${id}/type`, { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,221 +1,2 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth';
|
||||
import { api, type InstanceWithStatus } from '../api';
|
||||
|
||||
const BUSY_PHASES = ['downloading', 'extracting', 'installing'];
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, logout } = useAuth();
|
||||
const nav = useNavigate();
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [instances, setInstances] = useState<InstanceWithStatus[] | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const { instances } = await api.listInstances();
|
||||
setInstances(instances);
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => window.clearTimeout(timer.current);
|
||||
}, []);
|
||||
|
||||
// 任一实例安装/更新进行中时轮询
|
||||
useEffect(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
const busy = instances?.some((i) => BUSY_PHASES.includes(i.wechat.phase));
|
||||
if (busy) timer.current = window.setTimeout(load, 1500);
|
||||
return () => window.clearTimeout(timer.current);
|
||||
}, [instances]);
|
||||
|
||||
const trigger = async (inst: InstanceWithStatus, kind: 'install' | 'update') => {
|
||||
setErr('');
|
||||
try {
|
||||
await (kind === 'install' ? api.instanceWechatInstall(inst.id) : api.instanceWechatUpdate(inst.id));
|
||||
setInstances(
|
||||
(list) =>
|
||||
list?.map((i) =>
|
||||
i.id === inst.id ? { ...i, wechat: { ...i.wechat, phase: 'downloading', percent: -1, message: '正在准备…' } } : i,
|
||||
) ?? list,
|
||||
);
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(load, 1000);
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="topbar">
|
||||
<span className="topbar-title">云微</span>
|
||||
<button className="btn-text" onClick={() => logout()}>
|
||||
退出
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="content">
|
||||
<div className="hello">
|
||||
你好,<b>{user?.username}</b>
|
||||
{isAdmin && <span className="tag">管理员</span>}
|
||||
</div>
|
||||
|
||||
{err && <div className="error">{err}</div>}
|
||||
|
||||
<div className="section-row">
|
||||
<span className="section-title">微信实例</span>
|
||||
{isAdmin && (
|
||||
<button className="btn-text" onClick={() => nav('/admin')}>
|
||||
管理 ›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{instances && instances.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-blob">📱</div>
|
||||
<div className="empty-title">还没有微信实例</div>
|
||||
<div className="empty-sub">{isAdmin ? '去「管理」新建一个微信实例' : '请联系管理员为你分配实例'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inst-grid">
|
||||
{instances?.map((inst) => (
|
||||
<InstanceCard key={inst.id} inst={inst} isAdmin={isAdmin} onEnter={() => nav(`/desktop/${inst.id}`)} onTrigger={trigger} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="list">
|
||||
<button className="list-item" onClick={() => setShowPw(true)}>
|
||||
<span>修改密码</span>
|
||||
<span className="enter-arrow">›</span>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button className="list-item" onClick={() => nav('/admin')}>
|
||||
<span>实例与子账号管理</span>
|
||||
<span className="enter-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showPw && <ChangePassword onClose={() => setShowPw(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstanceCard({
|
||||
inst,
|
||||
isAdmin,
|
||||
onEnter,
|
||||
onTrigger,
|
||||
}: {
|
||||
inst: InstanceWithStatus;
|
||||
isAdmin?: boolean;
|
||||
onEnter: () => void;
|
||||
onTrigger: (inst: InstanceWithStatus, kind: 'install' | 'update') => void;
|
||||
}) {
|
||||
const wx = inst.wechat;
|
||||
const busy = BUSY_PHASES.includes(wx.phase);
|
||||
const installed = wx.installed && wx.phase !== 'downloading';
|
||||
const offline = inst.runtime !== 'running';
|
||||
|
||||
let badge: { text: string; cls: string };
|
||||
if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' };
|
||||
else if (busy) badge = { text: '处理中', cls: 'tag-busy' };
|
||||
else if (installed) badge = { text: '在线', cls: 'tag-on' };
|
||||
else badge = { text: '待安装', cls: 'tag-warn' };
|
||||
|
||||
let sub: string;
|
||||
if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…';
|
||||
else if (wx.phase === 'error') sub = wx.message || '操作失败,可重试';
|
||||
else if (installed) sub = wx.version ? `微信 ${wx.version}` : '微信已安装';
|
||||
else sub = '微信尚未安装';
|
||||
|
||||
const canEnter = !offline && installed && !busy;
|
||||
|
||||
return (
|
||||
<div className="inst-card">
|
||||
<div className="inst-head">
|
||||
<span className="inst-name">{inst.name}</span>
|
||||
<span className={'tag ' + badge.cls}>{badge.text}</span>
|
||||
</div>
|
||||
<div className="inst-sub">{sub}</div>
|
||||
|
||||
{busy && (
|
||||
<div className="wx-progress">
|
||||
<div
|
||||
className={'wx-progress-bar' + (wx.percent < 0 ? ' indeterminate' : '')}
|
||||
style={wx.percent >= 0 ? { width: `${wx.percent}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inst-actions">
|
||||
<button className="btn btn-primary inst-enter" disabled={!canEnter} onClick={onEnter}>
|
||||
进入微信
|
||||
</button>
|
||||
{isAdmin && !busy && !offline && (
|
||||
installed ? (
|
||||
<button className="btn inst-act" onClick={() => onTrigger(inst, 'update')}>
|
||||
更新
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn inst-act" onClick={() => onTrigger(inst, 'install')}>
|
||||
下载安装
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangePassword({ onClose }: { onClose: () => void }) {
|
||||
const [oldPassword, setOld] = useState('');
|
||||
const [newPassword, setNew] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMsg('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.changePassword(oldPassword, newPassword);
|
||||
setMsg('修改成功');
|
||||
setTimeout(onClose, 800);
|
||||
} catch (e: any) {
|
||||
setMsg(e.message || '修改失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h2>修改密码</h2>
|
||||
<input className="input" type="password" placeholder="原密码" value={oldPassword} onChange={(e) => setOld(e.target.value)} />
|
||||
<input className="input" type="password" placeholder="新密码(至少 6 位)" value={newPassword} onChange={(e) => setNew(e.target.value)} />
|
||||
{msg && <div className={msg === '修改成功' ? 'ok' : 'error'}>{msg}</div>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-primary" disabled={busy || !oldPassword || !newPassword}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 已废弃:主页与改密已迁移到 ../AppShell.tsx(HomeView / ChangePassword)。
|
||||
export {};
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import { useUI } from '../ui';
|
||||
import { useAuth } from '../auth';
|
||||
import { useInstances } from '../AppShell';
|
||||
import { VncAudio } from '../vncAudio';
|
||||
|
||||
// 直接加载 KasmVNC 的 noVNC 页面(由 kclient 静态托管)。
|
||||
// 反代按实例隔离:所有桌面流量走 /desktop/<id>/*,网关据 <id> 选目标容器并注入该实例凭据。
|
||||
// path=desktop/<id>/websockify:让 noVNC 把 ws 连到该实例路径,网关剥前缀反代回 KasmVNC 根 /websockify。
|
||||
// KasmVNC noVNC 页面;反代按实例隔离:/desktop/<id>/* → 对应容器,注入凭据。
|
||||
function desktopUrl(id: string) {
|
||||
return (
|
||||
`/desktop/${id}/vnc/index.html?autoconnect=1&path=desktop/${id}/websockify&resize=remote` +
|
||||
@@ -10,19 +14,708 @@ function desktopUrl(id: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Desktop() {
|
||||
const nav = useNavigate();
|
||||
interface TFile {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
function humanSize(n: number) {
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
|
||||
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
|
||||
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
const MenuIcon = (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void }) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const nav = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { toast, confirm } = useUI();
|
||||
const { instances, loaded, reload } = useInstances();
|
||||
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);
|
||||
const [vncNonce, setVncNonce] = useState(0);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const dragDepth = useRef(0);
|
||||
const lastBeat = useRef(0);
|
||||
const audioRef = useRef<VncAudio | null>(null);
|
||||
|
||||
const inst = instances.find((i) => i.id === id);
|
||||
// 进入实例时,共享列表可能尚未同步(管理页新建/安装后),先按"探测中"显示加载态,
|
||||
// 等列表刷新到该实例或超时后再判定是否真的不存在,避免从管理页跳转时误报"实例不存在"。
|
||||
const [probing, setProbing] = useState(true);
|
||||
const offline = inst ? inst.runtime !== 'running' : false;
|
||||
const installed = !!inst && inst.wechat.installed && inst.wechat.phase !== 'downloading';
|
||||
const showVnc = !!inst && !offline && installed;
|
||||
|
||||
// 切换实例时重置内嵌态
|
||||
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) {
|
||||
setProbing(false);
|
||||
return;
|
||||
}
|
||||
if (!probing) return;
|
||||
const t = window.setTimeout(() => setProbing(false), 2500);
|
||||
return () => window.clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inst, probing, id]);
|
||||
|
||||
// 实例未就绪(启动中 / 安装中 / 上下文状态未刷新)时,每 3s 拉取最新状态:
|
||||
// 就绪后自动进入桌面,无需手动刷新(修复"安装完进度 100% 仍提示无实例")。
|
||||
useEffect(() => {
|
||||
if (showVnc || !id) return;
|
||||
const t = window.setInterval(() => {
|
||||
if (!document.hidden) reload();
|
||||
}, 3000);
|
||||
return () => window.clearInterval(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showVnc, id]);
|
||||
|
||||
// 文件拖到窗口 → 弹出落区(覆盖 iframe 接住 drop)
|
||||
useEffect(() => {
|
||||
if (!showVnc) return;
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes('Files');
|
||||
const onEnter = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current++;
|
||||
setDragging(true);
|
||||
};
|
||||
const onOver = (e: DragEvent) => hasFiles(e) && e.preventDefault();
|
||||
const onLeave = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDragging(false);
|
||||
};
|
||||
const onDropWin = (e: DragEvent) => {
|
||||
if (hasFiles(e)) e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDragging(false);
|
||||
};
|
||||
window.addEventListener('dragenter', onEnter);
|
||||
window.addEventListener('dragover', onOver);
|
||||
window.addEventListener('dragleave', onLeave);
|
||||
window.addEventListener('drop', onDropWin);
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', onEnter);
|
||||
window.removeEventListener('dragover', onOver);
|
||||
window.removeEventListener('dragleave', onLeave);
|
||||
window.removeEventListener('drop', onDropWin);
|
||||
};
|
||||
}, [showVnc]);
|
||||
|
||||
// 控制权(交互驱动的心跳软锁):每 3s 只读轮询当前操作者;超 TTL 自动释放。
|
||||
useEffect(() => {
|
||||
if (!showVnc || !id) {
|
||||
setControl(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
const poll = async () => {
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
const r = await api.controlStatus(id);
|
||||
if (!alive) return;
|
||||
setControl(r);
|
||||
if (!r.free && !r.mine) frameRef.current?.blur(); // 只读:移开键盘焦点
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
poll();
|
||||
const t = window.setInterval(poll, 3000);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(t);
|
||||
};
|
||||
}, [showVnc, id]);
|
||||
|
||||
// 用户在 VNC 内真实操作(鼠标/键盘/滚轮)时续约控制权(同源 iframe 可监听)。节流 2.5s。
|
||||
// 只读用户的操作已被遮罩拦截/失焦,不会误续约;空闲不操作则超时自动释放。
|
||||
useEffect(() => {
|
||||
if (!showVnc || !id || !frameLoaded) return;
|
||||
const win = frameRef.current?.contentWindow;
|
||||
if (!win) return;
|
||||
const onInteract = async () => {
|
||||
const now = Date.now();
|
||||
if (now - lastBeat.current < 2500) return;
|
||||
lastBeat.current = now;
|
||||
try {
|
||||
const r = await api.controlBeat(id);
|
||||
setControl({ free: false, mine: r.mine, holder: r.holder });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
const evs = ['mousedown', 'keydown', 'wheel'] as const;
|
||||
try {
|
||||
evs.forEach((e) => win.addEventListener(e, onInteract, { capture: true, passive: true }));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
evs.forEach((e) => win.removeEventListener(e, onInteract, { capture: true } as any));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, [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;
|
||||
}
|
||||
|
||||
const refreshFiles = async () => {
|
||||
try {
|
||||
const { files } = await api.listFiles(id);
|
||||
setFiles(files);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (list: FileList | File[]) => {
|
||||
const arr = Array.from(list);
|
||||
if (!arr.length) return;
|
||||
setUploading(true);
|
||||
let ok = 0;
|
||||
for (const f of arr) {
|
||||
try {
|
||||
await api.uploadFile(id, f);
|
||||
ok++;
|
||||
} catch (e: any) {
|
||||
toast(`${f.name}: ${e.message || '上传失败'}`, 'error');
|
||||
}
|
||||
}
|
||||
setUploading(false);
|
||||
if (ok) {
|
||||
toast(`已上传 ${ok} 个文件到桌面,微信里可直接选取`, 'ok');
|
||||
refreshFiles();
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
dragDepth.current = 0;
|
||||
if (e.dataTransfer.files?.length) uploadFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const delFile = async (name: string) => {
|
||||
if (!(await confirm({ title: `删除「${name}」?`, body: '将从微信桌面(~/Desktop)移除该文件。', danger: true, confirmText: '删除' }))) return;
|
||||
try {
|
||||
await api.deleteFile(id, name);
|
||||
toast('已删除', 'ok');
|
||||
refreshFiles();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '删除失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 同源 iframe:把键盘焦点交给 VNC,帮助宿主机输入法把合成的字送进去
|
||||
const focusFrame = () => {
|
||||
try {
|
||||
frameRef.current?.focus();
|
||||
frameRef.current?.contentWindow?.focus();
|
||||
const ki = frameRef.current?.contentDocument?.getElementById('noVNC_keyboardinput') as HTMLElement | null;
|
||||
ki?.focus();
|
||||
} catch {
|
||||
/* 跨域兜底(正常同源不会到这) */
|
||||
}
|
||||
};
|
||||
|
||||
// 桌面加载后给 noVNC 原生控制条注入"实心可见"样式:原生背景近纯黑半透明,叠在深色/黑屏上看不见。
|
||||
// 注入后,用 KasmVNC 自带的左侧边缘手柄拉出控制条(音频/剪贴板/键盘/全屏等)时即可见。iframe 同源可直接访问。
|
||||
const injectVncStyle = () => {
|
||||
try {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
if (!doc || doc.getElementById('woc-vnc-style')) return;
|
||||
const st = doc.createElement('style');
|
||||
st.id = 'woc-vnc-style';
|
||||
st.textContent =
|
||||
'#noVNC_control_bar_anchor{z-index:2147483647!important;}' +
|
||||
'#noVNC_control_bar{background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.55)!important;box-shadow:0 0 24px rgba(0,0,0,.55)!important;}' +
|
||||
'#noVNC_control_bar_handle{opacity:1!important;background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.5)!important;}' +
|
||||
// macOS 中文输入法需要目标元素有非零尺寸才能激活;KasmVNC 默认 0x0 导致无法切换输入法
|
||||
'#noVNC_keyboardinput{width:1px!important;height:1px!important;opacity:0!important;overflow:hidden!important;}';
|
||||
(doc.head || doc.documentElement).appendChild(st);
|
||||
} catch {
|
||||
/* 同源正常不会到这 */
|
||||
}
|
||||
};
|
||||
|
||||
// 跨设备剪贴板(文本):通过同源 iframe 直接喂给 KasmVNC 自带的剪贴板 textarea 并触发其发送逻辑
|
||||
// (内部走 RFB.clipboardPasteFrom → clientCutText)。不依赖浏览器异步剪贴板 API,故 http/局域网 IP 下也可用,
|
||||
// 规避了"非安全上下文禁用 navigator.clipboard 导致粘贴失败"的问题。文本会进入容器系统剪贴板,
|
||||
// 在微信输入框按 Ctrl+V 即可粘贴。
|
||||
const pushClipboardToRemote = (text: string): boolean => {
|
||||
try {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
const ta = doc?.getElementById('noVNC_clipboard_text') as HTMLTextAreaElement | null;
|
||||
if (!doc || !ta) return false;
|
||||
ta.value = text;
|
||||
ta.dispatchEvent(new (frameRef.current!.contentWindow as any).Event('change', { bubbles: true }));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const sendClip = () => {
|
||||
const t = clipText;
|
||||
if (!t) {
|
||||
toast('请先输入要发送的文本', 'error');
|
||||
return;
|
||||
}
|
||||
if (pushClipboardToRemote(t)) {
|
||||
toast('已发送到容器剪贴板,请在微信输入框按 Ctrl+V 粘贴', 'ok');
|
||||
} else {
|
||||
toast('发送失败:桌面尚未连接', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 中文输入条发送:把本框文本经 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 {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
const ta = doc?.getElementById('noVNC_clipboard_text') as HTMLTextAreaElement | null;
|
||||
if (ta) {
|
||||
setClipText(ta.value || '');
|
||||
toast('已读取容器剪贴板', 'ok');
|
||||
} else {
|
||||
toast('读取失败:桌面尚未连接', 'error');
|
||||
}
|
||||
} catch {
|
||||
toast('读取失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const restartInstance = async () => {
|
||||
const ok = await confirm({
|
||||
title: '重启该实例?',
|
||||
body: '会重建容器(聊天记录保留),微信重新启动,约十几秒;用于修复卡死/最小化丢失等。',
|
||||
confirmText: '重启',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.instanceRestart(id);
|
||||
toast('已重启,正在重连…', 'ok');
|
||||
setFrameLoaded(false);
|
||||
setVncNonce((n) => n + 1); // 强制 iframe 重挂、重连
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '重启失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const takeControl = async () => {
|
||||
try {
|
||||
const r = await api.controlTake(id);
|
||||
setControl({ free: false, mine: r.mine, holder: r.holder });
|
||||
lastBeat.current = Date.now();
|
||||
focusFrame();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '接管失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
await api.instanceStart(id);
|
||||
toast('实例已启动', 'ok');
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '启动失败', 'error');
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = inst?.name || '微信实例';
|
||||
|
||||
return (
|
||||
<div className="desktop-wrap">
|
||||
<iframe className="desktop-frame" src={desktopUrl(id)} title="电脑版微信" allow="clipboard-read; clipboard-write" />
|
||||
<button className="desktop-back" onClick={() => nav('/')} title="返回">
|
||||
‹
|
||||
</button>
|
||||
<div className="ws-page">
|
||||
<header className="ws-head">
|
||||
<button className="ws-menu" onClick={onOpenMenu} aria-label="菜单">
|
||||
{MenuIcon}
|
||||
</button>
|
||||
<span className="ws-title">{title}</span>
|
||||
{showVnc && (
|
||||
<>
|
||||
<button
|
||||
className="ws-action"
|
||||
title="文件传输"
|
||||
onClick={() => {
|
||||
setShowFiles((v) => !v);
|
||||
if (!showFiles) refreshFiles();
|
||||
}}
|
||||
>
|
||||
文件
|
||||
</button>
|
||||
<button
|
||||
className={'ws-action' + (imeBar ? ' on' : '')}
|
||||
title="底部中文输入条:用本机输入法打中文,回车送进微信(最可靠)"
|
||||
onClick={() => setImeBar((v) => !v)}
|
||||
>
|
||||
中文输入
|
||||
</button>
|
||||
<button
|
||||
className="ws-action"
|
||||
title="把文本发送到容器剪贴板(局域网 http 下也可用)"
|
||||
onClick={() => setShowClip((v) => !v)}
|
||||
>
|
||||
剪贴板
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button className="ws-action" title="重启实例(修复卡死/最小化丢失)" onClick={restartInstance}>
|
||||
重启
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* —— 各种态 —— */}
|
||||
{!loaded || (probing && !inst) ? (
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : !inst ? (
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="iv-notice">
|
||||
<div className="iv-notice-title">无权访问或实例不存在</div>
|
||||
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/')}>
|
||||
返回主页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : offline ? (
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="iv-notice">
|
||||
<div className="iv-notice-title">{inst.runtime === 'missing' ? '容器尚未创建' : '实例已停止'}</div>
|
||||
{isAdmin ? (
|
||||
<button className="btn btn-primary iv-notice-btn" disabled={starting} onClick={start}>
|
||||
{starting ? '启动中…' : inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="iv-notice-sub">请联系管理员启动该实例</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
|
||||
查看日志
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : ['downloading', 'extracting', 'installing'].includes(inst.wechat.phase) ? (
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="iv-notice">
|
||||
<div className="spinner" />
|
||||
<div className="iv-notice-title">微信安装中…</div>
|
||||
<div className="iv-notice-sub">
|
||||
{inst.wechat.message || '请稍候'}
|
||||
{inst.wechat.percent >= 0 ? ` · ${inst.wechat.percent}%` : ''} ——完成后自动进入,无需刷新
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !installed ? (
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="iv-notice">
|
||||
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? '微信安装出错' : '微信尚未安装'}</div>
|
||||
<div className="iv-notice-sub">
|
||||
{inst.wechat.phase === 'error'
|
||||
? inst.wechat.message || '安装失败,可在「管理」重试'
|
||||
: '该实例容器已就绪,但尚未安装微信'}
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/admin')}>
|
||||
去「管理」{inst.wechat.phase === 'error' ? '重试 / 更新' : '下载安装'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="iv-notice-sub">请联系管理员在「管理」中下载安装微信</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
|
||||
查看日志
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="iv-stage iv-stage--vnc">
|
||||
<div className="iv-canvas">
|
||||
<iframe
|
||||
key={`${id}:${vncNonce}`}
|
||||
ref={frameRef}
|
||||
className="iv-frame"
|
||||
src={desktopUrl(id)}
|
||||
title="电脑版微信"
|
||||
allow="clipboard-read; clipboard-write; microphone; camera; autoplay"
|
||||
onLoad={() => {
|
||||
setFrameLoaded(true);
|
||||
setTimeout(() => {
|
||||
focusFrame(); // 加载完把键盘焦点交给 VNC
|
||||
injectVncStyle(); // 让原生控制条在深色背景下可见
|
||||
// 注意:不再调用 patchVncIme —— enable_ime 已关,直接打字走纯 keysym(英文/数字正常);
|
||||
// 中文由底部「中文输入条」承担。那套合成拦截既脆弱又会损坏混合输入,已弃用。
|
||||
}, 500);
|
||||
}}
|
||||
/>
|
||||
|
||||
{!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>
|
||||
{!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">
|
||||
<div className="drop-icon">⬇</div>
|
||||
<div className="drop-title">松开上传到微信桌面</div>
|
||||
<div className="drop-sub">上传后在微信里「+ / 文件」选择即可</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{control && !control.free && !control.mine && (
|
||||
<div className="iv-lock">
|
||||
<div className="iv-lock-card">
|
||||
<div className="iv-lock-title">「{control.holder}」正在操作</div>
|
||||
<div className="iv-lock-sub">为避免多端互相干扰,你当前为只读模式。</div>
|
||||
<button className="btn btn-primary iv-notice-btn" onClick={takeControl}>
|
||||
申请控制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFiles && (
|
||||
<div className="iv-files">
|
||||
<div className="files-head">
|
||||
<span>文件传输</span>
|
||||
<button className="btn-text" onClick={() => setShowFiles(false)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) uploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary files-upload" disabled={uploading} onClick={() => fileInput.current?.click()}>
|
||||
{uploading ? '上传中…' : '+ 选择文件上传'}
|
||||
</button>
|
||||
<div className="files-hint">也可直接把文件拖进来。下方为桌面(~/Desktop)里的文件,微信收到的文件另存到桌面即可在此下载。</div>
|
||||
<div className="files-list">
|
||||
{files.length === 0 && (
|
||||
<div className="muted small" style={{ padding: '10px 2px' }}>
|
||||
暂无文件
|
||||
</div>
|
||||
)}
|
||||
{files.map((f) => (
|
||||
<div key={f.name} className="files-item">
|
||||
<a className="files-dl" href={api.downloadFileUrl(id, f.name)} download={f.name} title="下载">
|
||||
<span className="files-name">{f.name}</span>
|
||||
<span className="files-size">{humanSize(f.size)} ↓</span>
|
||||
</a>
|
||||
<button className="files-del" title="删除" onClick={() => delFile(f.name)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showClip && (
|
||||
<div className="iv-files">
|
||||
<div className="files-head">
|
||||
<span>文本剪贴板</span>
|
||||
<button className="btn-text" onClick={() => setShowClip(false)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="clip-area"
|
||||
value={clipText}
|
||||
onChange={(e) => setClipText(e.target.value)}
|
||||
placeholder="在此输入或粘贴文本,点「发送到微信」后到微信输入框按 Ctrl+V 粘贴"
|
||||
rows={5}
|
||||
/>
|
||||
<button className="btn btn-primary files-upload" onClick={sendClip}>
|
||||
发送到微信(容器剪贴板)
|
||||
</button>
|
||||
<button className="btn-text" style={{ alignSelf: 'flex-start', marginTop: 6 }} onClick={pullClipboardFromRemote}>
|
||||
↓ 读取容器剪贴板到此框
|
||||
</button>
|
||||
<div className="files-hint">
|
||||
局域网 http 访问时浏览器会禁用系统级剪贴板同步,故用此框中转:文本→容器剪贴板,再在微信里 Ctrl+V。
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth';
|
||||
import { PasswordInput } from '../ui';
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
@@ -25,33 +26,33 @@ export default function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="center-screen">
|
||||
<form className="card login-card" onSubmit={submit}>
|
||||
<div className="brand">
|
||||
<div className="brand-logo"><img src="/favicon.svg" alt="" /></div>
|
||||
<h1>云微</h1>
|
||||
<p className="muted">登录以访问 NAS 上的微信</p>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="用户名"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<button className="btn btn-primary" disabled={busy || !username || !password}>
|
||||
{busy ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="center-screen login-screen">
|
||||
<div className="login-wrap">
|
||||
<form className="card login-card" onSubmit={submit}>
|
||||
<div className="brand">
|
||||
<div className="brand-logo">
|
||||
<img src="/favicon.svg" alt="" />
|
||||
</div>
|
||||
<h1>云微</h1>
|
||||
<p className="muted">登录以访问 NAS 上的微信</p>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="用户名"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<PasswordInput placeholder="密码" autoComplete="current-password" value={password} onChange={setPassword} />
|
||||
{err && <div className="error">{err}</div>}
|
||||
<button className="btn btn-primary" disabled={busy || !username || !password}>
|
||||
{busy ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="login-foot">服务端微信 · 多端共享 · 建议仅在内网 / 可信网络访问</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
// ── Toast ───────────────────────────────────────────────
|
||||
type ToastKind = 'ok' | 'error' | 'info';
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
text: string;
|
||||
kind: ToastKind;
|
||||
}
|
||||
|
||||
// ── Confirm ─────────────────────────────────────────────
|
||||
interface ConfirmOpts {
|
||||
title: string;
|
||||
body?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface UICtx {
|
||||
toast: (text: string, kind?: ToastKind) => void;
|
||||
confirm: (opts: ConfirmOpts) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<UICtx>(null!);
|
||||
|
||||
export function UIProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const [confirmState, setConfirmState] = useState<(ConfirmOpts & { resolve: (v: boolean) => void }) | null>(null);
|
||||
const seq = useRef(0);
|
||||
|
||||
const toast = useCallback((text: string, kind: ToastKind = 'info') => {
|
||||
const id = ++seq.current;
|
||||
setToasts((list) => [...list, { id, text, kind }]);
|
||||
setTimeout(() => setToasts((list) => list.filter((t) => t.id !== id)), 2600);
|
||||
}, []);
|
||||
|
||||
const confirm = useCallback(
|
||||
(opts: ConfirmOpts) => new Promise<boolean>((resolve) => setConfirmState({ ...opts, resolve })),
|
||||
[],
|
||||
);
|
||||
|
||||
const close = (v: boolean) => {
|
||||
confirmState?.resolve(v);
|
||||
setConfirmState(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ toast, confirm }}>
|
||||
{children}
|
||||
<div className="toast-stack">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={'toast toast-' + t.kind}>
|
||||
{t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{confirmState && (
|
||||
<div className="modal-mask" onClick={() => close(false)}>
|
||||
<div className="card modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 340 }}>
|
||||
<h2>{confirmState.title}</h2>
|
||||
{confirmState.body && <div className="muted" style={{ fontSize: 14, lineHeight: 1.5 }}>{confirmState.body}</div>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={() => close(false)}>
|
||||
{confirmState.cancelText || '取消'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'btn ' + (confirmState.danger ? 'btn-danger' : 'btn-primary')}
|
||||
onClick={() => close(true)}
|
||||
>
|
||||
{confirmState.confirmText || '确定'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useUI = () => useContext(Ctx);
|
||||
|
||||
// ── 带"显示/隐藏"切换的密码输入框 ────────────────────────
|
||||
export function PasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<div className="pw-field">
|
||||
<input
|
||||
className="input"
|
||||
type={show ? 'text' : 'password'}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="pw-toggle"
|
||||
tabIndex={-1}
|
||||
aria-label={show ? '隐藏密码' : '显示密码'}
|
||||
onClick={() => setShow((s) => !s)}
|
||||
>
|
||||
{show ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20C5 20 1 12 1 12a18.5 18.5 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19M1 1l22 22" />
|
||||
<path d="M9.5 9.5a3 3 0 0 0 4.24 4.24" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ export default defineConfig({
|
||||
workbox: {
|
||||
// 桌面反代与 API 不能被 SW 拦截
|
||||
navigateFallbackDenylist: [/^\/desktop/, /^\/api/],
|
||||
// 新版本立即接管 + 清理旧缓存,避免更新后仍跑旧代码(硬刷新绕不过 SW)
|
||||
clientsClaim: true,
|
||||
skipWaiting: true,
|
||||
cleanupOutdatedCaches: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||