☰
共享浏览器 Session 迁移:CDP 快照与登录态还原实践
2026/10/29 11:17:52 网站建设 项目流程

简介:围绕“共享浏览器与会话克隆”的实践型工程包,面向具备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/Cookies(SQLite)多数情况不能值带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 上是绑定当前用户的 DPAPI,macOS 上是钥匙串。这意味着把整个数据目录打包发到另一台机器,密钥解不开,Cookie 全是乱码。

注意:Local State里的os_crypt.encrypted_key一旦换了机器或换了系统用户就失效,这不是版本问题,是设计如此。

2.2 为什么整目录拷贝十次有八次还原不了

血泪经验里最常见的一条:直接把Default目录压缩、传过去、覆盖。看起来最省事,实际成功率极低。原因基本集中在四处。

其一是加密密钥绑定操作系统用户,换机后 Cookie 解不出来。其二是 Chromium 的单例锁:数据目录里会有SingletonLock、SingletonCookie、SingletonSocket三个文件,目标机器上如果已经有一个实例用着这个目录,新进程启动时会把命令行转发给老进程然后自己退出,你看到的现象是「命令执行成功了但端口没起来」。其三是版本差异,新版浏览器打开旧 profile 会做一次结构迁移,反向打开则可能直接拒绝。其四是服务端侧的绑定,很多系统会把会话和 User-Agent、Client Hints、时区、甚至屏幕尺寸绑在一起,你把 Cookie 搬过去但浏览器指纹全变了,第一跳就被判异常。

还有一个更隐蔽的:数据目录在浏览器运行期间不是一致状态。SQLite 有 WAL,leveldb 追加写,你 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", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) 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" PORT=9222 mkdir -p "$PROFILE_DIR" exec /usr/bin/chromium \ --user-data-dir="$PROFILE_DIR" \ --remote-debugging-port="$PORT" \ --remote-debugging-address=127.0.0.1 \ --remote-allow-origins="http://127.0.0.1:$PORT" \ --no-first-run \ --no-default-browser-check \ --window-size=1440,900 \ about:blank

逐项说明。--user-data-dir必须是独立目录,这条同时解决了单例锁冲突和「拒绝开调试端口」两个问题;--remote-debugging-port是监听端口;--remote-debugging-address=127.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; d=json.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 TZ=Asia/Shanghai \ --shm-size=1g \ sess-browser:latest \ --user-data-dir=/data/profile \ --remote-debugging-port=9222 \ --remote-debugging-address=0.0.0.0 \ --no-first-run \ --disable-dev-shm-usage

-v把 profile 目录挂出来,这是会话能跨容器存活的前提;--shm-size=1g给共享内存,默认 64MB 在渲染复杂页面时会直接崩标签页,配了--disable-dev-shm-usage则退化为写/tmp,两者取其一即可,我一般两个都上;-e TZ影响前端读到的时区,如果目标站点做时间相关的风控,这里和源端不一致会引入额外变量。容器内部监听必须是0.0.0.0才能被-p映射出去,但对外暴露的端口用127.0.0.1:9222:9222卡在本机,不要把调试端口暴露到对外网卡上——调试端口等价于浏览器完全控制权,谁连上谁就能读所有 Cookie。

--no-sandbox只在确认容器运行时已经提供隔离的前提下才考虑,配合--security-opt seccomp=unconfined一起用;能不开就不开。

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(headless=True) # storageState 在 new_context 阶段生效,等价于「先把状态灌进去再开页面」 ctx = browser.new_context( storage_state="snapshot.json", locale="zh-CN", timezone_id="Asia/Shanghai", viewport={"width": 1440, "height": 900}, ) page = ctx.new_page() resp = page.goto("https://app.example.test/dashboard", wait_until="domcontentloaded") 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_until="domcontentloaded"比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_path="snapshot.json"): problems = [] with open(state_path, encoding="utf-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(headless=True) ctx = b.new_context(storage_state=state_path, locale="zh-CN", timezone_id="Asia/Shanghai") page = ctx.new_page() resp = page.goto(PROBE, wait_until="domcontentloaded") 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 problems

PROBE要选一个「未登录必跳转、已登录返回 JSON」的接口,不要拿首页判断——首页经常对未登录用户也返回 200,只是内容不同。判定条件里我把"/login" in page.url也算失败,是因为不少系统用前端路由跳转而不是 302,只看状态码会漏。

对着这张表逐项过一遍,比人眼点十次都靠得住:

检查项判定方式失败时的动作
快照新鲜度过期 Cookie 占比低于 10%重新采集,别在旧快照上修
关键票据存在至少一条httpOnly+expires=-1检查采集时是否刚重启过进程
分区 Cookie 完整每条都带partitionKey或明确为空回查序列化代码是否裁剪过度
受保护端点返回 2xx 且 URL 未跳登录对齐 UA、时区、locale 后重试
状态落地localStorage键数量与源端一致检查还原顺序是否被前端覆盖

最后一条我吃过亏:某次快照还原后一切正常,两天后同事反馈「列表页的筛选条件每次都重置」,查下来是 localStorage 里存筛选偏好的那个键被前端在首跳时覆盖成了空数组——只因为我当时图快,先goto再补 localStorage。从那以后我每次做会话迁移,都强制把「先建上下文、后导航」这一条写进脚本骨架里,宁可多写两行也不手动操作,自检脚本不跑到全绿不往下走。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询