WechatOnCloud/panel/server/src/index.ts
Kris a2201d8be3 feat: 新增woc-bridge服务及配套能力
新增完整的woc-bridge业务服务,实现微信API代理、密钥自动提取、消息队列限流等功能:
1. 新增bridge核心代码与s6服务配置
2. 新增ptrace权限初始化脚本与docker配置
3. 新增M2M鉴权API代理与环境变量配置
4. 新增SQLCipher解密、密钥缓存、二维码截图等工具模块
5. 完善docker构建与实例部署配置
2026-07-07 19:04:13 +08:00

1532 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import cookie from '@fastify/cookie';
import fstatic from '@fastify/static';
import httpProxy from 'http-proxy';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import type { IncomingMessage } from 'node:http';
import type { Socket } from 'node:net';
import {
initStore,
findByUsername,
findById,
verifyPassword,
publicUser,
listUsers,
createSub,
setDisabled,
resetPassword,
renameUser,
deleteUser,
setUserInstances,
listInstances,
findInstance,
setInstanceMemLimits,
userInstances,
userCanAccess,
createInstance,
removeInstance as removeInstanceRecord,
renameInstance,
setInstanceIcon,
setInstanceUsers,
publicInstance,
getDesktopDark,
setDesktopDark,
APP_TYPES,
type AppType,
type User,
type Instance,
} from './store.js';
import {
ensureNetwork,
ensureRunning,
runInstance,
stopInstance,
upgradeInstance,
removeInstance as removeInstanceContainer,
instanceRuntime,
triggerWechat,
wechatStatus,
instanceTarget,
uploadToInstance,
listInstanceFiles,
downloadFromInstance,
deleteInstanceFile,
instanceLogs,
buildDiagnostics,
typeInInstance,
keyInInstance,
listOrphanVolumes,
removeVolume,
listOrphanContainers,
removeContainerById,
instanceMemoryMB,
instanceHttpHealthy,
regenInstanceMachineId,
listVolume,
volMkdir,
volMove,
volDelete,
volUploadFile,
volExtractArchive,
volDownloadFile,
volBackupStream,
volRestoreArchive,
listBackgrounds,
uploadBackground,
applyBackground,
deleteBackground,
getBackgroundImage,
getCurrentBackground,
clearBackground,
listFonts,
uploadFont,
deleteFont,
applyFont,
getAppliedFont,
getFontFamily,
} from './docker.js';
import { createSession, getSession, destroySession, destroyUserSessions, SESSION_TTL_MS } from './sessions.js';
import { parseHost, parseAllowedHosts, isRequestHostAllowed } from './host-guard.js';
import { CURRENT_VERSION, versionInfo, ensureChecked, checkForUpdate, startUpdateChecker } from './version.js';
import { triggerSelfUpdate } from './self-update.js';
import { appendInstanceLog, readInstanceLog, appendPanelLog, readPanelLog, pruneOldLogs, filterSince, rangeToMs, DIAG_RANGES } from './logs.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
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');
}
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));
// Heartbeat and other no-body POST routes send no Content-Type; fall through to this wildcard
// instead of being rejected with 415. Fastify's exact-match parsers above take priority.
app.addContentTypeParser('*', { parseAs: 'buffer' }, (_req, _body, done) => done(null, null));
// ---------- 鉴权辅助 ----------
function currentUser(req: FastifyRequest): User | null {
const token = req.cookies?.[COOKIE];
const s = getSession(token);
if (!s) return null;
const u = findById(s.userId);
if (!u || u.disabled) return null;
return u;
}
function requireAuth(req: FastifyRequest, reply: FastifyReply): User | null {
const u = currentUser(req);
if (!u) {
reply.code(401).send({ error: '未登录' });
return null;
}
return u;
}
function requireAdmin(req: FastifyRequest, reply: FastifyReply): User | null {
const u = requireAuth(req, reply);
if (!u) return null;
if (u.role !== 'admin') {
reply.code(403).send({ error: '需要管理员权限' });
return null;
}
return u;
}
// ---------- 登录 / 会话 ----------
app.post('/api/auth/login', async (req, reply) => {
const { username, password } = (req.body as any) ?? {};
const u = username ? findByUsername(username) : undefined;
if (!u || u.disabled || !verifyPassword(u, password ?? '')) {
return reply.code(401).send({ error: '用户名或密码错误' });
}
const token = createSession(u.id);
reply.setCookie(COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: Math.floor(SESSION_TTL_MS / 1000), // 与服务端会话时长一致WOC_SESSION_DAYS默认 30 天)
});
return { user: publicUser(u) };
});
app.post('/api/auth/logout', async (req, reply) => {
destroySession(req.cookies?.[COOKIE]);
reply.clearCookie(COOKIE, { path: '/' });
return { ok: true };
});
app.get('/api/auth/me', async (req, reply) => {
const u = currentUser(req);
if (!u) return reply.code(401).send({ error: '未登录' });
return { user: publicUser(u) };
});
// ---------- 版本与更新检测 ----------
// 当前构建版本 + 缓存的「最新版」检测结果(后台每 6h 查一次 Docker Hub/GHCR。任何登录用户可读。
app.get('/api/version', async (req, reply) => {
if (!requireAuth(req, reply)) return;
ensureChecked(); // 刚启动还没首检时,触发一次后台检查(不阻塞本次响应)
return versionInfo();
});
// 立即重新检查(管理员,用于「检查更新」按钮)。
app.post('/api/admin/version/check', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
return await checkForUpdate();
});
// 一键更新面板自身(管理员):拉新镜像 → 派生 helper 容器重建 woc-panel带健康检查 + 失败回滚)。
// 返回后面板会在十几秒内被 helper 重启,前端提示用户稍候刷新。
app.post('/api/admin/version/self-update', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
try {
const { target } = await triggerSelfUpdate();
return { ok: true, target, message: '已开始更新:面板将在十几秒内重启为新版本,请稍候刷新页面' };
} catch (e: any) {
appendPanelLog('ERROR', `面板自更新失败:${e?.message || e}`);
return reply.code(500).send({ error: '更新失败:' + (e?.message || e) });
}
});
// ---------- 实例桌面深色(与面板主题统一的那个开关)----------
// 读取当前实例深色状态(任何登录用户可读,用于前端同步主题开关与实例的一致性)。
app.get('/api/desktop-theme', async (req, reply) => {
if (!requireAuth(req, reply)) return;
return { dark: getDesktopDark() };
});
// 设置实例深色(管理员)。面板顶栏主题开关切到 深/浅 时调用:持久化即可。它作为浏览器(Chromium)实例
// 启动时的明暗(经 envList → WOC_DARK 下发autostart 据此加 --force-dark-mode故**重启实例后生效**
// 不做在线切换(极简容器内无稳定的桌面 portal微信也不跟随详见 docker/autostart 注释)。
app.post('/api/admin/desktop-theme', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const dark = !!(req.body as any)?.dark;
setDesktopDark(dark);
appendPanelLog('INFO', `实例深色设为 ${dark ? '深色' : '浅色'}(浏览器实例重启后生效)`);
return { ok: true, dark };
});
// ---------- 自助改密 ----------
app.post('/api/account/password', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const { oldPassword, newPassword } = (req.body as any) ?? {};
if (!verifyPassword(u, oldPassword ?? '')) return reply.code(400).send({ error: '原密码错误' });
if (!newPassword || String(newPassword).length < 6) return reply.code(400).send({ error: '新密码至少 6 位' });
resetPassword(u.id, newPassword);
return { ok: true };
});
// ---------- 管理员:子账号管理 ----------
app.get('/api/admin/users', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
return { users: listUsers() };
});
app.post('/api/admin/users', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const { username, password } = (req.body as any) ?? {};
if (!username || !/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
return reply.code(400).send({ error: '用户名为 3-20 位字母、数字或下划线' });
}
if (!password || String(password).length < 6) return reply.code(400).send({ error: '密码至少 6 位' });
const allowedInstances = Array.isArray((req.body as any)?.allowedInstances) ? (req.body as any).allowedInstances : [];
try {
return { user: createSub(username, password, allowedInstances) };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
// 账户侧:设置某账户可访问的实例
app.post('/api/admin/users/:id/instances', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const id = (req.params as any).id;
const instanceIds = Array.isArray((req.body as any)?.instanceIds) ? (req.body as any).instanceIds : [];
try {
return { user: setUserInstances(id, instanceIds) };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
app.post('/api/admin/users/:id/disable', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const { disabled } = (req.body as any) ?? {};
const id = (req.params as any).id;
try {
const user = setDisabled(id, !!disabled);
if (disabled) destroyUserSessions(id);
return { user };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
app.post('/api/admin/users/:id/reset', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const { newPassword } = (req.body as any) ?? {};
const id = (req.params as any).id;
if (!newPassword || String(newPassword).length < 6) return reply.code(400).send({ error: '密码至少 6 位' });
try {
const user = resetPassword(id, newPassword);
destroyUserSessions(id);
return { user };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
// 改用户名(登录名)。会话以 userId 为准,改名后保持登录、下次用新名登录即可。
app.post('/api/admin/users/:id/rename', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const { username } = (req.body as any) ?? {};
const id = (req.params as any).id;
if (!username || !/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
return reply.code(400).send({ error: '用户名为 3-20 位字母、数字或下划线' });
}
try {
return { user: renameUser(id, username) };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
app.delete('/api/admin/users/:id', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const id = (req.params as any).id;
try {
deleteUser(id);
destroyUserSessions(id);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
// ---------- 微信实例管理 ----------
// 列出当前用户可见实例(含运行态 + 微信安装状态)
app.get('/api/instances', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const visible = userInstances(u);
const out = await Promise.all(
visible.map(async (pub) => {
const inst = findInstance(pub.id)!;
const [runtime, wx] = await Promise.all([instanceRuntime(inst), wechatStatus(inst)]);
return { ...pub, runtime, wechat: wx };
}),
);
return { instances: out };
});
// 用户自助「卡死自愈」:当客户端检测到 VNC 多次干净重连仍连不上(多半是实例 KasmVNC 的 ws 接收器卡死——
// nginx 仍能serve 静态页让 noVNC 显示"正在连接",但新 ws 永远 accept 不了,刷新/重启面板都无效、只能重启容器),
// 客户端调用本接口重启该实例(数据卷保留,约十几秒恢复)。需对该实例有访问权;每实例 3 分钟限一次防重启风暴。
const lastHealAt = new Map<string, number>();
app.post('/api/instances/:id/heal', 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 inst = findInstance(id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const now = Date.now();
if (now - (lastHealAt.get(id) || 0) < 180000) {
return { ok: true, restarted: false, message: '近期已尝试恢复,请稍候重连' };
}
lastHealAt.set(id, now);
appendPanelLog('WARN', `实例「${inst.name}」(id=${id}) 由 ${u.username} 触发卡死自愈VNC 连不上 → 重启容器,数据保留)`);
try {
await runInstance(inst);
return { ok: true, restarted: true };
} catch (e: any) {
appendPanelLog('ERROR', `实例「${inst.name}」(id=${id}) 卡死自愈重启失败:${e?.message || e}`);
return reply.code(500).send({ error: '恢复失败:' + (e?.message || e) });
}
});
// 客户端连接日志:前端把 VNC 连接态/动作回传,记进实例持久日志([client] 前缀),与 [vnc] 服务端日志对齐排查。
app.post('/api/instances/:id/clientlog', 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 msg = String((req.body as any)?.msg ?? '')
.replace(/[\r\n]+/g, ' ')
.slice(0, 200);
if (msg) appendInstanceLog(id, `[client] ${msg}${u.username}`);
return { ok: true };
});
// 新建实例(仅管理员):生成凭据 + docker run + 分配访问账户
app.post('/api/admin/instances', async (req, reply) => {
const admin = requireAdmin(req, reply);
if (!admin) return;
const { name, reuseVolume, appType } = (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 type: AppType = APP_TYPES.includes(appType) ? appType : 'wechat';
// 复用卷:必须以 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, type);
appendPanelLog(
'INFO',
`创建实例「${inst.name}」(${type}, id=${inst.id}) by ${admin.username}${reuseVolumeName ? ` · 复用卷 ${reuseVolumeName}` : ''} → 开始创建容器(镜像缺失会自动拉取,首次较慢)`,
);
appendInstanceLog(inst.id, `实例创建(${type}by ${admin.username}`);
try {
await runInstance(inst);
} catch (e: any) {
removeInstanceRecord(inst.id); // 容器起不来则回滚登记
appendPanelLog('ERROR', `创建实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
return reply.code(500).send({ error: '创建容器失败:' + (e?.message || e) });
}
appendPanelLog('INFO', `创建实例「${inst.name}」(id=${inst.id}) 成功`);
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},每项可为正整数 / nullnull = 恢复默认)。
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;
const id = (req.params as any).id;
const purge = (req.query as any)?.purge === '1' || (req.query as any)?.purge === 'true';
const inst = findInstance(id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
appendPanelLog('INFO', `删除实例「${inst.name}」(id=${id})${purge ? ' · 同时清除数据卷' : ' · 保留数据卷'}`);
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 });
}
});
// 设置实例自定义图标仅管理员icon = builtin:<key> / data:image 图片 / 空串(恢复默认)。
app.post('/api/admin/instances/:id/icon', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const { icon } = (req.body as any) ?? {};
try {
return { instance: setInstanceIcon((req.params as any).id, typeof icon === 'string' ? icon : null) };
} 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);
appendPanelLog('INFO', `启动实例「${inst.name}」(id=${inst.id})`);
return { ok: true };
} catch (e: any) {
appendPanelLog('ERROR', `启动实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
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);
appendPanelLog('INFO', `停止实例「${inst.name}」(id=${inst.id})`);
return { ok: true };
} catch (e: any) {
appendPanelLog('ERROR', `停止实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
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 {
appendPanelLog('INFO', `重启实例「${inst.name}」(id=${inst.id})`);
await runInstance(inst);
return { ok: true };
} catch (e: any) {
appendPanelLog('ERROR', `重启实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
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 {
appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}):拉取最新镜像后重建`);
await upgradeInstance(inst);
appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}) 完成`);
return { ok: true };
} catch (e: any) {
appendPanelLog('ERROR', `升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
return reply.code(500).send({ error: '升级失败:' + (e?.message || e) });
}
});
// 实例侧:设置该实例可被哪些账户访问
app.post('/api/admin/instances/:id/users', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const id = (req.params as any).id;
const userIds = Array.isArray((req.body as any)?.userIds) ? (req.body as any).userIds : [];
try {
setInstanceUsers(id, userIds);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e.message });
}
});
// ---------- 文件中转(有访问权限即可用;走面板鉴权,不额外暴露) ----------
// 上传:原始二进制直传,落到实例 ~/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 || '输入失败' });
}
});
// 模拟单个按键(无感输入模式下按序送出被截下的回车/退格,保证与中文转发的顺序)
app.post('/api/instances/:id/key', 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 { key } = (req.body as any) ?? {};
if (!key || typeof key !== 'string') return reply.code(400).send({ error: '按键名为空' });
try {
await keyInInstance(findInstance(id)!, key);
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: '实例不存在' });
reply.header('content-type', 'text/plain; charset=utf-8');
// 持久化历史(重启原因 + 上一容器日志快照,跨重建保留)+ 本次容器实时日志。
const history = readInstanceLog(inst.id).trimEnd();
let live = '';
try {
live = (await instanceLogs(inst)).trimEnd();
} catch (e: any) {
live = '获取本次容器日志失败:' + (e?.message || e);
}
if (!history && !live) return reply.send('(暂无日志)');
if (!history) return reply.send(live);
return reply.send(
`═══ 历史日志(持久化 · 跨重启保留)═══\n${history}\n\n═══ 本次容器日志(实时)═══\n${live || '(本次容器暂无日志)'}`,
);
});
// ---------- 全局日志 / 诊断包(仅管理员)----------
// 面板全局运维日志(创建/删除/升级/启停/镜像拉取/错误等跨实例事件),可按时间范围裁剪。
app.get('/api/admin/panel-log', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
reply.header('content-type', 'text/plain; charset=utf-8');
const since = Date.now() - rangeToMs((req.query as any)?.range);
const text = filterSince(readPanelLog(), since).trimEnd();
return reply.send(text || '(暂无面板日志)');
});
// 一键导出诊断包tar.gz系统信息 + 面板日志 + 各实例容器状态/持久日志/实时日志 + 全部容器清单。
// 单实例日志只记录"实例内单次日志",这里把全局 + 全部实例 + 容器层面的信息打包,便于排查
// 首个实例创建卡死 / 打开实例黑屏不可用 / 升级失败等问题。range24h默认/7d/30d/1y。
app.get('/api/admin/diagnostics', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const range = ((req.query as any)?.range as string) || '24h';
if (!DIAG_RANGES[range]) return reply.code(400).send({ error: '时间范围非法24h/7d/30d/1y' });
const since = Date.now() - rangeToMs(range);
try {
const buf = await buildDiagnostics(listInstances(), since, { range, 面板版本: CURRENT_VERSION });
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
reply.header('content-type', 'application/gzip');
reply.header('content-disposition', `attachment; filename="woc-diag-${range}-${stamp}.tar.gz"`);
appendPanelLog('INFO', `导出诊断包(范围 ${range}${buf.length} 字节)`);
return reply.send(buf);
} catch (e: any) {
appendPanelLog('ERROR', `导出诊断包失败:${e?.message || e}`);
return reply.code(500).send({ error: '生成诊断包失败:' + (e?.message || e) });
}
});
// ---------- 数据卷管理(仅管理员):浏览/上传/解压/下载/改名/移动/删除 + 整卷备份/恢复 ----------
// 数据卷 = 容器 /config含微信完整会话与加密聊天库 → 仅 admin 可见可用admin 本就有 docker.sock=宿主 root
// 不新增风险;子账号永不可达)。
// 全程在「运行中」的实例上操作:浏览/改名/移动/删除靠 docker exec需容器运行上传/解压/下载/备份靠
// getArchive/putArchive。不强制停止实例exec 在停止容器无法运行)。整卷恢复会覆盖全部数据,前端强提示
// 并建议恢复后重启实例以加载数据。
// 浏览目录(一层)
app.get('/api/admin/instances/:id/volume', 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 {
return await listVolume(inst, String((req.query as any)?.path || ''));
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '读取目录失败' });
}
});
// 新建文件夹
app.post('/api/admin/instances/:id/volume/mkdir', 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 volMkdir(inst, String((req.body as any)?.path || ''));
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '新建失败' });
}
});
// 重命名 / 移动
app.post('/api/admin/instances/:id/volume/move', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const { from, to } = (req.body as any) ?? {};
try {
await volMove(inst, String(from || ''), String(to || ''));
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '移动失败' });
}
});
// 删除文件 / 目录
app.delete('/api/admin/instances/:id/volume', 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 volDelete(inst, String((req.query as any)?.path || ''));
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '删除失败' });
}
});
// 下载单个文件
app.get('/api/admin/instances/:id/volume/download', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const path = String((req.query as any)?.path || '');
const name = path.split('/').filter(Boolean).pop() || 'file';
try {
const buf = await volDownloadFile(inst, path);
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 || '下载失败' });
}
});
// 上传单个文件到当前目录(原始二进制;落地为 abc 属主)
app.post('/api/admin/instances/:id/volume/upload', { bodyLimit: 2 * 1024 * 1024 * 1024 }, async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const path = String((req.query as any)?.path || '');
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 volUploadFile(inst, path, name, body);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '上传失败' });
}
});
// 上传压缩包并解压到当前目录(.tar / .tar.gzPC 微信数据迁移用)
app.post('/api/admin/instances/:id/volume/extract', { bodyLimit: 3 * 1024 * 1024 * 1024 }, async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const body = req.body as Buffer;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' });
try {
await volExtractArchive(inst, String((req.query as any)?.path || ''), body);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '解压失败(请确认是 .tar 或 .tar.gz' });
}
});
// 整卷备份:流式下载 /config 为 .tar.gz
app.get('/api/admin/instances/:id/volume/backup', 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 stream = await volBackupStream(inst);
reply.header('content-type', 'application/gzip');
reply.header('content-disposition', `attachment; filename*=UTF-8''${encodeURIComponent(`woc-${inst.name}-backup.tar.gz`)}`);
return reply.send(stream);
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '备份失败' });
}
});
// 整卷恢复:上传本系统导出的 .tar.gz 备份(要求实例已停止)
app.post('/api/admin/instances/:id/volume/restore', { bodyLimit: 3 * 1024 * 1024 * 1024 }, async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const body = req.body as Buffer;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' });
try {
await volRestoreArchive(inst, body);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '恢复失败' });
}
});
// 该实例的微信安装状态(有访问权限即可看)
app.get('/api/instances/:id/wechat/status', 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: '无权访问该实例' });
return { status: await wechatStatus(findInstance(id)!) };
});
// 触发该实例微信下载/更新(仅管理员)
async function triggerInstanceWechat(id: string, cmd: 'install' | 'update', reply: FastifyReply) {
const inst = findInstance(id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await triggerWechat(inst, cmd);
appendPanelLog('INFO', `实例「${inst.name}」(id=${id}) 触发${cmd === 'install' ? '下载安装' : '更新'}应用`);
return { ok: true };
} catch (e: any) {
appendPanelLog('ERROR', `实例「${inst.name}」(id=${id}) 触发${cmd === 'install' ? '安装' : '更新'}失败:${e?.message || e}`);
return reply.code(500).send({ error: '无法触发安装:' + (e?.message || e) });
}
}
app.post('/api/admin/instances/:id/wechat/install', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
return triggerInstanceWechat((req.params as any).id, 'install', reply);
});
app.post('/api/admin/instances/:id/wechat/update', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
return triggerInstanceWechat((req.params as any).id, 'update', reply);
});
// ---------- 桌面壁纸管理 ----------
const bgHandler = (id: string, reply: FastifyReply): Instance | null => {
const inst = findInstance(id);
if (!inst) {
reply.code(404).send({ error: '实例不存在' });
return null; // 关键reply 是 truthy不能 return 它,否则调用方 `if (!inst)` 拦不住
}
return inst;
};
app.get('/api/admin/instances/:id/backgrounds', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try { return { backgrounds: await listBackgrounds(inst) }; }
catch (e: any) { return reply.code(500).send({ error: e?.message || '列出壁纸失败' }); }
});
app.post('/api/admin/instances/:id/backgrounds', { bodyLimit: 50 * 1024 * 1024 }, async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
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 uploadBackground(inst, name, body);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '上传失败' }); }
});
app.get('/api/admin/instances/:id/backgrounds/current', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try { return { background: await getCurrentBackground(inst) }; }
catch (e: any) { return reply.code(500).send({ error: e?.message || '获取当前壁纸失败' }); }
});
app.get('/api/admin/instances/:id/backgrounds/:name/image', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
const buf = await getBackgroundImage(inst, (req.params as any).name, true);
const name = (req.params as any).name as string;
const ext = name.includes('.') ? name.split('.').pop()!.toLowerCase() : 'png';
const mime: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp' };
reply.header('Content-Type', mime[ext] || 'application/octet-stream');
reply.header('Cache-Control', 'public, max-age=86400');
return reply.send(buf);
} catch (e: any) { return reply.code(404).send({ error: '图片不存在' }); }
});
app.post('/api/admin/instances/:id/backgrounds/:name/apply', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
await applyBackground(inst, (req.params as any).name);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '应用壁纸失败' }); }
});
app.post('/api/admin/instances/:id/backgrounds/clear', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
await clearBackground(inst);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '清除壁纸失败' }); }
});
app.delete('/api/admin/instances/:id/backgrounds/:name', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
await deleteBackground(inst, (req.params as any).name);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '删除失败' }); }
});
// ---------- 字体管理 ----------
app.get('/api/admin/instances/:id/fonts', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try { return { fonts: await listFonts(inst) }; }
catch (e: any) { return reply.code(500).send({ error: e?.message || '列出字体失败' }); }
});
app.post('/api/admin/instances/:id/fonts', { bodyLimit: 50 * 1024 * 1024 }, async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
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 uploadFont(inst, name, body);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '上传失败' }); }
});
app.delete('/api/admin/instances/:id/fonts/:name', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
await deleteFont(inst, (req.params as any).name);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '删除失败' }); }
});
app.get('/api/admin/instances/:id/fonts/current', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try { return { fontFile: await getAppliedFont(inst) }; }
catch (e: any) { return reply.code(500).send({ error: e?.message || '获取当前字体失败' }); }
});
app.post('/api/admin/instances/:id/fonts/:name/apply', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
await applyFont(inst, (req.params as any).name);
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '应用字体失败' }); }
});
app.post('/api/admin/instances/:id/fonts/default', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = bgHandler((req.params as any).id, reply);
if (!inst) return;
try {
await applyFont(inst, 'default');
return { ok: true };
} catch (e: any) { return reply.code(400).send({ error: e?.message || '重置字体失败' }); }
});
// ---------- 反向代理到内网 KasmVNC按实例注入 Basic auth会话 + 权限把守) ----------
// 单个 proxy 实例target 与凭据逐请求指定:凭据暂存在 req 上proxyReq 时注入。
const proxy = httpProxy.createProxyServer({ changeOrigin: true, ws: true });
proxy.on('proxyReq', (proxyReq, req) => {
const auth = (req as any)._wocAuth;
if (auth) proxyReq.setHeader('authorization', auth);
});
proxy.on('proxyReqWs', (proxyReq, req) => {
const auth = (req as any)._wocAuth;
if (auth) proxyReq.setHeader('authorization', auth);
// 上游(实例 nginx → KasmVNC websockify回 101 = ws 接收器接受了连接,桌面真正连上。
// 卡死时这条不会出现(接收器停止 accept即可定位"卡在面板→实例之间还是实例内部"。
const instId = (req as any)._wocInstId;
if (instId) proxyReq.on('upgrade', () => appendInstanceLog(instId, '[vnc] 上游已接受(101) · 桌面连接建立'));
});
// 兜底:剥掉 KasmVNC 401 的 WWW-Authenticate 头,避免浏览器弹出原生 Basic Auth 登录框。
// 正常路径下我们已注入正确凭据(不会 401万一凭据失配宁可桌面加载失败也绝不把登录弹窗暴露给用户。
proxy.on('proxyRes', (proxyRes) => {
delete proxyRes.headers['www-authenticate'];
});
// 上游(实例 Web暂时连不上时给浏览器导航请求回一个「自动重连」的友好页面而不是死的纯文本。
// 实例在 创建初始化 / 升级 / 重启 / 内存自愈软重启,以及面板自更新(代理短暂中断)时都会短暂 502
// 几秒后即恢复;旧版回纯文本"桌面服务暂时不可用"且 iframe 一旦载入它就判 frameLoaded=true、不再重试
// 用户就卡在黑屏死页(用户反馈的"新版黑屏 桌面服务暂不可用")。此页每 3s 自动重载,实例一就绪即自动连上;
// 连续约 30s 仍不行才转手动重试,并按 20s 间隔重置计数(区分新一轮故障)。
const UPSTREAM_DOWN_HTML =
`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">` +
`<meta name="viewport" content="width=device-width,initial-scale=1"><title>桌面连接中…</title><style>` +
`html,body{height:100%;margin:0}body{display:flex;align-items:center;justify-content:center;background:#14161c;` +
`color:#e7eaef;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}.box{text-align:center;` +
`max-width:340px;padding:24px}.sp{width:34px;height:34px;border:3px solid rgba(255,255,255,.16);border-top-color:#07C160;` +
`border-radius:50%;margin:0 auto 16px;animation:r 1s linear infinite}@keyframes r{to{transform:rotate(360deg)}}` +
`.t{font-size:15px;font-weight:600}.s{font-size:13px;color:#969ca6;margin-top:8px;line-height:1.6}.b{margin-top:18px;` +
`display:none}button{background:#07C160;color:#fff;border:0;border-radius:999px;padding:9px 22px;font-size:14px;cursor:pointer}` +
`</style></head><body><div class="box"><div class="sp" id="sp"></div><div class="t" id="t">桌面正在启动 / 重连中…</div>` +
`<div class="s" id="s">实例重启或初始化时会短暂不可用,将自动重连,请稍候。</div>` +
`<div class="b" id="b"><button onclick="location.reload()">重试</button></div></div><script>(function(){` +
`function rl(){location.reload()}try{var K='woc_up_retry',now=Date.now(),o={};try{o=JSON.parse(sessionStorage.getItem(K)||'{}')}catch(e){}` +
`var n=(now-(o.t||0)>20000)?1:((o.n||0)+1);sessionStorage.setItem(K,JSON.stringify({n:n,t:now}));` +
`if(n<=10){setTimeout(rl,3000)}else{document.getElementById('sp').style.display='none';document.getElementById('b').style.display='block';` +
`document.getElementById('t').textContent='桌面长时间未就绪';document.getElementById('s').textContent='实例可能在重启或未运行。可继续重试,或用左上角菜单返回主页让管理员检查。'}` +
`}catch(e){setTimeout(rl,3000)}})();</script></body></html>`;
proxy.on('error', (_err, req, res) => {
try {
const r = res as any;
if (r && typeof r.writeHead === 'function') {
// 仅对浏览器导航(接受 text/html回友好自动重连页JS/CSS/XHR 等子资源回纯文本,避免把 HTML 喂给非页面请求。
const accept = String((req as any)?.headers?.accept || '');
if (accept.includes('text/html')) {
r.writeHead(502, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
r.end(UPSTREAM_DOWN_HTML);
} else {
r.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
r.end('桌面服务暂时不可用');
}
} else if (r && typeof r.destroy === 'function') {
r.destroy();
}
} catch {
/* ignore */
}
});
// /desktop/:id/rest → rest剥掉前缀与实例段。返回 null 表示 url 非法。
function parseDesktopUrl(rawUrl: string): { id: string; rest: string } | null {
const m = rawUrl.match(/^\/desktop\/([0-9a-f]{6,})(\/.*|\?.*|)?$/);
if (!m) return null;
const id = m[1];
let rest = m[2] || '/';
if (rest.startsWith('?')) rest = '/' + rest;
if (rest === '') rest = '/';
return { id, rest };
}
const desktopHandler = (req: FastifyRequest, reply: FastifyReply) => {
const u = currentUser(req);
if (!u) {
reply.code(302).header('location', '/login').send();
return;
}
const parsed = parseDesktopUrl(req.raw.url || '');
if (!parsed || !userCanAccess(u, parsed.id)) {
reply.code(403).send({ error: '无权访问该实例' });
return;
}
const inst = findInstance(parsed.id)!;
reply.hijack();
req.raw.url = parsed.rest;
(req.raw as any)._wocAuth = basicAuth(inst);
proxy.web(req.raw, reply.raw, { target: instanceTarget(inst) });
};
app.all('/desktop/:id', desktopHandler);
app.all('/desktop/:id/*', desktopHandler);
// ---------- 反向代理到实例的 woc-bridge 业务 API ----------
// /api/bridge/:id/* → http://woc-wx-<id>:8088/*
// 双鉴权:
// 1. M2M 调用Authorization: Bearer <WOC_BRIDGE_API_TOKEN>(供外部系统如 ForcePilot 后端调用)
// 2. 浏览器调用:管理员会话 cookiewoc_sess
// 业务 API 等同微信会话凭据(可发消息/读消息/查联系人),任一鉴权通过即可访问。
// WOC_BRIDGE_API_TOKEN 未配置时仅允许会话鉴权(兼容旧部署)。
const BRIDGE_API_TOKEN = process.env.WOC_BRIDGE_API_TOKEN || '';
function parseBridgeUrl(rawUrl: string): { id: string; rest: string } | null {
const m = rawUrl.match(/^\/api\/bridge\/([0-9a-f]{6,})(\/.*|\?.*|)?$/);
if (!m) return null;
const id = m[1];
let rest = m[2] || '/';
if (rest.startsWith('?')) rest = '/' + rest;
if (rest === '') rest = '/';
return { id, rest };
}
// 常量时间字符串比较,避免 Bearer token 校验遭受时序攻击
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
const bridgeHandler = (req: FastifyRequest, reply: FastifyReply) => {
// 鉴权 1Bearer tokenM2M 调用)
// 仅当面板配置了 WOC_BRIDGE_API_TOKEN 时启用,避免空 token 误放行
let tokenAuthorized = false;
if (BRIDGE_API_TOKEN) {
const auth = req.headers.authorization || '';
if (auth.startsWith('Bearer ') && timingSafeEqual(auth.slice(7), BRIDGE_API_TOKEN)) {
tokenAuthorized = true;
}
}
// 鉴权 2未通过 token 则要求管理员会话
if (!tokenAuthorized) {
if (!requireAdmin(req, reply)) return;
}
const parsed = parseBridgeUrl(req.raw.url || '');
if (!parsed) {
reply.code(404).send({ error: 'not found' });
return;
}
const inst = findInstance(parsed.id);
if (!inst) {
reply.code(404).send({ error: '实例不存在' });
return;
}
reply.hijack();
req.raw.url = parsed.rest;
proxy.web(req.raw, reply.raw, {
target: `http://${inst.containerName}:8088`,
onError: (err: any) => {
try {
reply.raw.writeHead(502, { 'content-type': 'application/json' });
reply.raw.end(JSON.stringify({
success: false,
error: {
code: 'BRIDGE_UNAVAILABLE',
message: 'bridge 服务暂时不可用',
details: String(err?.message || err || ''),
},
}));
} catch {
// 响应已部分写出,无法再写 JSON仅记日志
}
},
});
};
app.all('/api/bridge/:id', bridgeHandler);
app.all('/api/bridge/:id/*', bridgeHandler);
// ---------- 静态 SPA + 前端路由回退 ----------
await app.register(fstatic, { root: STATIC_DIR, wildcard: false, index: ['index.html'] });
app.setNotFoundHandler((req, reply) => {
const url = req.raw.url || '';
if (url.startsWith('/api') || url.startsWith('/desktop')) {
return reply.code(404).send({ error: 'not found' });
}
return reply.sendFile('index.html');
});
// ---------- 启动 + WebSocket 升级(同样校验会话) ----------
function parseCookies(header?: string): Record<string, string> {
const out: Record<string, string> = {};
if (!header) return out;
for (const part of header.split(';')) {
const idx = part.indexOf('=');
if (idx === -1) continue;
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
}
return out;
}
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();
return;
}
const cookies = parseCookies(req.headers.cookie);
const s = getSession(cookies[COOKIE]);
const u = s && findById(s.userId);
if (!u || u.disabled || !userCanAccess(u, parsed.id)) {
socket.destroy();
return;
}
const inst = findInstance(parsed.id)!;
req.url = parsed.rest;
(req as any)._wocAuth = basicAuth(inst);
(req as any)._wocInstId = inst.id;
// 远程桌面连接日志:记录每次 ws 连接尝试 / 上游接受(在 proxyReqWs 里) / 失败 / 关闭时长。
// 与实例容器内 KasmVNC 的 "got client connection" 按时间对齐,即可看出卡在哪一段。
const ip = (req.socket && req.socket.remoteAddress) || '?';
const uname = (u as any).username || '?';
appendInstanceLog(inst.id, `[vnc] 连接尝试 user=${uname} ip=${ip}`);
const t0 = Date.now();
socket.on('close', () => appendInstanceLog(inst.id, `[vnc] 连接关闭(持续 ${Math.round((Date.now() - t0) / 1000)}s`));
proxy.ws(req, socket, head, { target: instanceTarget(inst) }, (err: any) => {
appendInstanceLog(inst.id, `[vnc] 连接失败:${err?.message || err}`);
});
});
// 探测面板网络 + 重启后把已登记实例的容器拉起来
await ensureNetwork().catch(() => {});
for (const pub of listInstances()) {
try {
await ensureRunning(findInstance(pub.id)!);
} catch (e: any) {
app.log.warn(`[instance] 启动实例 ${pub.id} 失败: ${e?.message || e}`);
}
}
// WatchdogKasmVNC/Xvnc 长跑会泄漏(实测 24h 可达 ~9 GiB小内存机器会被拖垮。
// 两档阈值,按"是否有人在用"决定时机:
// softmem >= soft 且当前无活跃会话 → 主动重启(柔和自愈,不打扰)
// hardmem >= 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 巡检间隔秒;默认 3005 分钟),最小 600 关闭整个 watchdog
// WOC_WATCHDOG_HEALTH_FAILS VNC 响应性探测:连续无响应几次才重启;默认 0=关闭该探测(仅保留内存自愈)
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));
// VNC 响应性探测默认关闭(=0。实测健康实例 ~1ms 响应,但偶发宿主级 CPU/IO 争用(如同机重 docker build
// 会让探测超时被误判为 stall 而重启正常实例,故默认不启用;需要时设为正整数 N连续 N 次无响应才重启)开启。
const HEALTH_FAIL_LIMIT = Math.max(0, Number(process.env.WOC_WATCHDOG_HEALTH_FAILS ?? 0));
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 → 连续无响应次数(仅 HEALTH_FAIL_LIMIT>0 时启用)
const recover = async (inst: Instance, reason: string, detail: string) => {
recovering.add(inst.id);
app.log.warn(`[watchdog] ${inst.containerName} ${detail}`);
appendInstanceLog(inst.id, `[看门狗] 自愈重启(${reason}${detail}`);
appendPanelLog('WARN', `[看门狗] 实例「${inst.name}」(id=${inst.id}) 自愈重启(${reason}${detail}`);
try {
await stopInstance(inst);
await runInstance(inst);
healthFails.delete(inst.id);
app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason}`);
} catch (e: any) {
appendPanelLog('ERROR', `[看门狗] 实例「${inst.name}」(id=${inst.id}) 自愈失败(${reason}${e?.message || e}`);
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 文件、永远卡在正在连接桌面"。
// 默认关闭HEALTH_FAIL_LIMIT=0偶发宿主级争用会误判健康实例为 stall需要时用 env 开启。
if (HEALTH_FAIL_LIMIT > 0) {
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 · VNC响应性探测=${HEALTH_FAIL_LIMIT > 0 ? `连续${HEALTH_FAIL_LIMIT}` : '关闭'}`,
);
}
await app.listen({ port: PORT, host: HOST });
console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)· 版本 ${CURRENT_VERSION}`);
appendPanelLog('INFO', `面板启动 · 版本 ${CURRENT_VERSION} · 监听 ${HOST}:${PORT}`);
startUpdateChecker(); // 后台检测新版best-effort失败静默
// 日志保留期清理:启动后跑一次 + 每 24h 一次删除超过一年的日志行unref 不阻止退出)。
pruneOldLogs();
setInterval(() => pruneOldLogs(), 24 * 60 * 60 * 1000).unref();