Cloudflare Workers 信令中枢 + 学生端本地 Agent
Esc/F11 就退出了。Cloudflare Workers 只能承担“信令中枢”的角色,真正的强制力必须来自学生机上以更高权限常驻的本地 Agent。
教师端网页 ──(WebSocket)──┐
├─► Cloudflare Worker + Durable Object(按班级分房间)
学生端 Agent ─(WebSocket)─┘ ▲
│ 广播 lock / unlock 指令
POST /unlock 校验密码
| 难点 | 纯网页 | 本地 Agent 的正确做法 |
|---|---|---|
| 占满屏幕 | 浏览器全屏,易退出 | 无边框置顶窗口(TopMost + Fullscreen),关不掉 |
屏蔽 Alt+Tab / Win / Alt+F4 |
做不到 | 低级键盘钩子 WH_KEYBOARD_LL |
屏蔽 Ctrl+Alt+Del |
做不到 | 自定义桌面 CreateDesktop + SwitchDesktop(需 SYSTEM 权限) |
| 被 killer 杀进程 | — | 服务以 SYSTEM 运行 + 服务 ACL 拒绝停止/删除 + 多进程互守 |
| 拔网线 / 断网 | — | 锁屏状态持久化到本地,重启后仍锁;失联不自动解锁 |
| 密码被逆向 | — | 密码只在 Worker 端校验,本地不留明文 |
src/worker.js
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/unlock') {
// 密码校验放在服务端,本地不留明文
if (request.method !== 'POST') return new Response('method', { status: 405 });
const { password } = await request.json().catch(() => ({}));
const ok = password && password === env.UNLOCK_PASSWORD;
return Response.json({ ok: !!ok });
}
if (url.pathname === '/ws') {
// 连接级鉴权:token 放在 Worker Secret 里
if (url.searchParams.get('token') !== env.ACCESS_TOKEN) {
return new Response('forbidden', { status: 403 });
}
const room = url.searchParams.get('room') || 'class-1';
const id = env.ROOMS.idFromName(room);
return env.ROOMS.get(id).fetch(request);
}
return new Response('not found', { status: 404 });
},
};
export class Room {
constructor(state) {
this.clients = new Set();
}
async fetch(request) {
if (request.headers.get('Upgrade') !== 'websocket') {
return new Response('expected websocket', { status: 426 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
this.clients.add(server);
server.addEventListener('message', (evt) => {
let msg;
try { msg = JSON.parse(evt.data); } catch { return; }
// 只放行白名单指令,避免变成任意命令通道
if (!['lock', 'unlock'].includes(msg.type)) return;
const out = JSON.stringify({ type: msg.type, ts: Date.now() });
for (const c of this.clients) {
try { c.send(out); } catch { this.clients.delete(c); }
}
});
const drop = () => this.clients.delete(server);
server.addEventListener('close', drop);
server.addEventListener('error', drop);
return new Response(null, { status: 101, webSocket: client });
}
}
wrangler.toml
name = "classroom-lock"
main = "src/worker.js"
compatibility_date = "2024-09-23"
[durable_objects]
bindings = [{ name = "ROOMS", class_name = "Room" }]
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Room"]
设置密钥:
npx wrangler secret put ACCESS_TOKEN
npx wrangler secret put UNLOCK_PASSWORD
<!doctype html>
<meta charset="utf-8">
<title>课堂控制台</title>
<button id="lock">锁定全班</button>
<button id="unlock">解除锁定</button>
<p id="status">连接中…</p>
<script>
const TOKEN = prompt('口令'); // 正式环境改为登录后下发
const ws = new WebSocket(
`wss://classroom-lock.<你的子域>.workers.dev/ws?room=class-1&role=teacher&token=${TOKEN}`
);
ws.onopen = () => status.textContent = '已连接';
ws.onclose = () => status.textContent = '已断开';
lock.onclick = () => ws.send(JSON.stringify({ type: 'lock' }));
unlock.onclick = () => ws.send(JSON.stringify({ type: 'unlock' }));
</script>
生产环境建议用 C# / Go 编译成 Windows 服务,Python 只用于先跑通链路。
import asyncio, json, queue, threading
import ctypes
import tkinter as tk
import websockets
WS_URL = "wss://classroom-lock.<你的子域>.workers.dev/ws?room=class-1&role=student&token=SECRET"
UNLOCK_API = "https://classroom-lock.<你的子域>.workers.dev/unlock"
events = queue.Queue()
# ---------- 网络层:独立线程跑 asyncio ----------
async def listen():
while True:
try:
async with websockets.connect(WS_URL, ping_interval=20) as ws:
async for raw in ws:
msg = json.loads(raw)
if msg.get("type") in ("lock", "unlock"):
events.put(msg["type"])
except Exception:
await asyncio.sleep(3) # 断网自动重连
threading.Thread(target=lambda: asyncio.run(listen()), daemon=True).start()
# ---------- 键盘钩子:屏蔽 Win / Alt+Tab / Alt+F4 / Esc ----------
class KBDLLHOOKSTRUCT(ctypes.Structure):
_fields_ = [("vkCode", ctypes.c_ulong), ("scanCode", ctypes.c_ulong),
("flags", ctypes.c_ulong), ("time", ctypes.c_ulong),
("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong))]
WH_KEYBOARD_LL = 13
BLOCKED = {0x5B, 0x5C, 0x09, 0x1B} # LWin, RWin, Tab, Esc
def install_hook():
user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32
HOOKPROC = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.c_int,
ctypes.c_void_p, ctypes.c_void_p)
def proc(nCode, wParam, lParam):
if nCode == 0 and wParam in (0x100, 0x104): # WM_KEYDOWN / SYSKEYDOWN
kb = ctypes.cast(lParam, ctypes.POINTER(KBDLLHOOKSTRUCT)).contents
alt_down = user32.GetAsyncKeyState(0x12) & 0x8000
if kb.vkCode in BLOCKED or (alt_down and kb.vkCode == 0x73): # Alt+F4
return 1 # 吞掉按键
return user32.CallNextHookEx(None, nCode, wParam, lParam)
cb = HOOKPROC(proc) # 必须保持引用,防 GC
user32.SetWindowsHookExW(WH_KEYBOARD_LL, cb, kernel32.GetModuleHandleW(None), 0)
return cb
# ---------- 锁屏窗口 ----------
class LockScreen:
def __init__(self):
self.root = None
def show(self):
if self.root:
return
cb = install_hook()
root = tk.Tk()
self.root = root
root.attributes("-fullscreen", True)
root.attributes("-topmost", True)
root.overrideredirect(True)
root.configure(bg="#101010")
root.protocol("WM_DELETE_WINDOW", lambda: None) # 关不掉
tk.Label(root, text="课堂已锁定,请输入教师密码", bg="#101010",
fg="white", font=("Microsoft YaHei", 28)).pack(pady=90)
entry = tk.Entry(root, show="*", justify="center",
font=("Microsoft YaHei", 20), width=18)
entry.pack(ipady=8)
entry.focus_force()
tip = tk.Label(root, text="", bg="#101010", fg="#ff6b6b",
font=("Microsoft YaHei", 14))
tip.pack(pady=10)
def verify():
import urllib.request
body = json.dumps({"password": entry.get()}).encode()
req = urllib.request.Request(UNLOCK_API, data=body, method="POST",
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=5) as r:
ok = json.loads(r.read())["ok"]
except Exception:
tip.config(text="网络异常,无法解锁")
return
if ok:
self.hide()
else:
tip.config(text="密码错误")
root.bind("<Return>", lambda _: verify())
root.mainloop()
def hide(self):
if self.root:
self.root.quit()
self.root.destroy()
self.root = None
locker = LockScreen()
# ---------- 主循环:轮询事件队列 ----------
def pump():
while True:
try:
cmd = events.get_nowait()
except queue.Empty:
break
if cmd == "lock":
locker.show()
elif cmd == "unlock":
locker.hide()
root_pump.after(200, pump)
root_pump = tk.Tk()
root_pump.withdraw()
root_pump.after(200, pump)
root_pump.mainloop()
WH_KEYBOARD_LL 只能在有消息泵的线程安装,tkinter 的 mainloop 正好提供了消息泵,所以上面的写法可行。但它挡不住 Ctrl+Alt+Del——那是系统级安全序列。
要连 Ctrl+Alt+Del 一起挡,只有一条路:自定义桌面。
HDESK desk = CreateDesktop("ClassLock", null, null, 0,
DESKTOP_CREATEWINDOW | DESKTOP_SWITCHDESKTOP |
DESKTOP_WRITEOBJECTS | DESKTOP_READOBJECTS, IntPtr.Zero);
SwitchDesktop(desk); // 切到一个非 Winlogon 的独立桌面
// 在该桌面上 CreateWindowEx 画出锁屏窗口
// 解锁时 SwitchDesktop(OpenInputDesktop()) 切回来
切到自定义桌面后,Ctrl+Alt+Del 触发的是 Winlogon 安全桌面(更高优先级),但普通用户无法在该桌面做任何操作,实际效果就是“锁死”。若还需完全接管,则要写 Credential Provider 或凭据 UI,属于企业级 MDM 范畴。
sc sdset 给服务的安全描述符加上 D;;(D;;;BU),拒绝普通用户停止 / 删除。Ctrl+Alt+Del 选项、禁止安装 / 运行未授权程序。lock 指令携带有效期,防止重放或延迟生效。lock / unlock,绝不设计成通用命令执行通道。