mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
Compare commits
7 Commits
+57
-5
@@ -8,12 +8,14 @@ WOC_PASSWORD=wechat
|
||||
# 同时作用于面板镜像和新建微信实例所用镜像。
|
||||
WOC_VERSION=latest
|
||||
|
||||
# 镜像源前缀(registry + 命名空间)。默认 GHCR 官方。
|
||||
# 中国大陆网络拉 ghcr.io 常 TLS 超时,可改用 GHCR 反代镜像(前提:GHCR 包已设为 Public):
|
||||
# WOC_IMAGE_PREFIX=ghcr.nju.edu.cn/gloridust # 南京大学镜像站反代 ghcr.io(较稳定)
|
||||
# 镜像源前缀(registry + 命名空间)。本项目镜像同步发布到 GHCR 和 Docker Hub。
|
||||
#
|
||||
# 默认走 GHCR;如果 ghcr.io 拉不动,按需切到其它源:
|
||||
# WOC_IMAGE_PREFIX=docker.io/gloridust # Docker Hub(国际网络通用、无需 PAT 即可拉公开镜像)
|
||||
# WOC_IMAGE_PREFIX=ghcr.nju.edu.cn/gloridust # 南京大学镜像站反代 ghcr.io(国内较稳)
|
||||
# WOC_IMAGE_PREFIX=registry.cn-hangzhou.aliyuncs.com/你的命名空间 # 自己的阿里云 ACR / 腾讯 TCR / 华为 SWR
|
||||
#
|
||||
# 反代会把 ghcr.io/gloridust/xxx 映射为 <反代>/gloridust/xxx。
|
||||
# 若用自己的国内仓库(阿里云 ACR / 腾讯 TCR / 华为 SWR),填该仓库的完整前缀即可,例如:
|
||||
# WOC_IMAGE_PREFIX=registry.cn-hangzhou.aliyuncs.com/你的命名空间
|
||||
WOC_IMAGE_PREFIX=ghcr.io/gloridust
|
||||
|
||||
# 宿主用户 uid/gid(飞牛上用 `id` 命令查看;单用户 NAS 一般是 1000)。
|
||||
@@ -28,6 +30,25 @@ WOC_TZ=Asia/Shanghai
|
||||
# 面板是唯一对外入口;微信实例不直接对宿主暴露,由面板反向代理。
|
||||
WOC_HTTP_PORT=36080
|
||||
|
||||
# 面板允许的 Host 头白名单。用于阻止 DNS rebinding:攻击者控制的域名解析到面板
|
||||
# LAN/loopback 地址后,浏览器视其为同源、自动带上面板 cookie,能从受害者浏览器内
|
||||
# 发起任何已鉴权 API 调用(含挂载 docker.sock 的管理端点);sameSite=lax 不挡此攻击。
|
||||
#
|
||||
# 默认始终放行:localhost / 127.0.0.1 / ::1 + RFC1918 私网(10/8、172.16-31/12、
|
||||
# 192.168/16、169.254/16)—— 覆盖直连 NAS / 局域网访问场景,**不需要改任何东西**。
|
||||
#
|
||||
# 套 HTTPS 反代部署(Caddy / nginx / 飞牛 内置反代 / Cloudflare Tunnel 等)时,把对外
|
||||
# 域名加进来。**支持多个域名,用英文逗号分隔**;填免端口主机名,大小写不敏感:
|
||||
#
|
||||
# 单域名: PANEL_ALLOWED_HOSTS=woc.example.com
|
||||
# 多域名: PANEL_ALLOWED_HOSTS=woc.example.com,woc.lan,wechat.mynas.cn
|
||||
# 含 IPv6 字面量:PANEL_ALLOWED_HOSTS=[2001:db8::1]
|
||||
#
|
||||
# 排错:若反代部署后浏览器看到 400 "Host header not allowed",多半是反代未透传
|
||||
# 客户端 Host(nginx 默认透传;Caddy v2 默认透传;某些环境会改写)—— 要么修反代
|
||||
# 配置让 Host 头透传,要么把反代后端看到的内部域名加进本白名单。
|
||||
PANEL_ALLOWED_HOSTS=
|
||||
|
||||
# ── 音频 / 麦克风 / 摄像头 ───────────────────────────────────
|
||||
# 音频(听):开箱即用,进入桌面后点 KasmVNC 左侧工具条的扬声器开启。
|
||||
# 麦克风(说) / 摄像头(视频):浏览器要求"安全上下文",即必须通过 HTTPS 访问面板
|
||||
@@ -43,3 +64,34 @@ WOC_HTTP_PORT=36080
|
||||
# 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=
|
||||
|
||||
# ── 自愈 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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
+22
-2
@@ -1,8 +1,8 @@
|
||||
# 发布到 GHCR
|
||||
# 发布镜像
|
||||
|
||||
> 返回 [← README](../README.md)
|
||||
|
||||
把两个镜像(`woc-panel`、`wechat-on-cloud`)发布到 GitHub Container Registry,供他人 `docker compose up -d` 直接拉取。两种方式任选其一。
|
||||
把两个镜像(`woc-panel`、`wechat-on-cloud`)发布到容器仓库,供他人 `docker compose up -d` 直接拉取。默认走 GHCR;可选同步推到 Docker Hub。
|
||||
|
||||
---
|
||||
|
||||
@@ -19,6 +19,26 @@ gh release create v1.0.0 --title v1.0.0 --notes "..."
|
||||
|
||||
> 注意:单纯 push tag 只产出 `X.Y.Z / X.Y / X`,**不会更新 `latest`**;要更新 `latest` 请改用 **发布 Release** 或在 Actions 里手动 `workflow_dispatch`。
|
||||
|
||||
### 可选:同步推到 Docker Hub
|
||||
|
||||
GHCR 拉取在国际网络下偶尔被 TLS / DNS 干扰;Docker Hub 覆盖面更广,且公开镜像 `docker pull` 无需先 `docker login`。workflow 已内置 Docker Hub 双推开关:**配齐两个变量即可启用,没配就保持只推 GHCR**(向后兼容)。
|
||||
|
||||
**一次性配置**(GitHub repo → Settings → Secrets and variables → Actions):
|
||||
|
||||
| 类型 | Name | Value |
|
||||
|---|---|---|
|
||||
| Variable | `DOCKERHUB_USERNAME` | 你的 Docker Hub 用户名(如 `gloridust`) |
|
||||
| Secret | `DOCKERHUB_TOKEN` | Docker Hub Access Token([hub.docker.com → Account Settings → Personal access tokens](https://hub.docker.com/settings/personal-access-tokens) → New Access Token,权限选 `Read & Write`) |
|
||||
|
||||
> Variable 和 Secret 是两个不同的 tab;用户名放 Variable 即可(不敏感),Token 必须放 Secret。
|
||||
> 在 Docker Hub 上**预先建好两个 public repo**:`<用户名>/woc-panel`、`<用户名>/wechat-on-cloud`(hub.docker.com → Create Repository),否则首次推送会失败(Docker Hub 不会自动建 repo)。
|
||||
|
||||
配齐后,下次发版(或 `workflow_dispatch` 手动触发)就会同时推到:
|
||||
- `ghcr.io/<github-owner>/woc-panel:X.Y.Z`
|
||||
- `docker.io/<dockerhub-user>/woc-panel:X.Y.Z`
|
||||
|
||||
使用者在 `.env` 里 `WOC_IMAGE_PREFIX=docker.io/<dockerhub-user>` 即可从 Docker Hub 拉。
|
||||
|
||||
---
|
||||
|
||||
## 方式 B · 本机 buildx 手动构建并推送(不走 Actions)
|
||||
|
||||
@@ -21,10 +21,20 @@ services:
|
||||
# 摄像头直通:逗号分隔的宿主视频设备(如 /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}
|
||||
# 面板首个管理员账号(仅首次启动、无账号文件时写入;务必改掉默认密码)
|
||||
- PANEL_ADMIN_USER=${WOC_USER:-admin}
|
||||
- PANEL_ADMIN_PASSWORD=${WOC_PASSWORD:-wechat}
|
||||
- PANEL_DATA=/data/accounts.json
|
||||
# DNS-rebinding 防护:套 HTTPS 反代部署时把对外域名加进 .env(详见 .env.example)。
|
||||
# 默认仅放行 loopback + RFC1918 私网,直连 NAS / 局域网无需改动。
|
||||
- PANEL_ALLOWED_HOSTS=${PANEL_ALLOWED_HOSTS:-}
|
||||
|
||||
volumes:
|
||||
# 面板账号数据(用户、实例元信息、密码哈希)
|
||||
|
||||
+6
-1
@@ -72,7 +72,12 @@ RUN chmod +x /woc/wechat-ctl.sh
|
||||
COPY autostart /defaults/autostart
|
||||
RUN chmod +x /defaults/autostart
|
||||
|
||||
# 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本(否则旧实例升级后用不上新逻辑)
|
||||
# 启动钩子(00):给每个实例唯一且持久的 machine-id,避免所有实例共用镜像里烤死的同一个,
|
||||
# 触发腾讯"设备农场"风控导致登录即被强制退出。须在 autostart(拉起微信)之前执行,故用 00 前缀。
|
||||
COPY woc-identity.sh /custom-cont-init.d/00-woc-identity
|
||||
RUN chmod +x /custom-cont-init.d/00-woc-identity
|
||||
|
||||
# 启动钩子(01):每次启动用镜像内最新 autostart 覆盖数据卷旧副本(否则旧实例升级后用不上新逻辑)
|
||||
COPY woc-update-autostart /custom-cont-init.d/01-woc-autostart
|
||||
RUN chmod +x /custom-cont-init.d/01-woc-autostart
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
# linuxserver 启动钩子(/custom-cont-init.d,root 身份,每次启动、在各服务起来之前执行)。
|
||||
#
|
||||
# 目的:给每个实例一个【唯一且持久】的设备身份,避免所有实例共用镜像里烤死的同一个 machine-id。
|
||||
#
|
||||
# 背景(P0):Debian/基础镜像把 machine-id 固化在镜像层里,于是全世界每个 wechat-on-cloud
|
||||
# 实例的 /etc/machine-id 都相同。machine-id 是 Linux 上最接近"设备指纹"的标识,微信会读它做
|
||||
# 风控;成千上万个账号共用同一个 machine-id = 典型"设备农场"特征 → 被腾讯批量判风险 → 登录即
|
||||
# 被强制退出、反复循环。
|
||||
#
|
||||
# 解法:
|
||||
# 1) 在数据卷(/config,随实例持久)里存一个本实例专属的随机 machine-id;
|
||||
# 2) 每次启动把它写回 /etc/machine-id 与 /var/lib/dbus/machine-id。
|
||||
# 这样:实例之间互不相同(破掉设备农场特征),且重启 / 升级 / 重建容器都保持不变("设备老在变"
|
||||
# 同样可疑)。仅在卷里尚无该文件时才生成,故老实例首启会拿到一个新的唯一 id(之后恒定)。
|
||||
set -e
|
||||
|
||||
ID_FILE=/config/.woc-machine-id
|
||||
|
||||
# 生成 32 位小写十六进制(systemd machine-id 格式):优先用内核 uuid 源,去掉连字符。
|
||||
if [ ! -s "$ID_FILE" ]; then
|
||||
if [ -r /proc/sys/kernel/random/uuid ]; then
|
||||
tr -d '-' < /proc/sys/kernel/random/uuid | tr 'A-F' 'a-f' > "$ID_FILE"
|
||||
else
|
||||
head -c16 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$ID_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
MID="$(tr -dc 'a-f0-9' < "$ID_FILE" | head -c 32)"
|
||||
# 兜底:若文件内容异常(长度不足 32),重新生成
|
||||
if [ "${#MID}" -ne 32 ]; then
|
||||
MID="$(tr -d '-' < /proc/sys/kernel/random/uuid | tr 'A-F' 'a-f' | head -c 32)"
|
||||
printf '%s\n' "$MID" > "$ID_FILE"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$MID" > /etc/machine-id 2>/dev/null || true
|
||||
mkdir -p /var/lib/dbus
|
||||
printf '%s\n' "$MID" > /var/lib/dbus/machine-id 2>/dev/null || true
|
||||
|
||||
# 抹掉最明显的容器标记(微信可能据此判定非真实桌面)。/.dockerenv 由 docker 注入,删掉无副作用。
|
||||
rm -f /.dockerenv 2>/dev/null || true
|
||||
|
||||
echo "[woc-identity] machine-id 已设为本实例专属(持久化于数据卷)"
|
||||
+126
-2
@@ -9,6 +9,17 @@ 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;
|
||||
|
||||
const docker = new Docker(); // 默认连 /var/run/docker.sock
|
||||
|
||||
// 面板自身所在的 docker 网络名;新实例都 attach 到它,便于按容器名互访。
|
||||
@@ -58,13 +69,16 @@ function videoDevices(): string[] {
|
||||
}
|
||||
|
||||
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');
|
||||
return env;
|
||||
}
|
||||
|
||||
// 确保微信镜像在本地存在;缺失则从 GHCR 拉取(首次新建实例时镜像通常还没拉过)。
|
||||
@@ -98,6 +112,10 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
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
|
||||
@@ -111,7 +129,18 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
ExposedPorts: { '3000/tcp': {} },
|
||||
HostConfig: hostConfig,
|
||||
});
|
||||
await container.start();
|
||||
try {
|
||||
await container.start();
|
||||
} catch (e) {
|
||||
// 启动失败但容器已被创建出来(Created 状态),不清理的话会成为"幽灵容器"——
|
||||
// 它仍占着卷名 woc-data-<id>,让后续删卷报 409。修复 #23 时发现 4 个此类残留。
|
||||
try {
|
||||
await container.remove({ force: true });
|
||||
} catch {
|
||||
/* 容器已被外部移走或正在被清理,忽略 */
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保实例容器在运行:缺失则按需创建(不会重建已有卷),停止则启动。
|
||||
@@ -136,6 +165,26 @@ export async function upgradeInstance(inst: Instance): Promise<void> {
|
||||
await runInstance(inst);
|
||||
}
|
||||
|
||||
// 重置实例的设备 machine-id:删掉持久化的 .woc-machine-id 后重启,由 00-woc-identity 钩子重新生成
|
||||
// 一个全新的唯一值(相当于"换一台新设备")。用于某账号被腾讯风控标记后手动滚新设备身份。
|
||||
// 仅对含身份钩子的新镜像有效;旧镜像(升级前)无钩子,先 throw 提示升级,避免做无用功。
|
||||
export async function regenInstanceMachineId(inst: Instance): Promise<void> {
|
||||
const hasHook = (
|
||||
await execCapture(inst, [
|
||||
'sh',
|
||||
'-c',
|
||||
'test -f /custom-cont-init.d/00-woc-identity && echo yes || echo no',
|
||||
])
|
||||
).trim();
|
||||
if (hasHook !== 'yes') {
|
||||
throw new Error('该实例运行的是旧镜像(无设备身份模块),请先「升级实例」后再重置设备 ID');
|
||||
}
|
||||
// 删除持久化文件;重启时钩子检测到缺失 → 生成新的唯一 machine-id 并写回卷
|
||||
await execCapture(inst, ['sh', '-c', 'rm -f /config/.woc-machine-id']);
|
||||
await stopInstance(inst);
|
||||
await runInstance(inst);
|
||||
}
|
||||
|
||||
// 停止实例容器(保留容器与数据卷,可再启动)。
|
||||
export async function stopInstance(inst: Instance): Promise<void> {
|
||||
try {
|
||||
@@ -161,6 +210,81 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
|
||||
try {
|
||||
const info = await docker.getContainer(inst.containerName).inspect();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// 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;
|
||||
if (allowlist.includes(host)) return true;
|
||||
return false;
|
||||
}
|
||||
+262
-2
@@ -20,6 +20,7 @@ import {
|
||||
setUserInstances,
|
||||
listInstances,
|
||||
findInstance,
|
||||
setInstanceMemLimits,
|
||||
userInstances,
|
||||
userCanAccess,
|
||||
createInstance,
|
||||
@@ -47,8 +48,15 @@ import {
|
||||
deleteInstanceFile,
|
||||
instanceLogs,
|
||||
typeInInstance,
|
||||
listOrphanVolumes,
|
||||
removeVolume,
|
||||
listOrphanContainers,
|
||||
removeContainerById,
|
||||
instanceMemoryMB,
|
||||
regenInstanceMachineId,
|
||||
} from './docker.js';
|
||||
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
|
||||
import { parseHost, parseAllowedHosts, isAllowedHost } from './host-guard.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -56,6 +64,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');
|
||||
@@ -64,6 +77,23 @@ function basicAuth(inst: Instance) {
|
||||
initStore();
|
||||
|
||||
const app = Fastify({ logger: true, trustProxy: true });
|
||||
|
||||
// DNS-rebinding gate: reject requests whose Host header is neither a loopback /
|
||||
// RFC1918 LAN address nor in PANEL_ALLOWED_HOSTS. Runs before every route so
|
||||
// /api/*, /desktop/* and static-file responses are all covered.
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
const host = parseHost(req.headers.host);
|
||||
if (!isAllowedHost(host, ALLOWED_HOSTS)) {
|
||||
// 把被拒的 host 一起回显,反代调试时可一眼看出"反代后端实际传过来的 Host 是什么"
|
||||
// —— 决定是去白名单加这个 host,还是修反代让它透传客户端 Host。不泄露敏感信息。
|
||||
reply.code(400).send({
|
||||
error: 'Host header not allowed',
|
||||
host: host || null,
|
||||
hint: '反代部署请把对外域名加入 PANEL_ALLOWED_HOSTS(.env,逗号分隔多个域名),或修反代让 Host 头透传',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await app.register(cookie);
|
||||
// 文件上传走原始二进制(前端以 application/octet-stream 直传 File)
|
||||
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
|
||||
@@ -229,12 +259,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) {
|
||||
@@ -244,6 +285,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;
|
||||
@@ -588,6 +755,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 (!isAllowedHost(parseHost(req.headers.host), ALLOWED_HOSTS)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const parsed = req.url ? parseDesktopUrl(req.url) : null;
|
||||
if (!parsed) {
|
||||
socket.destroy();
|
||||
@@ -616,5 +789,92 @@ 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 tick = async () => {
|
||||
for (const pub of listInstances()) {
|
||||
const inst = findInstance(pub.id);
|
||||
if (!inst || recovering.has(inst.id)) continue;
|
||||
try {
|
||||
if ((await instanceRuntime(inst)) !== 'running') continue;
|
||||
const mb = await instanceMemoryMB(inst);
|
||||
if (mb === 0) continue;
|
||||
const { soft, hard } = effectiveLimits(inst);
|
||||
const active = hasActiveSession(inst.id);
|
||||
let reason: 'hard' | 'soft' | null = null;
|
||||
if (hard > 0 && mb >= hard) reason = 'hard';
|
||||
else if (soft > 0 && mb >= soft && !active) reason = 'soft';
|
||||
if (!reason) {
|
||||
if (soft > 0 && mb >= soft && active) {
|
||||
app.log.info(
|
||||
`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 但用户在使用(holder=${controlHolders.get(inst.id)?.username}),延后`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
recovering.add(inst.id);
|
||||
if (reason === 'hard') {
|
||||
app.log.warn(
|
||||
`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ hard=${hard}MiB,强制重启(active=${active})`,
|
||||
);
|
||||
} else {
|
||||
app.log.warn(
|
||||
`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 且无活跃会话,柔和重启`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await stopInstance(inst);
|
||||
await runInstance(inst);
|
||||
app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason})`);
|
||||
} catch (e: any) {
|
||||
app.log.error(`[watchdog] ${inst.containerName} 自愈失败(${reason}): ${e?.message || e}`);
|
||||
} finally {
|
||||
recovering.delete(inst.id);
|
||||
}
|
||||
} 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} (多实例反代已就绪)`);
|
||||
|
||||
@@ -34,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 {
|
||||
@@ -190,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() {
|
||||
@@ -213,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 特殊字符。
|
||||
|
||||
+30
-2
@@ -24,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;
|
||||
@@ -75,11 +86,28 @@ 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) =>
|
||||
|
||||
@@ -27,7 +27,12 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
const [resetTarget, setResetTarget] = useState<PanelUser | null>(null); // 重置密码弹窗
|
||||
const [deleteInst, setDeleteInst] = useState<InstanceWithStatus | null>(null); // 删除实例弹窗
|
||||
const [renameInst, setRenameInst] = useState<InstanceWithStatus | null>(null); // 重命名实例弹窗
|
||||
const [securityInst, setSecurityInst] = useState<InstanceWithStatus | null>(null); // 安全(内存阈值)弹窗
|
||||
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
|
||||
// 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。
|
||||
const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]);
|
||||
// 残留 woc-wx-* 容器(runInstance 启动失败遗留的 Created 容器等):占着卷名让删卷报 409。
|
||||
const [orphanConts, setOrphanConts] = useState<{ id: string; name: string; status: string; volumeName?: string }[]>([]);
|
||||
const setAct = (id: string, label: string | null) =>
|
||||
setActing((a) => {
|
||||
const n = { ...a };
|
||||
@@ -48,6 +53,60 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
} catch (e: any) {
|
||||
setErr(e.message);
|
||||
}
|
||||
// 孤儿卷 / 残留容器独立 catch:docker 接口失败不应阻塞用户/实例视图
|
||||
try {
|
||||
const { volumes } = await api.listOrphanVolumes();
|
||||
setOrphanVols(volumes);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const { containers } = await api.listOrphanContainers();
|
||||
setOrphanConts(containers);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const removeOrphanCont = async (c: { id: string; name: string }) => {
|
||||
const ok = await confirm({
|
||||
title: `删除残留容器「${c.name}」?`,
|
||||
body: '此容器不属于任何登记实例(多为创建失败遗留)。删除不会动数据卷,删后才能继续清理同名旧数据卷。',
|
||||
danger: true,
|
||||
confirmText: '删除容器',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.deleteOrphanContainer(c.id);
|
||||
toast('已删除残留容器,可继续清理数据卷', 'ok');
|
||||
setOrphanConts((cs) => cs.filter((x) => x.id !== c.id));
|
||||
// 容器走了之后,原本被它占着的卷可能从"被引用"翻成"孤儿",刷新一次
|
||||
try {
|
||||
const { volumes } = await api.listOrphanVolumes();
|
||||
setOrphanVols(volumes);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e.message || '删除失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const removeOrphanVol = async (name: string) => {
|
||||
const ok = await confirm({
|
||||
title: `彻底删除数据卷「${name}」?`,
|
||||
body: '该卷里保存的微信本地数据(聊天记录缓存等)将永久消失,无法恢复。',
|
||||
danger: true,
|
||||
confirmText: '彻底删除',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.deleteOrphanVolume(name);
|
||||
toast('已删除数据卷', 'ok');
|
||||
setOrphanVols((vs) => vs.filter((v) => v.name !== name));
|
||||
} catch (e: any) {
|
||||
toast(e.message || '删除失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -171,6 +230,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
onRename={() => setRenameInst(inst)}
|
||||
onAssign={() => setAssignInst(inst)}
|
||||
onDelete={() => setDeleteInst(inst)}
|
||||
onSecurity={() => setSecurityInst(inst)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -222,6 +282,63 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{orphanConts.length > 0 && (
|
||||
<>
|
||||
<div className="section-row" style={{ marginTop: 22 }}>
|
||||
<span className="section-title">残留容器</span>
|
||||
<span className="muted small">不属于任何登记实例(多为创建失败遗留);它们占着数据卷名,需先清理它们才能删除同名数据卷。</span>
|
||||
</div>
|
||||
<div className="inst-grid">
|
||||
{orphanConts.map((c) => (
|
||||
<div key={c.id} className="inst-card">
|
||||
<div className="inst-head">
|
||||
<span className="inst-name" style={{ fontFamily: 'monospace', fontSize: 13 }}>{c.name}</span>
|
||||
<span className="tag tag-off">{c.status || 'unknown'}</span>
|
||||
</div>
|
||||
{c.volumeName && (
|
||||
<div className="inst-sub" style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
占用卷:{c.volumeName}
|
||||
</div>
|
||||
)}
|
||||
<div className="inst-admin-links">
|
||||
<button className="btn-text danger" onClick={() => removeOrphanCont(c)}>
|
||||
删除容器
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{orphanVols.length > 0 && (
|
||||
<>
|
||||
<div className="section-row" style={{ marginTop: 22 }}>
|
||||
<span className="section-title">未使用的数据卷</span>
|
||||
<span className="muted small">删除实例时未勾选「彻底清除」会保留下来;可在新建实例时复用以继承聊天记录。</span>
|
||||
</div>
|
||||
<div className="inst-grid">
|
||||
{orphanVols.map((v) => (
|
||||
<div key={v.name} className="inst-card">
|
||||
<div className="inst-head">
|
||||
<span className="inst-name" style={{ fontFamily: 'monospace', fontSize: 13 }}>{v.name}</span>
|
||||
</div>
|
||||
<div className="inst-sub">
|
||||
{v.createdAt ? `创建于 ${v.createdAt.slice(0, 10)}` : '创建时间未知'}
|
||||
{typeof v.sizeBytes === 'number' ? ` · ${(v.sizeBytes / 1024 / 1024).toFixed(1)} MB` : ''}
|
||||
</div>
|
||||
<div className="inst-admin-links">
|
||||
<button className="btn-text" onClick={() => setCreatingInst(true)} title="去「新建实例」对话框,在「数据卷」下拉里选择复用此卷">
|
||||
复用为新实例
|
||||
</button>
|
||||
<button className="btn-text danger" onClick={() => removeOrphanVol(v.name)}>
|
||||
彻底删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -319,6 +436,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{securityInst && (
|
||||
<InstanceSecurity
|
||||
inst={securityInst}
|
||||
onClose={() => setSecurityInst(null)}
|
||||
onDone={() => {
|
||||
toast('已保存安全阈值', 'ok');
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -402,6 +529,199 @@ function ResetPassword({ user, onClose, onDone }: { user: PanelUser; onClose: ()
|
||||
);
|
||||
}
|
||||
|
||||
// 「安全」弹窗:编辑某实例的内存安全阀(soft / hard)。
|
||||
// soft:超过且无人在远程会话时主动重启(柔和自愈,不打扰)
|
||||
// hard:超过即强制重启(无视会话,防止 OOM)
|
||||
// 留空 = 使用面板全局默认(来自 env)。
|
||||
function InstanceSecurity({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) {
|
||||
const { toast, confirm } = useUI();
|
||||
const [data, setData] = useState<import('../api').MemLimits | null>(null);
|
||||
// 输入字段:空串 = "使用默认"(→ 提交时映射为 null)
|
||||
const [softStr, setSoftStr] = useState('');
|
||||
const [hardStr, setHardStr] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [regenBusy, setRegenBusy] = useState(false);
|
||||
|
||||
const regenMachineId = async () => {
|
||||
const ok = await confirm({
|
||||
title: '重置该实例的设备 ID?',
|
||||
body: '会生成一个全新的设备标识(machine-id)并重启实例,相当于"换一台新设备"。微信需要重新扫码登录。适用于该账号被微信判定设备风险、登录即被强制退出的情况。',
|
||||
danger: true,
|
||||
confirmText: '重置并重启',
|
||||
});
|
||||
if (!ok) return;
|
||||
setRegenBusy(true);
|
||||
try {
|
||||
await api.regenMachineId(inst.id);
|
||||
toast('已重置设备 ID,实例正在重启,请稍后重新扫码登录', 'ok');
|
||||
onClose();
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '重置失败', 'error');
|
||||
} finally {
|
||||
setRegenBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 首次加载 + 每 5s 刷新 currentMB(运行实例的实时内存)
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const fetchOnce = async (initial: boolean) => {
|
||||
try {
|
||||
const d = await api.getInstanceMemLimits(inst.id);
|
||||
if (!alive) return;
|
||||
setData(d);
|
||||
if (initial) {
|
||||
setSoftStr(d.soft == null ? '' : String(d.soft));
|
||||
setHardStr(d.hard == null ? '' : String(d.hard));
|
||||
setLoaded(true);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (alive && initial) {
|
||||
setErr(e?.message || '读取失败');
|
||||
setLoaded(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchOnce(true);
|
||||
const t = window.setInterval(() => fetchOnce(false), 5000);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(t);
|
||||
};
|
||||
}, [inst.id]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr('');
|
||||
const parse = (s: string): number | null => {
|
||||
const t = s.trim();
|
||||
if (t === '') return null;
|
||||
const n = Number(t);
|
||||
if (!Number.isInteger(n)) throw new Error('阈值需为整数(MiB)');
|
||||
return n;
|
||||
};
|
||||
let s: number | null;
|
||||
let h: number | null;
|
||||
try {
|
||||
s = parse(softStr);
|
||||
h = parse(hardStr);
|
||||
} catch (e: any) {
|
||||
setErr(e.message);
|
||||
return;
|
||||
}
|
||||
if (s != null && h != null && s >= h) {
|
||||
setErr('soft 阈值需小于 hard 阈值');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.setInstanceMemLimits(inst.id, s, h);
|
||||
onDone();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '保存失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetToDefault = () => {
|
||||
setSoftStr('');
|
||||
setHardStr('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit} style={{ maxWidth: 460 }}>
|
||||
<h2>安全 · {inst.name}</h2>
|
||||
{!loaded ? (
|
||||
<div className="muted small" style={{ padding: '14px 0' }}>读取中…</div>
|
||||
) : !data ? (
|
||||
<div className="error">{err || '读取失败'}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="muted small" style={{ lineHeight: 1.6 }}>
|
||||
当 KasmVNC/Xvnc 长跑泄漏内存时,面板的 watchdog 会自动重启实例。两档阈值(单位 MiB):
|
||||
<br />
|
||||
<b>soft</b>:超过且<b>无人在远程会话</b>时柔和重启(不打扰使用者)。
|
||||
<br />
|
||||
<b>hard</b>:超过即<b>强制重启</b>,无视会话,防止 OOM 拖垮宿主。
|
||||
</div>
|
||||
|
||||
<div className="security-status">
|
||||
<div className="security-row">
|
||||
<span>当前内存</span>
|
||||
<b>{data.currentMB > 0 ? `${data.currentMB} MiB` : '—'}</b>
|
||||
</div>
|
||||
<div className="security-row">
|
||||
<span>面板默认</span>
|
||||
<span className="muted">soft {data.defaultSoft} · hard {data.defaultHard}</span>
|
||||
</div>
|
||||
<div className="security-row">
|
||||
<span>巡检间隔</span>
|
||||
<span className="muted">
|
||||
{data.watchdogEnabled ? `每 ${data.intervalSec}s` : 'watchdog 已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field-label" style={{ marginTop: 12 }}>soft 阈值(留空 = 用默认 {data.defaultSoft})</div>
|
||||
<input
|
||||
className="input"
|
||||
inputMode="numeric"
|
||||
placeholder={`${data.defaultSoft}`}
|
||||
value={softStr}
|
||||
onChange={(e) => setSoftStr(e.target.value.replace(/[^0-9]/g, ''))}
|
||||
/>
|
||||
<div className="field-label" style={{ marginTop: 8 }}>hard 阈值(留空 = 用默认 {data.defaultHard})</div>
|
||||
<input
|
||||
className="input"
|
||||
inputMode="numeric"
|
||||
placeholder={`${data.defaultHard}`}
|
||||
value={hardStr}
|
||||
onChange={(e) => setHardStr(e.target.value.replace(/[^0-9]/g, ''))}
|
||||
/>
|
||||
<div className="muted small" style={{ marginTop: 6 }}>
|
||||
提示:日常活跃内存约 1500 MiB;soft 建议略高于此(如 2000),hard 建议远低于宿主可用内存(如 3000~4000)。
|
||||
</div>
|
||||
|
||||
<div className="field-label" style={{ marginTop: 16 }}>设备身份(machine-id)</div>
|
||||
<div className="muted small" style={{ lineHeight: 1.6 }}>
|
||||
微信会用设备标识做风控。若该账号被判定<b>设备风险</b>、登录后被强制退出且反复循环,
|
||||
可重置为一个全新的唯一设备 ID(相当于换台新设备),再重新扫码登录。会重启该实例。
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ marginTop: 8, alignSelf: 'flex-start' }}
|
||||
onClick={regenMachineId}
|
||||
disabled={regenBusy || busy}
|
||||
>
|
||||
{regenBusy ? '重置中…' : '↻ 重置设备 ID 并重启'}
|
||||
</button>
|
||||
|
||||
{err && <div className="error">{err}</div>}
|
||||
</>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-text" onClick={resetToDefault} disabled={busy}>
|
||||
↺ 恢复默认
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={onClose} disabled={busy}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-primary" disabled={busy || !loaded || !data}>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) {
|
||||
const [purge, setPurge] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
@@ -459,6 +779,7 @@ function InstanceAdminCard({
|
||||
onRename,
|
||||
onAssign,
|
||||
onDelete,
|
||||
onSecurity,
|
||||
}: {
|
||||
inst: InstanceWithStatus;
|
||||
userCount: number;
|
||||
@@ -472,6 +793,7 @@ function InstanceAdminCard({
|
||||
onRename: () => void;
|
||||
onAssign: () => void;
|
||||
onDelete: () => void;
|
||||
onSecurity: () => void;
|
||||
}) {
|
||||
const wx = inst.wechat;
|
||||
const busy = BUSY_PHASES.includes(wx.phase);
|
||||
@@ -557,6 +879,9 @@ function InstanceAdminCard({
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志(排错)">
|
||||
日志
|
||||
</button>
|
||||
<button className="btn-text" onClick={onSecurity} title="设置内存阈值:超过 soft 且无人会话时柔和重启;超过 hard 强制重启">
|
||||
安全
|
||||
</button>
|
||||
<button className="btn-text danger" onClick={onDelete}>
|
||||
删除
|
||||
</button>
|
||||
@@ -656,13 +981,29 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
|
||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
// 未使用的旧数据卷(之前删除实例但未勾选「彻底清除」时保留下来的),允许在此复用以继承聊天记录。
|
||||
const [orphans, setOrphans] = useState<{ name: string; createdAt?: string }[]>([]);
|
||||
const [reuse, setReuse] = useState<string>(''); // '' = 不复用,新建空卷
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.listOrphanVolumes()
|
||||
.then(({ volumes }) => alive && setOrphans(volumes))
|
||||
.catch(() => {
|
||||
/* 读取失败时不阻塞创建:列表为空即可,照常新建空卷 */
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.createInstance(name.trim(), [...sel]);
|
||||
await api.createInstance(name.trim(), [...sel], reuse || undefined);
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '创建失败');
|
||||
@@ -683,6 +1024,23 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
|
||||
onToggle={(id) => setSel((s) => toggleSet(s, id))}
|
||||
empty="暂无子账号"
|
||||
/>
|
||||
{orphans.length > 0 && (
|
||||
<>
|
||||
<div className="field-label" style={{ marginTop: 12 }}>数据卷(可选)</div>
|
||||
<select className="input" value={reuse} onChange={(e) => setReuse(e.target.value)}>
|
||||
<option value="">新建空卷(全新登录)</option>
|
||||
{orphans.map((v) => (
|
||||
<option key={v.name} value={v.name}>
|
||||
复用 · {v.name}
|
||||
{v.createdAt ? `(${v.createdAt.slice(0, 10)} 创建)` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="muted small" style={{ marginTop: 4 }}>
|
||||
复用旧卷需**用原微信号扫码登录**才能解密历史消息;用别的号登录将看不到旧记录。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="muted small" style={{ marginTop: 4 }}>创建后会拉起一个新的微信容器,进入后扫码登录。</div>
|
||||
<div className="modal-actions">
|
||||
|
||||
@@ -732,6 +732,22 @@ button {
|
||||
.clip-area:focus {
|
||||
box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.16), 0 0 0 2px rgba(7, 193, 96, 0.35);
|
||||
}
|
||||
.security-status {
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
background: var(--mf-trough, #edeef1);
|
||||
box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.security-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.files-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
|
||||
Reference in New Issue
Block a user