简介围绕“共享浏览器与会话克隆”的实践型工程包面向具备Web开发与HTTP基础、希望研究跨设备复用登录态的开发者与学习者可用于团队协作、远程办公及多终端无缝切换等场景的验证与二次开发。压缩包共129个文件约77.46MB以58个pak资源包、20个dll动态库、10个pdb调试符号为主并配有xml、ini、config等配置文件及可执行程序整体呈现基于CEF/CefSharp的浏览器运行目录结构便于直接启动与调试。已有362人学习或下载。读者可从中理解Session ID与Cookie的捕获解析、安全传输、请求头注入、代理或扩展模拟会话以及定时同步等关键环节并借助现成库文件与配置观察浏览器内核加载、资源打包和调试符号的对应关系为自行实现会话迁移、排查登录态失效问题提供可复用的参考。涉及敏感用户信息实际使用需重视加密、身份验证与数据合规。1. 共享浏览器到底在共享什么一份能搬到异地的会话现场异地同事要复现一个只在登录态下才出现的渲染问题把账号密码发过去对方在自己机器上登录页面表现还是不一样——差异从来不在账号而在那台机器上 Cookie、localStorage、Service Worker 一起构成的会话现场。共享浏览器要做的事很具体把一台机器上的会话现场整体搬到另一台机器让对方接管同一个浏览器上下文接着操作。而克隆 Session 的关键不在于复制某个文件是把登录态拆成可序列化、可校验、可还原的几层数据再按正确顺序重新写入。这套做法适合三类人要在异地复现线上问题的测试与前端、需要把已登录的自动化流程从本机迁到内网机器的工程师、以及做多设备接力调试的人。它不适合、也不该用于还原别人的会话这条边界得先划在前面。2. 拆开 session 的黑匣子Cookie、localStorage 与 CDP 快照动手之前得先弄清楚登录态到底存在哪。很多人对「浏览器会话」的理解停留在「有个 Cookie 文件」真去拷的时候才发现拷过去是废的。这一章把落盘位置、加密方式和可迁移性逐项拆开再给出一条不依赖解密的路子。2.1 登录态到底散落在哪几个位置一个已经登录的浏览器上下文至少包含六七种不同性质的状态。它们的持久化方式、能否跨机使用差别很大混在一起谈就会得出「拷目录就行」的错误结论。数据种类磁盘位置相对数据目录换机能否直接用备注持久 CookieDefault/CookiesSQLite多数情况不能值带v10/v11前缀密钥不在这个文件里会话 Cookie不落盘不能expires为空进程一退就没了localStorageDefault/Local Storage/leveldb勉强可以追加写结构实例运行中拷出来可能是半截sessionStorage内存 会话恢复文件不能生命周期只到标签页IndexedDBDefault/IndexedDB部分可以大对象多拷贝慢且容易撕裂Service Worker / Cache StorageDefault/Service Worker可以离线态、PWA 类登录强依赖HTTP 认证缓存内存不能走协商认证的内网系统经常栽在这两个容易被忽略的点。第一会话级 Cookie 根本不落盘而很多后台管理系统恰恰把最重要的票据放在会话 Cookie 里所以你拷完目录过去打开还是登录页不一定是拷错了是那条数据压根不在磁盘上。第二Default/Cookies里的值不是明文加密密钥存在数据目录根部的Local State里而这个密钥又被操作系统那一层的用户密钥保护——Windows 上是绑定当前用户的 DPAPImacOS 上是钥匙串。这意味着把整个数据目录打包发到另一台机器密钥解不开Cookie 全是乱码。注意Local State里的os_crypt.encrypted_key一旦换了机器或换了系统用户就失效这不是版本问题是设计如此。2.2 为什么整目录拷贝十次有八次还原不了血泪经验里最常见的一条直接把Default目录压缩、传过去、覆盖。看起来最省事实际成功率极低。原因基本集中在四处。其一是加密密钥绑定操作系统用户换机后 Cookie 解不出来。其二是 Chromium 的单例锁数据目录里会有SingletonLock、SingletonCookie、SingletonSocket三个文件目标机器上如果已经有一个实例用着这个目录新进程启动时会把命令行转发给老进程然后自己退出你看到的现象是「命令执行成功了但端口没起来」。其三是版本差异新版浏览器打开旧 profile 会做一次结构迁移反向打开则可能直接拒绝。其四是服务端侧的绑定很多系统会把会话和 User-Agent、Client Hints、时区、甚至屏幕尺寸绑在一起你把 Cookie 搬过去但浏览器指纹全变了第一跳就被判异常。还有一个更隐蔽的数据目录在浏览器运行期间不是一致状态。SQLite 有 WALleveldb 追加写你 cp 出来的Cookies文件可能缺少最近的事务日志还原后发现最新那条刷新令牌没了。真要拷目录必须先干净退出进程再打包。2.3 用 CDP 把会话切成语义快照绕开加密问题的思路不是去破解它而是让持有密钥的那一方主动交出来。Chromium 内核浏览器都开了 DevTools Protocol其中Storage.getCookies返回的是内存里已解密的值DOMStorage域能取到各站点的 localStorage。这条路的好处是拿到的不是字节流而是带语义的结构化数据每条 Cookie 的domain、path、expires、httpOnly、secure、sameSite以及分区 Cookie 的partitionKey都在。手上有 Playwright 的话一行就能把这件事做完不必自己拼 WebSocket 帧。import json, time from playwright.sync_api import sync_playwright DEBUG http://127.0.0.1:9222 with sync_playwright() as p: # 连接已在运行的实例而不是新起一个这样拿到的是真实登录现场 browser p.chromium.connect_over_cdp(DEBUG) ctx browser.contexts[0] # 复用默认上下文保留已打开的页面 state ctx.storage_state() # 结构{cookies: [...], origins: [...]} now time.time() # session cookie 的 expires 是 -1必须保留其余丢掉已过期的 state[cookies] [ c for c in state[cookies] if c[expires] -1 or c[expires] now ] with open(snapshot.json, w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2) print(cookies:, len(state[cookies]), origins:, len(state.get(origins, [])))connect_over_cdp的参数是调试端点地址指向第 3 章启动的那个端口contexts[0]取默认上下文因为绝大多数时候登录态就在默认上下文里storage_state()不带参数时返回完整快照也可以传path直接落盘。这里特意做了过期过滤是因为快照要跨机器传输路上可能耽搁几分钟把明显已经死掉的条目带上只会干扰后续判断——但expires -1的会话 Cookie 一定要留它往往是主票据。代价也要说清楚storage_state()覆盖的是 Cookie 和各 origin 的 localStorage不含sessionStorage、IndexedDB、Service Worker 缓存。如果你的目标站点把访问令牌放在 sessionStorage 里这条路拿到的快照是残缺的得额外用Runtime.evaluate或DOMStorage域单独抓。判断方法很简单还原后打开页面看 Network 面板里第一跳请求带没带上鉴权头。3. 起一个能被接管的实例启动参数、容器与还原顺序快照有了接下来是两头源端要有一个能被远程接管的实例目标端要按正确顺序把状态灌回去。这两步里最容易翻车的不是代码是启动参数和写入时机。3.1 三个启动参数决定能不能连上调试端口不是加上就会开。新版 Chromium 有一个硬性限制如果--user-data-dir指向默认数据目录会直接拒绝开启远程调试。所以第一步永远是给一个独立的、新建的目录。#!/usr/bin/env bash set -euo pipefail PROFILE_DIR/srv/sessions/profile-a PORT9222 mkdir -p $PROFILE_DIR exec /usr/bin/chromium \ --user-data-dir$PROFILE_DIR \ --remote-debugging-port$PORT \ --remote-debugging-address127.0.0.1 \ --remote-allow-originshttp://127.0.0.1:$PORT \ --no-first-run \ --no-default-browser-check \ --window-size1440,900 \ about:blank逐项说明。--user-data-dir必须是独立目录这条同时解决了单例锁冲突和「拒绝开调试端口」两个问题--remote-debugging-port是监听端口--remote-debugging-address127.0.0.1把监听收在本机回环上只有本机的转发进程能连别图省事写成0.0.0.0--remote-allow-origins是 111 之后版本才需要关注的点协议层会校验 WebSocket 握手的Origin头不配这一项客户端连接会返回 403而错误信息只写一个 403看不出所以然。启动后先确认端口真的活了别急着跑脚本curl -s http://127.0.0.1:9222/json/version \ | python3 -c import sys,json; djson.load(sys.stdin); print(d[Browser]); print(d[webSocketDebuggerUrl])能打印出webSocketDebuggerUrl就说明协议端点在接着用/json/list看有哪些页面目标可以接管。如果curl直接连接被拒先看进程还在不在——多半是被单例锁顶掉了删掉数据目录里残留的Singleton*文件再启动。3.2 容器里跑挂载、时区与共享内存把实例放进容器是最省心的做法会话数据目录挂成卷容器重建不影响登录态。但默认参数直接跑基本起不来三个地方要改。docker run -d --name sess-a \ -p 127.0.0.1:9222:9222 \ -v /srv/sessions/profile-a:/data/profile \ -e TZAsia/Shanghai \ --shm-size1g \ sess-browser:latest \ --user-data-dir/data/profile \ --remote-debugging-port9222 \ --remote-debugging-address0.0.0.0 \ --no-first-run \ --disable-dev-shm-usage-v把 profile 目录挂出来这是会话能跨容器存活的前提--shm-size1g给共享内存默认 64MB 在渲染复杂页面时会直接崩标签页配了--disable-dev-shm-usage则退化为写/tmp两者取其一即可我一般两个都上-e TZ影响前端读到的时区如果目标站点做时间相关的风控这里和源端不一致会引入额外变量。容器内部监听必须是0.0.0.0才能被-p映射出去但对外暴露的端口用127.0.0.1:9222:9222卡在本机不要把调试端口暴露到对外网卡上——调试端口等价于浏览器完全控制权谁连上谁就能读所有 Cookie。--no-sandbox只在确认容器运行时已经提供隔离的前提下才考虑配合--security-opt seccompunconfined一起用能不开就不开。3.3 还原顺序先 Cookie 后 localStorage 再导航这是整篇文章里最值钱的一条。还原不是「把 JSON 丢进去就行」写入和导航的先后顺序错了快照再完整也白搭。原因在于前端框架的启动逻辑。如果先goto打开页面首次请求是未登录状态服务端返回登录页或空态前端守卫会把「未登录」这个结论写进 localStorage、甚至清掉你稍后要写的键等你再补上 Cookie 刷新被覆盖的状态已经回不去了。正确顺序是先用快照建上下文Cookie 和 localStorage 一次性就位再导航。from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessTrue) # storageState 在 new_context 阶段生效等价于「先把状态灌进去再开页面」 ctx browser.new_context( storage_statesnapshot.json, localezh-CN, timezone_idAsia/Shanghai, viewport{width: 1440, height: 900}, ) page ctx.new_page() resp page.goto(https://app.example.test/dashboard, wait_untildomcontentloaded) print(resp.status, page.url) # 确认关键状态真的落地了而不是被前端重写 print(page.evaluate(() Object.keys(localStorage).length)) browser.close()storage_state传文件路径或字典都行字典适合从网络接收后直接还原locale、timezone_id、viewport这三项要和源端保持一致它们会体现在Accept-Language、Intl输出和 Client Hints 里是服务端判定「这还是不是同一个人」的常见依据。wait_untildomcontentloaded比load快做可用性判断够用如果要等前端异步校验完成再补一个page.wait_for_load_state(networkidle)。如果目标是一个已经开着的实例比如接管别人正在用的那个上下文顺序就得反过来做先Network.setCookies写 Cookie再Page.reload最后用Runtime.evaluate补 localStorage。这种情况没法完全避免首跳的未登录请求但可以先把页面导航到about:blank再写状态减少被前端覆盖的机会。4. 把快照送到异地序列化、加密与增量同步会话快照本质是一份凭据集合拿到它就等于拿到登录态。所以传输这一段不能按「传个配置文件」的标准来做得按凭据的标准来做。4.1 快照格式与字段裁剪自己定格式时建议显式带上版本号和采集时间后面排查问题时这两项能省很多事。最小可用结构大致如下。{ schema: snap:v1, capturedAt: 1730000000, ua: Mozilla/5.0 (...) Chrome/1xx.0.0.0 Safari/537.36, cookies: [ { name: sid, value: …, domain: .app.example.test, path: /, expires: -1, httpOnly: true, secure: true, sameSite: Lax, partitionKey: { topLevelSite: https://app.example.test } } ], origins: [ { origin: https://app.example.test, localStorage: [{ name: token, value: … }] } ] }裁剪的原则是「留鉴权去埋点」。_ga、_gid、各类 A/B 实验分组、性能采样标识这些都可以丢它们不影响登录判定只是把快照撑大。反过来httpOnly的 Cookie 一条都不能丢——恰恰是它们从 JS 里读不到才必须靠快照迁移。partitionKey尤其要留分区 Cookie 少了这个字段还原后会落到非分区命名空间里第三方嵌入的登录态直接失效而现象是「主站正常、内嵌页面要求重新登录」排查起来非常绕。ua字段不是给人看的是给还原端做一致性校验用的还原时把上下文的 User-Agent 设成同一个值能消掉一大类「Cookie 正确但被踢」的问题。4.2 传输前先加密密钥不出机器快照不要明文走网络也不要用同一个通道传密文和密钥。做法很朴素AES-256-GCM 加密密钥通过另一条路口头、内部密钥管理服务给对方。const crypto require(crypto); const fs require(fs); // 32 字节密钥走带外方式传递绝不和密文放在一起 const key Buffer.from(process.env.SNAP_KEY, hex); const nonce crypto.randomBytes(12); // GCM 推荐 12 字节 const cipher crypto.createCipheriv(aes-256-gcm, key, nonce); const plain fs.readFileSync(snapshot.json); cipher.setAAD(Buffer.from(snap:v1)); // 版本号绑进 AAD防跨版本重放 const body Buffer.concat([cipher.update(plain), cipher.final()]); // 布局nonce(12) | authTag(16) | 密文 fs.writeFileSync(snapshot.bin, Buffer.concat([nonce, cipher.getAuthTag(), body]));解密端按同样的布局把三段切出来setAAD和setAuthTag都必须在update之前调用否则final()会抛认证失败。AAD 里放 schema 版本的好处是如果有人把旧版本快照塞给新版本还原逻辑认证阶段就会失败而不是等到写进浏览器才发现字段对不上。拿到密文后snapshot.bin只在内存或临时目录里存在还原完立刻删。顺手在仓库根目录加一条.gitignore把snapshot.json、snapshot.bin、profile-*全排掉——提交过一次的东西历史里就永远在了。注意调试端口、快照文件、加密密钥三者任意一个泄露等价于账号被接管。别把它们放在同一台机器的同一个目录下。4.3 增量同步只搬变化的 Cookie整份快照每次全量传输对一次性迁移没问题但如果要做「源端一直开着目标端定时跟」的接力模式全量就太重了。这时候按 Cookie 的身份键做差分。def key_of(c): pk c.get(partitionKey) or {} # domain 去掉前导点.a.test 和 a.test 视为同一个站 return (c[domain].lstrip(.), c[path], c[name], pk.get(topLevelSite, )) def diff_cookies(old, new): a {key_of(c): c for c in old} b {key_of(c): c for c in new} added [b[k] for k in b.keys() - a.keys()] removed [a[k] for k in a.keys() - b.keys()] changed [b[k] for k in a.keys() b.keys() if a[k][value] ! b[k][value] or a[k][expires] ! b[k][expires]] return added, removed, changed身份键必须包含partitionKey否则分区 Cookie 和非分区 Cookie 会被判成同一条互相覆盖。domain去前导点是必须的.app.example.test和app.example.test在匹配规则上覆盖范围不同但实际业务里经常混着出现不做归一化会产生大量假「新增」。localStorage 不建议做增量整体覆盖更安全。原因是它没有身份键键名可能被前端动态生成差分出来的结果往往是「删了一堆其实还在用的键」。省下的那点带宽不值得用一次难查的线上问题去换。5. 排查清单五条踩坑记录与对应改法还原失败的现象五花八门但归因其实很集中。下面五条是按「现象、原因、解决」记录的真实类型前三条属于会话还原后两条属于连接与运行。5.1 会话还原类状态写进去了却不生效第一条还原后仍然停在登录页。现象是打开目标站点被弹回/login但打印快照里的 Cookie 条数明明是几十条。原因通常有两个一是用了「先导航再写 Cookie」的顺序首跳的未登录响应已经让前端清了一次状态二是主票据是会话 Cookie源端采集时浏览器进程刚重启过那条数据压根不存在。解决方式是改用new_context(storage_state...)的顺序并且在采集端加一道断言——快照里必须存在至少一条expires -1且httpOnly的 Cookie否则直接判定采集失败不要往下走。第二条主站正常内嵌的第三方页面反复要求授权。现象是主框架一切正常页面里嵌入的支付、地图或单点登录组件却一直转圈或跳授权。原因基本是分区 Cookie 的partitionKey在序列化时被丢了或者domain的前导点在还原时被改写导致匹配范围缩小。解决方式是序列化时原样保留partitionKey还原时不要对domain做任何「美化」处理.app.example.test就照原样写回去。第三条还原成功点两下就被踢下线。现象是首屏能开操作几个请求后服务端返回 401重新登录后一切正常。原因是服务端把会话和客户端特征绑定了User-Agent、Accept-Language、时区任意一项和源端不一致风控就判为异常。解决办法是把快照里的ua字段在还原时显式设进上下文locale和timezone_id与源端对齐viewport也保持一致。这一条没有通用解只能逐个对齐特征对齐完再验证。5.2 连接与运行类端口起来了却连不上第四条客户端连接调试端口报 403。现象是curl /json/version能通但 WebSocket 握手被拒日志里只有一个 403。原因是协议层校验握手请求的Origin头新版浏览器默认只接受白名单来源。解决办法是启动时加--remote-allow-origins把实际发起连接的来源写进去如果客户端压根不发Origin可以显式给一个匹配值。别图省事写成通配这个口子开大了等于把控制权交出去。第五条容器启动后进程立刻退出日志里没有明显错误。现象是docker ps看不到容器logs只有一两行启动信息。原因有三个高频项数据目录里残留了SingletonLock上一次没干净退出留下的进程发现「已有实例」就把命令转交然后自己退了共享内存不足导致渲染进程崩溃挂载目录的属主和容器内运行用户不匹配写不进去。解决方式是启动脚本里先清Singleton*三个文件--shm-size给到 1g 以上挂载目录提前chown成容器内用户。这三步做完仍起不来再看dmesg里有没有被 OOM 杀掉的记录。6. 上线前的一分钟自检把会话可用性写成断言会话迁移这类操作最怕的不是失败是「看起来成功了但一半功能不可用」。所以我习惯把可用性判断做成一段可以反复跑的脚本而不是靠人眼点几下页面确认。三段断言就够了快照本身是否完整、目标站点的 Cookie 是否在、受保护端点是否真的放行。第三段是决定性的前两段只是提前暴露问题。import json, time from playwright.sync_api import sync_playwright PROBE https://app.example.test/api/me def selfcheck(state_pathsnapshot.json): problems [] with open(state_path, encodingutf-8) as f: state json.load(f) now time.time() dead [c for c in state[cookies] if c[expires] ! -1 and c[expires] now] if dead: problems.append(f已过期 cookie {len(dead)} 条快照太旧) hosts {c[domain].lstrip(.) for c in state[cookies]} if app.example.test not in hosts: problems.append(目标站点无任何 cookie大概率取错了 profile) with sync_playwright() as p: b p.chromium.launch(headlessTrue) ctx b.new_context(storage_statestate_path, localezh-CN, timezone_idAsia/Shanghai) page ctx.new_page() resp page.goto(PROBE, wait_untildomcontentloaded) if resp is None or resp.status 400 or /login in page.url: problems.append(f受保护端点未放行: status{resp and resp.status} url{page.url}) b.close() return problemsPROBE要选一个「未登录必跳转、已登录返回 JSON」的接口不要拿首页判断——首页经常对未登录用户也返回 200只是内容不同。判定条件里我把/login in page.url也算失败是因为不少系统用前端路由跳转而不是 302只看状态码会漏。对着这张表逐项过一遍比人眼点十次都靠得住检查项判定方式失败时的动作快照新鲜度过期 Cookie 占比低于 10%重新采集别在旧快照上修关键票据存在至少一条httpOnlyexpires-1检查采集时是否刚重启过进程分区 Cookie 完整每条都带partitionKey或明确为空回查序列化代码是否裁剪过度受保护端点返回 2xx 且 URL 未跳登录对齐 UA、时区、locale 后重试状态落地localStorage键数量与源端一致检查还原顺序是否被前端覆盖最后一条我吃过亏某次快照还原后一切正常两天后同事反馈「列表页的筛选条件每次都重置」查下来是 localStorage 里存筛选偏好的那个键被前端在首跳时覆盖成了空数组——只因为我当时图快先goto再补 localStorage。从那以后我每次做会话迁移都强制把「先建上下文、后导航」这一条写进脚本骨架里宁可多写两行也不手动操作自检脚本不跑到全绿不往下走。希望帮到你。本文还有配套的精品资源点击获取