技术方案

课堂屏幕管控方案

Cloudflare Workers 信令中枢 + 学生端本地 Agent

前提结论:纯网页方案永远做不到“强制”——浏览器全屏一个 EscF11 就退出了。Cloudflare Workers 只能承担“信令中枢”的角色,真正的强制力必须来自学生机上以更高权限常驻的本地 Agent。

目录

  1. 整体架构
  2. 关键技术难点与对策
  3. 代码骨架
  4. 加固清单
  5. 两点提醒

一、整体架构

architecture
教师端网页 ──(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 端校验,本地不留明文
最根本的一条:学生账户必须是标准用户,不能是管理员。极域这类软件被 killer 干掉,九成是因为学生账户有管理员权限——有管理员权限的任何保护都能被拆掉。

三、代码骨架

3.1 Worker + Durable Object(信令中枢)

src/worker.js

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

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"]

设置密钥:

bash
npx wrangler secret put ACCESS_TOKEN
npx wrangler secret put UNLOCK_PASSWORD

3.2 教师端页面

teacher.html
<!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>

3.3 学生端 Agent(Python 骨架,验证可行性)

生产环境建议用 C# / Go 编译成 Windows 服务,Python 只用于先跑通链路。

student_agent.py
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 一起挡,只有一条路:自定义桌面

C# · Windows 服务(SYSTEM 权限)
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 范畴。

四、加固清单(按重要性排序)

  1. 学生账户降为标准用户——这一条比后面所有技术手段加起来都有效。用域 / 本地组策略统一管理。
  2. Agent 装成 Windows 服务,以 SYSTEM 运行。用 sc sdset 给服务的安全描述符加上 D;;(D;;;BU),拒绝普通用户停止 / 删除。
  3. 多进程互守:主服务 + UI 进程 + 看门狗进程,三者用命名管道或共享内存互相心跳,任一被结束立刻重建对方。
  4. 状态持久化:把“当前处于锁定”写进注册表或本地文件,重启后先复现锁屏,再连服务端确认。
  5. 失联策略要明确:断网时不要自动解锁,而是保持锁定并本地提示“等待教师处理”。否则断网就成了绕过手段。
  6. 组策略收口:禁用任务管理器、限制 Ctrl+Alt+Del 选项、禁止安装 / 运行未授权程序。
  7. 密码校验只在服务端,本地只缓存“已解锁”这一状态,且状态由服务端指令翻转。
  8. 通信加时间戳与 TTLlock 指令携带有效期,防止重放或延迟生效。
  9. Worker 端做白名单,只放行 lock / unlock,绝不设计成通用命令执行通道。
  10. 日志留存:记录锁定 / 解锁时间与操作人,便于事后追溯。

五、两点提醒

下一步:如果告诉我学生机的 Windows 版本、是否有域环境、以及你倾向用 C# 还是 Go 做服务端,可以把 Agent 那部分补成可直接编译部署的完整服务代码。