mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
Compare commits
5 Commits
+26
-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 访问面板
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -32,6 +32,9 @@ services:
|
||||
- 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 已设为本实例专属(持久化于数据卷)"
|
||||
@@ -165,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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -53,8 +53,10 @@ import {
|
||||
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));
|
||||
|
||||
@@ -62,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');
|
||||
@@ -70,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));
|
||||
@@ -372,6 +396,21 @@ app.put('/api/admin/instances/:id/mem-limits', async (req, reply) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 重置实例的设备 machine-id(仅管理员):滚一个全新的唯一设备身份并重启实例。
|
||||
// 用于某微信账号被腾讯按"设备风险"标记、登录即被踢时,像"换台新设备"一样恢复。会触发重新扫码登录。
|
||||
app.post('/api/admin/instances/:id/regen-machine-id', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const id = (req.params as any).id;
|
||||
const inst = findInstance(id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await regenInstanceMachineId(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '重置设备 ID 失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除实例(仅管理员):默认保留数据卷,?purge=1 才永久删聊天记录
|
||||
app.delete('/api/admin/instances/:id', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
@@ -716,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();
|
||||
|
||||
@@ -91,6 +91,8 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined }),
|
||||
}),
|
||||
regenMachineId: (id: string) =>
|
||||
req(`/api/admin/instances/${id}/regen-machine-id`, { method: 'POST' }),
|
||||
getInstanceMemLimits: (id: string) =>
|
||||
req<MemLimits>(`/api/admin/instances/${id}/mem-limits`),
|
||||
setInstanceMemLimits: (id: string, soft: number | null | undefined, hard: number | null | undefined) =>
|
||||
|
||||
@@ -534,6 +534,7 @@ function ResetPassword({ user, onClose, onDone }: { user: PanelUser; onClose: ()
|
||||
// hard:超过即强制重启(无视会话,防止 OOM)
|
||||
// 留空 = 使用面板全局默认(来自 env)。
|
||||
function InstanceSecurity({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) {
|
||||
const { toast, confirm } = useUI();
|
||||
const [data, setData] = useState<import('../api').MemLimits | null>(null);
|
||||
// 输入字段:空串 = "使用默认"(→ 提交时映射为 null)
|
||||
const [softStr, setSoftStr] = useState('');
|
||||
@@ -541,6 +542,28 @@ function InstanceSecurity({ inst, onClose, onDone }: { inst: InstanceWithStatus;
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [regenBusy, setRegenBusy] = useState(false);
|
||||
|
||||
const regenMachineId = async () => {
|
||||
const ok = await confirm({
|
||||
title: '重置该实例的设备 ID?',
|
||||
body: '会生成一个全新的设备标识(machine-id)并重启实例,相当于"换一台新设备"。微信需要重新扫码登录。适用于该账号被微信判定设备风险、登录即被强制退出的情况。',
|
||||
danger: true,
|
||||
confirmText: '重置并重启',
|
||||
});
|
||||
if (!ok) return;
|
||||
setRegenBusy(true);
|
||||
try {
|
||||
await api.regenMachineId(inst.id);
|
||||
toast('已重置设备 ID,实例正在重启,请稍后重新扫码登录', 'ok');
|
||||
onClose();
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '重置失败', 'error');
|
||||
} finally {
|
||||
setRegenBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 首次加载 + 每 5s 刷新 currentMB(运行实例的实时内存)
|
||||
useEffect(() => {
|
||||
@@ -664,6 +687,22 @@ function InstanceSecurity({ inst, onClose, onDone }: { inst: InstanceWithStatus;
|
||||
<div className="muted small" style={{ marginTop: 6 }}>
|
||||
提示:日常活跃内存约 1500 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>}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user