平时从 Google Drive 拖几十 GB 的数据集或备份,走代理往往既烧流量又受限于节点带宽;用浏览器下载还经常在中途断流后从头再来。为此我写了 gdirect:一个在终端里浏览、下载、上传和挂载 Google 云盘的工具。
这篇文章按三个部分组织:先用几分钟讲清楚它是怎么“伪装”并直连 Google 的(TL;DR),接着是开箱即用的安装与 10 分钟图文配置指南,最后再深入源码做底层技术解析。
2026-10-26 勘误:本文初版说
183.56.143.147是 Google 在广州电信的google-cn国内节点、云盘流量“物理上不过墙”。读者 Ken 来信指出这是错的,我们复核后确认:它是一台把流量转发到香港、再进入 Google 的第三方中转(反向代理);国内真正的 Google 节点也并不提供云盘服务。gdirect 0.7.0 起改为扫描 Google 自己公布的地址段,找出恰好能直连的真实 Google 前端;0.8.0 默认先试这台中转(快且稳定),不通再用扫描结果,并新增--google-only彻底不用中转。下文已全部改正,验证过程见勘误与改进。
TL;DR:我们是怎么“伪装”直连的?
在大陆网络下,Google Drive 的域名(drive.google.com、www.googleapis.com、drive.usercontent.google.com)被封锁:drive.google.com 的 DNS 被污染成别人的 IP,另外两个域名虽然能解析出真实的 Google IP,但这些 IP 在 TCP 层就被黑洞,连不上。
但 Google 的 IP 远不止 DNS 会给出的那些。Google 公布了自己全部的地址段(goog.json),其中减去租给 Google Cloud 客户的部分(cloud.json),剩下的就是 Google 自家服务的地址。这些地址上的服务器都接入 Google 的前端服务层(GFE,Google Front End:Google 安全白皮书里负责 TLS 终止、DDoS 防护和负载均衡的那一层),按 HTTP Host 头把请求路由到对应的后端服务,并且持有 *.googleapis.com、*.googleusercontent.com 的合法证书。我们实测发现,其中有一部分前端从国内可以直接连通——大概因为国内 DNS 从来不会把它们解析给用户,它们还没进封锁名单。
gdirect 就是利用这一点,用“TCP 连向一个能连通的 Google 前端 IP,TLS 与 HTTP 照常发 Google Drive 的原始请求(域名与证书)”完成直连的:
- 底层 TCP 指向扫描出的端点:绕过被污染的系统 DNS,直接连目标 IP 的 443 端口。候选只来自 Google 公布的自有地址段:先试 TCP,只对连得上的做完整验证(Google 官方证书 + Drive v3 API 响应 + 测试文件下载),凑够 8 个就停,结果存入本地 SQLite。从 0.8.0 起,默认会先试第三方中转
183.56.143.147(实测快且稳定),不通再用扫描出的 Google 前端;不想让任何第三方经手的话,加--google-only(或设置GDIRECT_GOOGLE_ONLY=1)就只用 Google 地址段内的端点。在底层套接字建立阶段(Rust 的reqwest::resolve和定制 C++libaria2的getaddrinfo钩子),哪怕下载中途发生多次 302 跨域名重定向,所有连接的目标 IP 都会被锁死在这个端点上。 - 上层 TLS / HTTP 保持真实云盘域名与证书校验;被重置时自动换“伪造身份”重试:默认发送真实域名。一旦审查设备把携带真实 SNI 的连接重置(表现为连接错误或超时),
gdirect会自动改用伪造身份再试一次——TLS 以g.cn(Google 自有、前端会出示配套证书的域名)连接并按g.cn校验证书,而 HTTPHost头依然携带www.googleapis.com等真实云盘主机名。GFE 按Host路由,云盘请求照常工作;由于 TLS 连接名与证书校验名始终一致,证书校验从头到尾没有被破坏——这也是它与“裸改 SNI”(伪造后证书就对不上了)的本质区别。能通过的身份会被记进 SQLite,之后直接用能通的那条。 - 证书校验只锚定 Google 自家的 GTS 根证书:不再信任通用 CA 包。Google 把我们用到的每个端点都签在自己的 Google Trust Services 根下,所以校验只认这几张内置根证书——即使某家第三方 CA 被攻破、中间 CA 私钥泄露(如 2026 年 10 月公开的 eMudhra 泄钥事件),也无法对
gdirect冒充 Google 做中间人。这一点对中转同样成立:TLS 原样穿过中转,它读不到令牌和文件内容。 - 端点失效自动切换:当前端点连不上时,先换另一个 TLS 身份,再依次换到过去一天内验证过的其他端点;失效的端点在数据库里标死,下载会在下一个端点上接着已下载的部分续传。
- 控制面与数据面分离:只有几 KB 的登录、账号核验与刷新令牌(
accounts.google.com、oauth2.googleapis.com、www.googleapis.com)照常走系统网络(翻墙代理;0.8.0 起在 macOS / Windows 上会读取系统 HTTP/HTTPS 代理,Linux 用HTTPS_PROXY),保证随时能登得上;真正吃带宽的目录浏览、多连接并发下载、32 MiB 分块上传全部走上述直连端点,不耗代理流量。
| 流量类型 | 涉及命令 / 域名 | 走向 |
|---|---|---|
| 云盘 API、下载、上传 | browse、ls、download、upload(www.googleapis.com、drive.usercontent.google.com、*.googleusercontent.com) |
默认先试中转 183.56.143.147,不通再用扫描到的 Google 前端(--google-only 只用后者)。默认用真实 SNI,被重置时自动回退为 g.cn 伪造身份(Host 头保持真实);端点失效时切换到下一个已验证端点。也可用 --ip、--sni / --no-sni 手动指定 |
| 登录、账号核验与刷新令牌 | login 及自动刷新(accounts.google.com、oauth2.googleapis.com、www.googleapis.com) |
系统网络(系统 DNS 与代理;读取系统 HTTP/HTTPS 代理或 HTTPS_PROXY) |
| 磁盘挂载 | mount(底层调用 rclone) |
系统网络 |
大陆网络环境特别提示: 登录类请求要走代理,传输类请求要直连。0.8.0 起最省事的配法是:代理软件用规则模式、打开系统代理、关闭 TUN——macOS / Windows 上
gdirect登录时会自动使用系统代理(Linux 设置HTTPS_PROXY),扫描和传输则绕过代理直连,不需要任何直连规则。 如果开着 TUN 模式,流量很可能被重新送进代理:一是gdirect连的是 Google 自己的海外 IP(如142.250.x.x、192.178.x.x),按GEOIP/ Google IP 段分流的规则会把它们判给代理;二是 TLS 握手里的 SNI 是云盘域名,翻墙软件嗅探(Sniff)出域名后会按gdirect scan发现几乎所有地址都能连上时会提示)。TUN 必须常开时,要让gdirect进程的传输走直连,详见下文大陆网络与代理配置指南。
安装指南
预编译二进制支持 macOS 15+(Apple Silicon / Intel)、Linux x86_64(glibc 2.35+,如 Ubuntu 22.04+、Debian 12+、Fedora、Arch)和 Windows x86_64。
方式一:一键安装脚本
macOS / Linux:
curl -fsSL https://raw.githubusercontent.com/mizorewww/gdirect/main/scripts/install.sh | bashshWindows PowerShell:
& ([scriptblock]::Create((Invoke-RestMethod 'https://raw.githubusercontent.com/mizorewww/gdirect/main/scripts/install.ps1')))powershell安装脚本会把 gdirect 放到 ~/.local/bin(Windows 为 %LOCALAPPDATA%\gdirect\bin)。Google 证书校验所需的 GTS 根证书已内置在二进制里,不需要再放置任何 CA 文件。
如果你还想用 gdirect mount 把云盘挂载成本地磁盘,在安装命令末尾加上 --with-mount(PowerShell 用 -WithMount),脚本会顺带装好依赖(rclone 1.75.2+,Linux 额外安装 fuse3,Windows 通过 winget 安装 WinFsp):
curl -fsSL https://raw.githubusercontent.com/mizorewww/gdirect/main/scripts/install.sh | bash -s -- --with-mountsh方式二:Homebrew(macOS / Linux)
brew tap mizorewww/gdirect https://github.com/mizorewww/gdirect
brew install mizorewww/gdirect/gdirect
brew install rclone # 仅挂载功能需要;Linux 另需用系统包管理器安装 fuse3sh后续升级执行 brew update && brew upgrade gdirect 即可。
Google Cloud 一次性配置指南(约 10 分钟)
为了避免共用 OAuth 客户端导致全员配额耗尽(429 限流)或被封禁,gdirect 不内置公共客户端,而是让你在自己的 Google Cloud 项目里创建一个 Desktop app(桌面应用) 客户端。只需操作一次,Google 完全免费。
1. 新建项目并启用 Drive API
-
打开 Google Cloud 控制台,点击页面顶部的项目选择器,再点击右上角的 New project(新建项目)。名称随意(例如
gdirect),个人账号的 Location(位置) 保持 No organization(无组织) 即可。点击 Create(创建) 后,确认顶部已经切换到这个新项目。
-
打开 Google Drive API 页面,确认顶部是刚才新建的项目,点击 Enable(启用)。

完成后,页面会显示服务名称
drive.googleapis.com,状态变为 Enabled(已启用)。
2. 配置 OAuth 应用
-
打开 Google Auth Platform,点击 Get started(开始)。
-
App information(应用信息):填写应用名称(比如
gdirect,登录授权时会显示),用户支持邮箱选择你自己的邮箱,点击 Next(下一步)。
-
Audience(受众群体):选择 External(外部),点击 Next(下一步)(个人账号无法选 Internal)。

-
Contact information(联系信息):填写你的邮箱,点击 Next(下一步)。
-
Finish(完成):勾选同意 Google API 服务用户数据政策,点击 Continue(继续),再点击 Create(创建)。

3. 添加 Drive 权限范围(Scope)
-
在左侧菜单打开 Data Access(数据访问),点击 Add or remove scopes(添加或移除范围)。
-
在弹出的面板中勾选
.../auth/drive(说明为“查看、修改、创建和删除您的所有 Google 云端硬盘文件”)。如果列表里找不到,直接把下面的地址粘贴到 Manually add scopes(手动添加范围) 中,点击 Add to table(添加到表),再点击 Update(更新):https://www.googleapis.com/auth/drive
-
注意:回到数据访问页面后,一定要点击底部的 Save(保存)。只点上一步的 Update 并不会真正保存。

4. 创建桌面客户端并下载 JSON
-
在左侧菜单打开 Clients(客户端),点击 Create client(创建客户端)。应用类型必须选择 Desktop app(桌面应用)(网页客户端、API Key 和服务账号 JSON 都不能用),名称随意(如
gdirect Desktop),点击 Create(创建)。
-
在弹出的窗口中,关闭之前点击 Download JSON(下载 JSON)。窗口关闭后就无法再次下载这个文件;如果不小心关掉了,重新创建一个客户端即可。

5. 允许你的账号登录(解决 7 天过期问题)
新建的应用默认处于 Testing(测试) 状态,只有测试用户列表中的账号可以登录:
-
在左侧菜单打开 Audience(目标对象),在 Test users(测试用户) 下点击 Add users,填入你要在
gdirect中使用的 Google 账号邮箱,点击 Save(保存)。
测试状态下,登录凭证会在 7 天后失效,届时执行 gdirect login --reauth 重新授权即可。
如果不想每周重新登录,可以直接在 Audience(目标对象) 页面顶部点击 Publish app(发布应用),把状态改为 In production(正式版)。自己使用的应用不需要经过 Google 审核,切到正式版后就不会再有 7 天过期的限制。
6. 在终端登录
在终端执行下面的命令,把路径换成你下载的 JSON 文件:
gdirect login --client-secret ~/Downloads/client_secret.jsonsh-
程序会自动打开浏览器(如果没有自动打开,请把终端打印的链接复制到同一台电脑的浏览器中打开,因为授权完成后要回调本机的临时端口)。
-
选择你的 Google 账号,Google 会提示“此应用未经验证”——这是正常的,因为应用是你自己刚刚创建的:
- 测试状态:点击 Continue(继续),不要点“返回到安全网页”。
- 正式版(In production):点击左下角 Advanced(高级),再点击 Go to 你的应用名 (unsafe)(转至……(不安全))。

-
核对应用名称和 Google 云端硬盘权限,确认无误后点击 Continue(继续)。

-
回到终端,看到
Logged in即大功告成。此时可以删掉下载的 JSON 文件(客户端信息已保存在本地 SQLite 中,以后切换账号或重登只需执行gdirect login --reauth)。
无界面服务器(Headless)提示:如果在没有浏览器的远程服务器上使用,可以先在桌面电脑上完成登录,或者用
gdirect login --credentials 路径导入已有的authorized_user凭据 JSON;写脚本时也可以直接通过环境变量GDIRECT_ACCESS_TOKEN传入访问令牌。
大陆网络与代理配置指南
gdirect 的流量分两类:登录、账号核验和刷新令牌(accounts.google.com、oauth2.googleapis.com、www.googleapis.com)必须走代理;扫描、云盘 API、上传和下载则要直连扫描出的端点,不能被代理接走。代理软件怎么配,决定了这两类流量能不能各走各的路。
0.8.0 起(感谢 @Liuycer 贡献的 #1),最省事的做法是规则模式 + 系统代理,关掉 TUN:登录类请求会读取系统代理设置,传输类请求本来就绕过一切应用层代理,两者天然分开,什么直连规则都不用加。下面先讲这种做法,再讲 TUN 常开时会出什么问题、该怎么配。
做法一(推荐):规则模式 + 系统代理,关闭 TUN
- 代理软件(sing-box / Mihomo / Clash 等)保持规则模式,打开“系统代理”,关闭 TUN。 确认
accounts.google.com、oauth2.googleapis.com和www.googleapis.com会走代理(常见规则集的google分组已经包含它们)。不需要添加任何DIRECT规则。 - macOS 和 Windows:
gdirect的登录、账号核验与刷新令牌会自动使用系统里手动配置的 HTTP/HTTPS 代理,直接用即可。Linux,或者系统代理用的是 PAC:给每条命令设置HTTPS_PROXY,指向代理软件的 HTTP 或 mixed 端口(HTTP_PROXY/HTTPS_PROXY优先于系统设置,mount也会把它们传给 rclone;NO_PROXY不要把上面三个域名排除掉)。传输命令里也可能刷新令牌,所以最简单是直接export到 shell 里。 - 扫描和传输不受影响:扫描、固定 IP 的 API 请求、上传和下载都显式绕过应用层代理,直连扫描出的端点。没有 TUN,代理软件根本看不到这些连接,也就谈不上截胡。
gdirect login --client-secret ~/Downloads/client_secret.json # macOS / Windows:自动走系统代理
export HTTPS_PROXY=http://127.0.0.1:7890 # Linux / PAC:换成你的代理端口
gdirect scan
gdirect download FILE_ID
gdirect --google-only download FILE_ID # 只用 Google 自己的前端,不经过第三方中转
# 手动干预仍可用:--sni 强制指定 TLS 身份,--no-sni 干脆不发 SNI(仅下载)
gdirect download FILE_ID --no-snishgdirect scan --list 会列出每个端点的延迟、需要哪种 TLS 身份,并把 Google 地址段之外的标为 [third-party relay]。即使运营商重置携带真实 SNI 的连接也不用手动处理:gdirect 会自动改用 g.cn 伪造身份重试,当前端点彻底不通时再换下一个已验证端点。
也可以登录后完全退出代理软件,只靠
gdirect直连传输。但访问令牌大约 1 小时过期,之后刷新令牌仍需要代理,所以长时间传输时最好让系统代理保持开启。
TUN 模式下,翻墙软件是怎么把直连流量截胡的?
如果你开着 TUN 虚拟网卡模式,所有流量都会先经过翻墙软件的路由引擎,gdirect 在应用层绕过代理也没用。顺着匹配逻辑看一遍:
- 目标 IP 是 Google 自己的海外前端:
gdirect在底层把连接目标换成扫描出的端点——通常是 Google 自有地址段里的前端(比如142.250.x.x、192.178.x.x),默认情况下也可能是先试的中转183.56.143.147。为了让 Google 前端按主机名正确路由并通过本机 TLS 证书校验,它在 TLS 握手(ClientHello的 SNI)和 HTTPHost头里发出的依然是www.googleapis.com、drive.usercontent.google.com和*.googleusercontent.com(启用伪造身份时 SNI 是g.cn)。 - 按 IP 分流:Google 的 IP 默认就走代理:
常见规则模板里有
GEOIP,google/RULE-SET,google-ip之类的 IP 规则,Google 的地址段整体被判给代理。这对浏览器是对的(大部分 Google IP 在国内确实不通),但gdirect好不容易扫出来的“恰好能直连”的前端也会被一并送进代理。 - 按域名分流:TUN 流量嗅探(
sniff)把连接截胡: 当gdirect向某个前端 IP 的 443 端口发起连接时,TUN 网卡拦下数据包,通过sniff(TLS 域名嗅探)读出 SNI 是drive.usercontent.google.com或www.googleapis.com。这些域名在 DustinWin/ruleset_geodata 等规则集里属于需要翻墙的google,而不是国内可直连的google-cn(后者只收录dl.google.com、g.cn、fonts.googleapis.com这类域名):- 经过
rule_set: google-cn(直连) 未命中; - 走到
rule_set: google(代理) 直接命中! - 即使目标 IP 本来会被某条直连规则放行(例如中转
183.56.143.147是国内 IP,会命中GEOIP,CN),在主流模板里域名规则优先于 IP 规则,嗅探出的云盘域名照样把它拉回代理。登录要求www.googleapis.com走代理,偏偏传输用的也是这个域名,这就是 TUN 模式下两类流量难以分开的根源。
- 经过
- TUN 会替所有地址“接通” TCP:
TUN 模式下,翻墙软件会先在本地接受连接,再自己去连目标。于是
gdirect scan的第一步 TCP 探测几乎全部“成功”,测到的其实是代理而不是你的网络。scan看到一批地址里 90% 以上都能连上时会打印警告,提示你把 Google IP 设为直连后再扫。
做法二:TUN 必须常开时,让传输绕开嗅探与 IP 规则
如果 TUN 必须 24 小时开着,目标是:登录类请求照常按域名走代理,gdirect 的传输连接则直连。 有两种配法:
- 按进程直连(桌面端最简单):在
GEOSITE,google、GEOIP,google等规则之前,先放登录与刷新令牌的代理规则,再放PROCESS-NAME,gdirect,DIRECT。代价是gdirect login时核验账号的那次www.googleapis.com请求也会被这条进程规则判为直连而失败,所以登录那一下要临时切回做法一(关 TUN、开系统代理),或暂时停用这条进程规则。登录很少发生(正式版应用只需一次),之后的刷新令牌只用oauth2.googleapis.com,排在进程规则前面,照常走代理。 - 对扫描出的 IP 关闭嗅探:在代理软件的嗅探设置里把
gdirect scan --list列出的端点排除掉,再让这些 IP 直连(或者干脆让 Google 的 IP 段直连)。这样传输连接不带域名,www.googleapis.com的代理规则就只会命中登录请求。缺点是端点会随扫描变化,IP 段直连还会让浏览器里的 Google 服务断掉。
注意:普通浏览器访问
www.googleapis.com时连的是 DNS 给出的、在国内被黑洞的 IP,不会像gdirect这样换成扫描出的可用前端。所以不要为了gdirect把云盘域名或 Google 的 IP 段整体设为DIRECT,那会让浏览器里依赖它们的网页和 Google 服务全部断掉。fake-ip 也不影响传输:gdirect直接连 IP、不查 DNS,只是scan时从 DNS 拿到的198.18.x.x假地址会被丢弃,候选少几个而已。
以 Mihomo(Clash) 为例,按进程直连的配法是在配置文件的 GEOSITE,google、GEOIP,google 等规则之前加入:
rules:
# 1. 登录与刷新令牌走代理(必须排在进程规则之前)
- DOMAIN,accounts.google.com,PROXY
- DOMAIN,oauth2.googleapis.com,PROXY
# 2. gdirect 进程的其余流量(扫描、API、下载、上传)强制直连,
# 不被 sniff 出的域名规则或 Google IP 规则截胡。
# gdirect login 时请临时关 TUN 改用系统代理,或停用这一条
- PROCESS-NAME,gdirect,DIRECTyaml如果你用的是 sing-box,同样可以在 route.rules 的 rule_set: ["google"] 和 Google IP 规则之前,先放 oauth2.googleapis.com 的代理规则,再放一条 process_name: gdirect 的直连规则。
日常使用与排障速查
常用命令
gdirect # 等同于 gdirect browse,打开交互式 TUI
gdirect browse -o ~/Downloads # 指定下载目录(默认 ./downloads)
gdirect ls root --json # 列出目录内容(支持 JSON 输出)
gdirect download 文件ID或分享链接 # 下载文件,断流重跑同一命令即可续传
gdirect download FILE_ID -o file.zip
gdirect download DOC_ID --export application/pdf -o doc.pdf
gdirect upload ./file.zip # 32 MiB 分块上传,支持断点续传
gdirect upload ./file.zip --parent FOLDER_ID --name backup.zip
gdirect mount # 挂载到 ~/GoogleDrive(macOS 走原生 NFS 免 FUSE)
gdirect unmount # 卸载并清理临时导出的凭据
gdirect scan # 重新扫描;scan --list 查看结果sh0.6.0 起支持多个 Google 账号,共用同一个 OAuth 客户端:
gdirect login --add # 在浏览器里登录另一个账号
gdirect accounts # 列出账号,* 为默认账号
gdirect switch work # 把 work@… 设为默认
gdirect -a me@gmail.com browse # 只在这一条命令里用某个账号(也可设 GDIRECT_ACCOUNT)sh新版 browse 仿照 yazi 的三栏布局,并排显示上级文件夹、当前文件夹和预览,支持多选标记、整目录下载和云端文件操作。按 ? 可查看全部按键:
| 按键 | 操作 |
|---|---|
j / k、↑ / ↓ |
选择 |
g / G、Home/End |
跳到第一项 / 最后一项 |
Ctrl-d / Ctrl-u、PgDn/PgUp |
向下或向上翻半页 / 一页 |
Enter、l / → |
进入文件夹 |
h / ←、Backspace |
返回上级 |
/ |
按名称筛选当前文件夹。Enter 保留筛选,Esc 清除 |
Ctrl-r、F5 |
刷新 |
| 空格 | 标记或取消标记;切换文件夹后标记仍保留 |
Ctrl-a |
标记当前显示的所有项目(再按一次取消) |
| Esc | 依次清除筛选、标记、剪贴板 |
a |
新建空文件;名称以 / 结尾则新建文件夹 |
r |
重命名 |
y / x |
复制 / 剪切已标记的项目(没有标记时为当前选中的项目) |
p |
粘贴到当前文件夹。剪切的项目会被移动,复制的文件会生成副本 |
D、Delete |
把已标记的项目移到 Drive 回收站(会先确认) |
u |
上传本地文件到当前文件夹 |
d |
把已标记的项目加入下载队列。文件夹会连同所有子文件夹一起下载;Google 文档、表格、幻灯片导出为 PDF |
+ / - |
调整下一个下载的并发连接数(1–16),并保存为默认值 |
c |
取消当前下载并保留已下载部分 |
R |
重试失败或已取消的下载 |
X |
移除尚未开始的下载 |
q |
退出 |
下载逐个进行,文件列表会显示每个文件的状态(排队、下载中、已保存、失败),选中失败的文件可以看到完整错误。
如果遇到特殊网络环境,还支持手动干预传输层参数:
--google-only(全局选项,也可设GDIRECT_GOOGLE_ONLY=1):不使用任何第三方中转,扫描和传输只用 Google 公布的自有地址段内的端点;--ip IP:强制使用指定的 IP,跳过自动扫描结果,也不做端点切换;--sni HOST/--no-sni:手动指定 TLS 身份,或干脆不发 SNI(--sni也适用于ls、upload;--no-sni仅下载)。不指定时自动使用扫描出的身份,失败自动回退;--ca-cert FILE:用指定的 CA 证书包代替内置的 Google GTS 根证书;-j N:仅对当前命令设置并发连接数(1–16);gdirect scan --ips FILE:探测自定义候选 IP 列表(每行一个公网 IP,#后为注释)。列表里的地址不受 Google 地址段限制,但不在段内的会被标为[third-party relay]。
常见报错速查表
| 现象 | 处理方法 |
|---|---|
access_denied 或提示应用正在测试 |
把当前账号加为测试用户,或发布应用(见配置指南第 5 步) |
redirect_uri_mismatch |
下载的 JSON 不是 Desktop app 客户端,按第 4 步重新创建 |
Drive API 403 … insufficient permission |
在客户端所在的同一个项目中启用 Drive API(第 1 步),并保存 Drive 权限范围(第 3 步底部点 Save);然后执行 gdirect login --reauth |
refresh credential expired or revoked |
执行 gdirect login --reauth。如果每 7 天出现一次,把应用从测试状态发布为正式版(第 5 步) |
scan 提示 almost every address answered TCP |
TUN 代理在替所有地址接通连接,扫描测的是代理。按上文关闭 TUN,或让 gdirect 进程 / Google IP 走直连后重扫 |
no usable Google front end found |
当前网络没扫到能用的端点。先排除 TUN 代理;仍不行可过段时间重扫(可直连的前端会变化),或用 gdirect scan --ips FILE 提供自己的候选 |
下载中途报 libaria2 code 1(连接或 TLS 失败) |
真实 SNI 被运营商重置或端点失效。gdirect 会先换 g.cn 身份重试,再换到下一个已验证端点续传,并把失效端点标死;所有端点都失败时才报错,重跑同一命令会重新扫描。也可手动 --sni g.cn / --no-sni |
登录时报 cannot verify the account 或 cannot reach Google's token endpoint |
登录、账号核验和刷新令牌没能走代理。确认代理软件开了系统代理且 accounts.google.com、oauth2.googleapis.com、www.googleapis.com 走代理;Linux 或 PAC 下设置 HTTPS_PROXY;TUN 下按进程直连时,登录那一下请临时关 TUN(见做法二) |
scan --list 里某个 IP 带 [third-party relay] |
这个地址不在 Google 公布的地址段里,是第三方中转(如默认先试的 183.56.143.147)。它读不到流量内容,但能看到你的 IP;介意就用 --google-only |
勘误与改进:183.56.143.147 不是 Google
本文初版发出后,读者 Ken 来信指出了几处事实错误:183.56.143.147 并不是 Google 的服务器;国内的 google-cn 节点不会替你访问云盘;“GFE”也不是一类 IP 或一种服务器。我们花了一天逐条复核,他说的都对。这一节把复核过程、新的做法和它的局限写清楚。感谢 Ken 认真读完并写信。
初版错在哪里
初版的说法是:183.56.143.147 是 Google 在广州电信的 google-cn 边缘节点;这些国内节点跑着 Google 的前端(GFE),经 Google 内网连到云盘后端;所以 gdirect 连它,流量“物理上不过墙”。扫描也以它为内置种子。
这个 IP 来自 Sheas-Cealer 使用的 Cealing-Host 列表,那里所有 Google 条目都指向同一个 IP。它出示的是 Google 的真证书,Drive API 也能正常返回,我们当时据此认定它就是 Google,没有再查它到底是谁。
验证一:Google 看到的“客户端”是谁
Google 有个诊断接口 https://redirector.googlevideo.com/report_mapping?di=no,会返回它看到的客户端 IP。我们在中国联通上海的宽带上测试(绑定物理网卡,绕开本机 VPN):
- 直接请求时,Google 报告的是我们自己的 IP;
- 经
183.56.143.147请求时,Google 报告的是122.10.103.81——一个香港的地址(AS136897,Enjoyvc)。
也就是说,连接是从香港进入 Google 的。再查归属:whois 显示 183.56.143.147 属于 CHINANET 广东;它和 122.10.103.81 都不在 Google 公布的地址段(goog.json)里。结论:它是一台第三方 TCP 中转(反向代理),把流量转发到香港,再从那里连到 Google。
它不碰 TLS,只是原样转发字节流,所以客户端看到的是 Google 真正的 GTS 证书,gdirect 也照常按 Google 的 GTS 根校验通过。这意味着:
- 中转读不到:访问令牌、文件内容、请求路径,全部在端到端的 TLS 里;它也无法伪造证书做中间人。
- 中转看得到:你的 IP、流量大小和时间;以及在不用伪造身份时,TLS SNI 里的主机名(比如
www.googleapis.com)。用g.cn伪造身份时,它只看到一个连往g.cn的连接。
验证二:国内真正的 Google 节点能不能用
Google 在国内确实有节点,但它们不是初版说的那样。我们测了两类:
203.208.40.x/203.208.41.x,登记在 GOOGLECN(北京谷翔,Google 的中国实体)名下;- 运营商网内的 Google 缓存节点,比如联通上海网内的
211.95.34.x——国内 DNS 给dl.google.com、fonts.googleapis.com、g.cn、www.gstatic.com返回的就是它们。
它们都能建立 TCP 和 TLS,出示的也是 Google 证书。但按 Host 头分别请求各个服务,结果一目了然:
| 请求的主机 | 结果 |
|---|---|
www.google.cn、fonts.googleapis.com |
200 |
www.gstatic.com/ipranges/goog.json |
200 |
www.googleapis.com/drive/v3/… |
404 |
oauth2.googleapis.com |
404 |
drive.usercontent.google.com |
404 |
它们只服务一份白名单,云盘不在其中。所以“国内节点经 Google 内网连到云盘”这条路并不存在。(不过它们能返回 Google 的 IP 段列表,后面会用到。)
验证三:其他直觉上的候选
- 国内 DNS 给
www.googleapis.com、drive.usercontent.google.com返回的是真的 Google IP(74.125.x、172.217.x、142.250.157.x等),但 TCP 直接超时:被黑洞了。 drive.google.com的 DNS 被污染,返回的69.63.184.30、128.242.240.59根本不是 Google。- Firebase Hosting 和 Google Cloud 负载均衡的 IP 出示的是客户自己的证书,不能用来访问 Google API。
- 一个 Cloud Storage 的 IP(
142.250.73.123)能连通,但它是 GCS 专用前端:不管Host写什么,都按 GCS 回答。
验证四:扫描 Google 自己的前端
既然 DNS 给出的 IP 都被封了,那 DNS 不给的呢?Google 公布了自己全部的地址段(goog.json)和租给 Cloud 客户的部分(cloud.json),两者相减就是 Google 自家服务的地址。我们扫描了其中前端最集中的几块(142.250.0.0/15、172.217.0.0/16、172.253.0.0/16、173.194.0.0/16、74.125.0.0/16、192.178.0.0/15、64.233.160.0/19、66.102.0.0/20、108.177.0.0/17、209.85.128.0/17、216.58.192.0/19、216.239.32.0/19、108.170.192.0/18、72.14.192.0/18),约 2500 个 /24,每个 /24 试 12 个主机号(.4 .14 .91 .100 .113 .123 .132 .138 .155 .187 .207 .219)。对每个地址:先试 TCP 443;连得上的,用真实 SNI 和 g.cn 两种身份各做一次完整验证——无凭证请求 Drive API 必须返回预期的 401 JSON,公开测试文件必须下载正确。
漏斗约 2500 个 /24 × 12 个主机号
- 探测地址
- 30528
- TCP 443 有应答
- 1423
- 4.7%
- 完全可用
- +104
- Drive API + 测试下载均通过
- 分布
- 约 65 个 /24
- 按 /24 成簇,192.179.50/60/61 各有 7 个
性能从同一条联通线路测得
- TCP 建连
- 268 ms
- p50;p90 为 511 ms
- 单连接下载
- 4–6 MB/s
- 27 MB 文件,Host 为 dl.google.com
每个可用地址都同时通过了真实 SNI 和 g.cn 伪造身份两种验证。
| 原因 | 数量 |
|---|---|
| 证书不匹配 | 约 1030 |
| TLS 超时或重置 | 约 290 |
证书不匹配的大多是只服务 Cloud Storage 的前端;TLS 超时 / 重置可能是按 SNI 的干扰。
主机号的规律也很明显:.187、.207、.219 一个都没成功过(都是 Cloud Storage 专用前端);.100、.113、.155、.132、.138、.91 成功率最高。
它们真的是 Google,而且是直连吗? 三个证据:地址都在 goog.json 里;whois 归属 Google LLC;从联通线路 traceroute,路径直接进入 Google 骨干网(142.251.x、192.178.x、172.253.x),中间没有任何第三方。
为什么这些前端没被封? 我们的推测是:国内 DNS 从来不会把它们解析给用户,所以它们还没进封锁名单。这也意味着用的人多了,它们随时可能被封——这正是 gdirect 不内置固定 IP 列表、而是每次动态扫描的原因。
顺带澄清:GFE 是什么
初版把“GFE”当成一类节点在用,这不准确。Google 的服务器有 GWS(Google Web Server)、GVS(视频服务器)、GGC(运营商网内的缓存节点)等类别;而 Google Front End(GFE)是 Google 基础设施里的一个组件,Google 安全白皮书里描述它负责 TLS 终止、DDoS 防护和负载均衡,再把请求转给后端服务。本文现在只在这个意义上使用“GFE”:它是 Google 的前端服务层,按 Host 路由请求;它不是一个 IP 类别。文中说“Google 前端”时,指的是 Google 自有地址段里由这一层对外服务的服务器。
gdirect 0.7.0 / 0.8.0 改了什么
- Google 地址段内置并每周更新(0.7.0,
src/ranges.rs、src/google-ranges.txt、scripts/update-ranges.py):内置一份 goog.json 减去 cloud.json 的快照,扫描时每周更新一次。更新先走系统网络——在国内,www.gstatic.com解析到的正是上面那种运营商网内的 Google 缓存,它会返回 IP 段列表——不通再经一个已验证的端点去取;明显不完整的列表会被拒绝。 - 扫描改为 Google 前端扫描(0.7.0,
src/scan.rs):候选只来自 Google 地址段内:之前验证过的端点、DNS 结果(被污染的自然落在段外、被丢弃)、我们这次扫出可用前端的 /24(src/frontend-seeds.txt),再是前端集中的核心地址块,最后是其余地址,每部分为每个用户打乱顺序。只试成功率高的主机号。每批 2048 个地址先做 TCP 探测,再对有应答的做完整验证;某个地址可用,就优先试它所在 /24 的其他地址;凑够 8 个已验证端点即停。几乎所有地址都能连上时提示 TUN 代理在拦截。 - 端点切换(0.7.0,
Api::deliver与下载):当前端点连不上时,先换 TLS 身份,再依次换到一天内验证过的其他端点;下载在新端点上续传。 - 中转的处理:0.7.0 曾把
183.56.143.147从扫描里去掉,并从已有数据库里清除一次。实际用下来,这台中转速度快、也很稳定,所以 0.8.0 改为默认先试它,不通再用扫描出的 Google 前端,并取消了 0.7.0 的清除。同时新增全局选项--google-only(环境变量GDIRECT_GOOGLE_ONLY=1):扫描跳过中转,传输只用 Google 地址段内的端点。无论哪种模式,Google 地址段之外的端点在scan和scan --list里都会标为[third-party relay]。 - 登录走系统代理(0.8.0,@Liuycer 贡献的 #1):登录、账号核验和刷新令牌会使用 macOS / Windows 的 HTTP/HTTPS 系统代理(以及
HTTPS_PROXY),关掉 TUN、开着规则模式的系统代理就能登录;扫描、固定 IP 的 API 请求、上传和下载仍然直连。
怎么选:中转读不到你的令牌和文件,但它的运营者能看到你的 IP、流量规模和时间,以及(不用伪造身份时)你连的是哪个 Google 主机,而且我们不知道运营者是谁。介意这些,就用 --google-only;需要时也可以用 --sni g.cn 强制伪造身份,让中转连主机名也看不到。
为什么不内置网上搜到的反向代理
用 fofa 这类网络空间搜索引擎,能搜出不少出示 Google 证书的反向代理,有人建议直接内置一批。我们没有这么做:它们多半是别人配置失误、无意中对外开放的服务器,未经授权批量使用是在蹭别人的机器和带宽;运营者能看到每个用户的 IP 和访问时间;而且这类服务器往往很快就会被关掉或被封,内置进去很快就成了死地址。183.56.143.147 是一个公开列表里长期使用的中转,我们把它明确标为第三方、默认先试、并提供 --google-only 关掉;除此之外,gdirect 不会主动去用任何第三方服务器。你自己找到的候选仍然可以用 gdirect scan --ips FILE 测试,段外的同样会被标出来。
局限
- 以上全部结果来自一条线路(中国联通上海)、一个地点、一天(2026-10-26)。电信、移动和其他地区没有验证过,可直连的前端可能完全不同,也可能一个都没有。
- 这些前端能直连,很可能只是因为还没被注意到。它们可能随时被封,被封后
gdirect只能重新扫描、换别的地址;扫不到时就只能回到中转或代理。 - 单连接 4–6 MB/s、建连 p50 268 ms,是跨境直连的水平,谈不上“跑满宽带”;多连接并发会快一些,但取决于线路。
如果你在其他运营商或地区测出了结果(不管能不能用),欢迎告诉我。
技术解析:底层是怎么实现的?
如果你对 gdirect 内部是如何用 Rust 配合定制 libaria2 实现上述机制感兴趣,下面结合仓库源码逐一拆解。
1. 候选从哪来:Google 自有地址段与扫描顺序
第一次运行传输类命令时,如果本地 SQLite 里没有 24 小时内验证过的可用端点,src/scan.rs 就会启动扫描。0.8.0 默认先探测已知的中转 183.56.143.147(--google-only 时跳过),然后照样扫描 Google 前端,作为中转失效时的备用:
336 if !google_only {
337 let relays: Vec<IpAddr> = RELAYS.iter().filter_map(|r| r.parse().ok()).collect();
338 eprintln!("Checking known relays (not Google; use --google-only to skip)…");
339 record(
340 db,
341 &google,
342 verify(&relays, usize::MAX),
343 |_| true,
344 &mut warned,
345 )?;
346 }中转只有一个,写死在 RELAYS 里,并在注释里写明它不是 Google。传输时,usable 决定端点的使用顺序:中转排第一,其余按延迟;--google-only 则直接丢掉 Google 地址段之外的所有端点,包括你用 scan --ips 手动加进来的:
32/// Known third-party relays that forward to Google. Not Google: the operator
33/// sees client IPs and, without the forged identity, the hostname, but TLS
34/// stays end to end and is verified against Google's roots. They are fast and
35/// stable in mainland China, so they are tried first unless --google-only.
36const RELAYS: [&str; 1] = ["183.56.143.147"];
37fn relay(ip: IpAddr) -> bool {
38 RELAYS.iter().any(|r| r.parse() == Ok(ip))
39}
40/// Endpoints in the order transfers should use them: known relays first
41/// (unless `google_only`, which drops everything outside Google's space),
42/// then the rest as given (fastest first).
43pub fn usable(
44 routes: Vec<crate::store::Route>,
45 google: &Ranges,
46 google_only: bool,
47) -> Vec<crate::store::Route> {
48 let mut routes: Vec<_> = routes
49 .into_iter()
50 .filter(|r| !google_only || google.contains(r.ip))
51 .collect();
52 routes.sort_by_key(|r| !relay(r.ip));
53 routes
54}194fn direct_route(db: &store::Store, route: &Route, google_only: bool) -> Result<Direct> {
195 let explicit = route.sni();
196 let auto = explicit.is_none();
197 if let Some(ip) = route.ip {
198 return Ok(Direct {
199 ip,
200 sni: explicit,
201 auto,
202 backups: Vec::new(),
203 });
204 }
205 let google = ranges::current(db);
206 let mut routes = scan::usable(db.routes(16)?, &google, google_only);
207 if routes.is_empty() {
208 scan::run(db, None, google_only)?;
209 routes = scan::usable(db.routes(16)?, &google, google_only);
210 }
211 let identity = |sni: String| (!sni.is_empty()).then_some(sni);
212 let mut routes = routes.into_iter().map(|r| (r.ip, identity(r.sni)));
213 let (ip, stored) = routes.next().context("no verified endpoint")?;
214 Ok(Direct {
215 ip,
216 sni: explicit.or(stored),
217 auto,
218 backups: routes.take(8).collect(),
219 })
220}下面关于 Google 前端扫描的代码摘自 0.7.0,这部分在 0.8.0 里没有变。
扫描的边界是 src/ranges.rs 里的 Google 自有地址段:goog.json 是 Google 拥有的全部地址,cloud.json 是其中可以分配给 Google Cloud 客户的部分,两者相减,剩下的就是 Google 自家服务的地址。程序内置一份快照,每周从 www.gstatic.com 更新一次;如果下载到的列表少得不像话(被截断或被篡改),宁可沿用旧的也不接受:
128 /// Google's own space from the published feeds: everything Google owns
129 /// minus what Google Cloud customers can be assigned.
130 pub fn from_feeds(goog: &str, cloud: &str) -> Result<Self> {
131 let list = |json: &str| -> Result<Self> {
132 let data: serde_json::Value = serde_json::from_str(json)?;
133 let prefixes = data["prefixes"]
134 .as_array()
135 .context("IP range feed has no prefixes")?;
136 Self::from_prefixes(
137 prefixes
138 .iter()
139 .filter_map(|p| p["ipv4Prefix"].as_str().or(p["ipv6Prefix"].as_str())),
140 )
141 };
142 let (goog, cloud) = (list(goog)?, list(cloud)?);
143 let ranges = Self {
144 v4: subtract(&goog.v4, &cloud.v4),
145 v6: subtract(&goog.v6, &cloud.v6),
146 };
147 // A truncated or hostile feed must not shrink the trusted space to nothing.
148 ensure!(ranges.v4.len() >= 16, "IP range feed looks incomplete");
149 Ok(ranges)
150 }候选按固定的优先级排队,而且只收 Google 地址段内的地址:之前验证过的端点和 DNS 结果排最前(被污染的 DNS 结果落在段外,自动丢弃),然后是我们扫出过可用前端的 /24(src/frontend-seeds.txt,67 个),再是前端最集中的核心地址块,最后是其余地址。每一部分都为每个用户随机打乱,免得所有人都挤在同几个前端上:
96/// Sweep order: earlier verified endpoints, DNS answers, seed /24s, then
97/// Google's core front-end blocks and finally the rest of its space, each
98/// shuffled. Only addresses inside `google` are ever considered.
99fn plan(
100 google: &Ranges,
101 known: &[IpAddr],
102 dns: &[IpAddr],
103 shuffle: &mut Shuffle,
104) -> VecDeque<IpAddr> {
105 let core = Ranges::parse(&CORE.replace(' ', "\n")).expect("core blocks must parse");
106 let seeds = Ranges::parse(SEEDS).expect("seed list must parse");
107 let mut seen = HashSet::new();
108 let mut order = VecDeque::new();
109 let mut add = |ip: IpAddr, order: &mut VecDeque<IpAddr>| {
110 if public(ip) && google.contains(ip) && seen.insert(ip) {
111 order.push_back(ip);
112 }
113 };
114 for &ip in known.iter().chain(dns) {
115 add(ip, &mut order);
116 }
117 let mut seed_nets: Vec<u32> = seeds.slash24s().collect();
118 shuffle.apply(&mut seed_nets);
119 let (mut core_nets, mut rest_nets): (Vec<u32>, Vec<u32>) = google
120 .slash24s()
121 .filter(|n| !seeds.contains(IpAddr::V4(Ipv4Addr::from(*n))))
122 .partition(|n| core.contains(IpAddr::V4(Ipv4Addr::from(*n))));
123 shuffle.apply(&mut core_nets);
124 shuffle.apply(&mut rest_nets);
125 for net in seed_nets.into_iter().chain(core_nets).chain(rest_nets) {
126 for ip in hosts(net) {
127 add(ip, &mut order);
128 }
129 }
130 order
131}每个 /24 只试 7 个主机号。这来自前面那次扫描的统计:.187、.207、.219 从没成功过(都是 Cloud Storage 专用前端),所以直接不试:
16const LIMIT: usize = 256;
17/// Verified endpoints to collect before a sweep stops.
18const TARGET: usize = 8;
19/// Upper bound on TCP probes in one sweep.
20const MAX_PROBES: usize = 60_000;
21/// Addresses swept per batch: TCP first, then full verification of what answered.
22const BATCH: usize = 2048;
23const TCP_WORKERS: usize = 256;
24const TCP_TIMEOUT: Duration = Duration::from_secs(2);
25/// Host numbers inside a /24 where Google's general front ends answer. .187,
26/// .207 and .219 are Cloud Storage-only front ends and never serve Drive.
27const OFFSETS: [u8; 7] = [100, 113, 155, 132, 138, 91, 123];
28/// Blocks that hold most of Google's general front ends; swept before the
29/// rest of Google's space.
30const CORE: &str = "142.250.0.0/15 172.217.0.0/16 172.253.0.0/16 173.194.0.0/16 74.125.0.0/16 192.178.0.0/15 64.233.160.0/19 66.102.0.0/20 108.177.0.0/17 209.85.128.0/17 216.58.192.0/19 216.239.32.0/19 108.170.192.0/18 72.14.192.0/18";
31const SEEDS: &str = include_str!("frontend-seeds.txt");在进入探测前,public(ip) 会先把私网、环回、链路本地地址以及 198.18.0.0/15(Clash / Mihomo fake-ip 默认网段)全部过滤掉。
2. 两段式扫描:先 TCP,再完整验证
大部分 Google 地址在国内连 TCP 都不通,对每个地址都做完整的 TLS + API 验证太慢。所以扫描分两段:每批 2048 个地址,先用 256 个线程各做一次 2 秒超时的 TCP 连接;只有连得上的,才交给 8 个线程做完整验证。某个地址验证通过后,它所在 /24 的其他主机号会被插到队首——可用的前端往往成簇出现。凑够 8 个已验证端点就停:
321 while good < TARGET && probed < MAX_PROBES && !queue.is_empty() {
322 let batch: Vec<IpAddr> = std::iter::from_fn(|| queue.pop_front())
323 .filter(|ip| tried.insert(*ip))
324 .take(BATCH)
325 .collect();
326 probed += batch.len();
327 let answered = tcp_sweep(&batch);
328 open += answered.len();
329 // Real networks block most of Google; a TUN proxy accepts every connect.
330 if batch.len() >= 256 && answered.len() * 10 >= batch.len() * 9 && !intercepted {
331 intercepted = true;
332 eprintln!(
333 "warning: almost every address answered TCP, so a TUN proxy or VPN is probably intercepting; results reflect the proxy, not your direct network. Route Google IPs DIRECT for a real scan"
334 );
335 }
336 let candidates: Vec<IpAddr> = answered.into_iter().map(|(ip, _)| ip).collect();
337 reached.extend(&candidates);
338 let verdicts = verify(&candidates, TARGET - good);
339 let working = record(db, &google, verdicts, |ip| known.contains(&ip), &mut warned)?;
340 good += working.len();
341 // Front ends come in groups: the rest of a working /24 goes next.
342 for ip in working.iter().rev() {
343 if let IpAddr::V4(v4) = ip {
344 for neighbour in hosts(u32::from(*v4) & !0xff) {
345 if !tried.contains(&neighbour) {
346 queue.push_front(neighbour);
347 }
348 }
349 }
350 }
351 eprintln!(" … probed {probed}, {open} answered TCP, {good} verified");
352 }注意循环里那条 TUN 警告:真实网络会挡掉绝大多数 Google 地址,如果一批里 90% 以上都能连上,几乎可以肯定是 TUN 代理在本地替所有地址接通了连接。
完整验证对每个地址最多探两遍:先真实 SNI,失败再换 g.cn 伪造身份:
172 let (result, sni) = match probe(ip, false) {
173 Ok(ok) => (Ok(ok), ""),
174 Err(real) => match probe(ip, true) {
175 Ok(ok) => (Ok(ok), crate::api::FORGED_HOST),
176 Err(forged) => (Err(format!("{real:#} | forged: {forged:#}")), ""),
177 },
178 };189// A missing or changed public test file is reported as a warning, not as a dead IP.
190//
191// `forged` selects the fallback identity: the URL (and therefore SNI and
192// certificate name) is g.cn while the Host header still carries the real
193// Google host. Google's front end routes by Host, so the same probes work.
194fn probe(ip: IpAddr, forged: bool) -> Result<(i64, Option<String>)> {
195 use crate::api::{API_HOST, FORGED_HOST};
196 const FILE_HOST: &str = "drive.usercontent.google.com";
197 let (api_url, file_url) = if forged {
198 (FORGED_HOST, FORGED_HOST)
199 } else {
200 (API_HOST, FILE_HOST)
201 };
202 let client = crate::api::client_builder()
203 .no_proxy()
204 .redirect(reqwest::redirect::Policy::none())
205 .connect_timeout(Duration::from_secs(3))
206 .timeout(Duration::from_secs(5))
207 .resolve(api_url, SocketAddr::new(ip, 443))
208 .resolve(file_url, SocketAddr::new(ip, 443))
209 .build()?;
210 let start = Instant::now();
211 let response = client
212 .get(format!("https://{api_url}/drive/v3/about?fields=user"))
213 .header(reqwest::header::HOST, API_HOST)
214 .send()
215 .map_err(|e| e.without_url())?;
216 ensure!(
217 response.status() == reqwest::StatusCode::UNAUTHORIZED,
218 "unexpected Drive API status {}",
219 response.status()
220 );
221 let data: serde_json::Value = response.json()?;
222 ensure!(data["error"]["code"] == 401, "invalid Drive API response");
223 let response = client
224 .get(format!(
225 "https://{file_url}/download?id=0B9P1L--7Wd2vU3VUVlFnbTgtS2c&export=download&confirm=t"
226 ))
227 .header(reqwest::header::HOST, FILE_HOST)
228 .send()
229 .map_err(|e| e.without_url())?;
230 let status = response.status();
231 let warning = if matches!(
232 status,
233 reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::NOT_FOUND
234 ) {
235 Some(format!("HTTP {status}"))
236 } else {
237 ensure!(status.is_success(), "Drive download probe HTTP {status}");
238 let mut bytes = Vec::new();
239 response.take(32).read_to_end(&mut bytes)?;
240 (bytes != b"spam\n").then(|| "unexpected content".into())
241 };
242 Ok((start.elapsed().as_millis() as i64, warning))
243}这段探测逻辑有几个关键设计:
.no_proxy()+.resolve():强制绕过环境变量代理,在客户端内部把连接直接绑定到待测的ip:443,同时按内置的 GTS 根走完整的 TLS 证书校验(杜绝连上运营商拦截页、客户证书的 Cloud 负载均衡或中间人)。- 无凭证 API 校验:不携带任何用户 Token 请求
GET /drive/v3/about?fields=user。真正服务 Drive API 的前端一定会返回 HTTP401 Unauthorized且 JSONerror.code == 401;国内那些只服务白名单的 Google 节点返回 404,Cloud Storage 专用前端则过不了证书校验。既验证了应用层路由,又不会在探测未知 IP 时泄露用户的 Bearer Token。 - 下载链路探针:接着请求一个公开的小测试文件。如果测试文件本身因 Google 限流返回 403/404,只要前一步 Drive API 校验通过,该 IP 依然会被标记为可用并打印警告,避免因为公开测试文件失效而把正常 IP 全部误杀。
- 双身份探测:伪造身份下,连接 URL、TLS SNI 与证书校验名全部变成
g.cn,但Host头通过.header(HOST, API_HOST)显式设回真实主机名。GFE 按Host路由,所以同一套 API/测试文件探针可以端到端地验证伪造路径真的能跑通——能通过的身份会被存进endpoints表的sni列,gdirect scan --list也会标注每个 IP 需要哪种身份。 - 归属标注:扫描结果打印时会再查一次地址是否在 Google 地址段内,不在的标为
[third-party relay]。默认扫描碰到的段外地址只有内置的中转183.56.143.147;gdirect scan --ips FILE则会照单探测你给的任何地址,段外的同样标出来。
想自己提供候选 IP? 把 IP 每行一个存成
ips.txt,执行gdirect scan --ips ips.txt,探测仪会逐个验证证书、Drive API 和测试下载。注意:国内 DNS 给dl.google.com、g.cn等google-cn域名返回的运营商网内 Google 节点不服务云盘,放进去也会全部失败;更有希望的是 Google 地址段内、DNS 平时不会给出的前端地址。
3. 伪造身份回退与端点切换
扫描解决的是“连哪个 IP”,但实际网络里还有两个变数:用什么身份连,以及这个 IP 明天还通不通。有些审查设备不封 IP 本身,而是精确重置 TLS ClientHello 里携带真实 Google 域名(SNI)的连接——这是下载中途“连接突然被掐断”的典型成因,单纯换 IP 解决不了;而我们扫出来的前端本身也可能随时被封。
gdirect 的应对是把所有固定 IP 的请求路径统一套上 src/api.rs 里的 deliver 包装:遇到连接错误或超时,先在当前端点上换另一个 TLS 身份重试;还不行,就把当前端点在数据库里标死,依次换到过去一天内验证过的其他端点(每个端点先用它记录的身份,再试另一个)。哪条路走通了,就在当前进程内保留下来,并把能用的身份持久化到数据库里该 IP 的 sni 列:
145 /// Sends one request. On a connect/timeout failure it is retried under
146 /// the other TLS identity (real <-> forged g.cn), then on each backup
147 /// endpoint in turn; dead endpoints are marked in SQLite. Whatever works
148 /// is kept for this process and the working identity is persisted.
149 pub(crate) fn deliver(
150 &self,
151 build: impl Fn(&Client, Option<&str>) -> reqwest::blocking::RequestBuilder,
152 ) -> Result<reqwest::blocking::Response> {
153 let (client, ip, forged) = {
154 let live = self.live.lock().unwrap();
155 (live.client.clone(), live.ip, live.forged.clone())
156 };
157 let send = |client: &Client, forged: Option<&str>| {
158 build(client, forged)
159 .header(reqwest::header::HOST, API_HOST)
160 .send()
161 .map_err(|e| e.without_url())
162 };
163 let transport = |e: &reqwest::Error| e.is_connect() || e.is_timeout();
164 let first = match send(&client, forged.as_deref()) {
165 Err(error) if ip.is_some() && transport(&error) => error,
166 result => return Ok(result?),
167 };
168 let db = Store::open(&self.db).ok();
169 let mut endpoint = ip.map(|ip| (ip, forged.clone()));
170 let mut tried_current = true;
171 let mut last = first;
172 while let Some((ip, preferred)) = endpoint {
173 // The identity already tried on the current endpoint is skipped.
174 let mut identities = Vec::new();
175 if !tried_current {
176 identities.push(preferred.clone());
177 }
178 if self.auto {
179 identities.push(match &preferred {
180 Some(_) => None,
181 None => Some(FORGED_HOST.to_owned()),
182 });
183 }
184 for identity in identities {
185 let client = client_for(Some(ip), identity.as_deref())?;
186 match send(&client, identity.as_deref()) {
187 Ok(response) => {
188 let mut live = self.live.lock().unwrap();
189 live.client = client;
190 live.ip = Some(ip);
191 live.forged = identity.clone();
192 if let Some(db) = &db {
193 let _ = db
194 .set_sni(&ip.to_string(), identity.as_deref().unwrap_or_default());
195 }
196 return Ok(response);
197 }
198 Err(error) if transport(&error) => last = error,
199 Err(error) => return Err(error.into()),
200 }
201 }
202 if let Some(db) = &db {
203 let _ = db.endpoint(&ip.to_string(), None, Some("connection failed"), "");
204 }
205 tried_current = false;
206 endpoint = self
207 .live
208 .lock()
209 .unwrap()
210 .backups
211 .pop_front()
212 .map(|(ip, stored)| {
213 // An explicit --sni applies to every endpoint.
214 (ip, if self.auto { stored } else { forged.clone() })
215 });
216 }
217 Err(last.into())
218 }几个设计点:
- 只有传输层错误才触发:连接错误和超时才会换身份、换端点;HTTP 层错误(如 403)直接返回,不会被误判成审查。
- 手动指定优先:显式传了
--sni时(auto为假)不会悄悄换身份,换端点时也沿用你指定的身份;传了--ip时只用这一个端点,不做切换。 - 记忆分两级:进程内的
Live结构让后续请求直接用能通的端点和身份;store::set_sni把结果写进 SQLite,换一条命令也记得:
107 /// The fallback identity proved itself: make it the stored one for this IP.
108 pub fn set_sni(&self, ip: &str, sni: &str) -> Result<()> {
109 self.conn.execute(
110 "UPDATE endpoints SET sni=?,available=1,verified_at=? WHERE ip=?",
111 params![sni, now(), ip],
112 )?;
113 Ok(())
114 }下载(libaria2 路径)同样两级回退。aria2 错误码 1/2/6/19 分别是 TLS/连接失败、超时、网络问题、域名解析失败——正是 SNI 被重置或端点被封的直接表现。命中这些码时,先换身份重下一次;仍不行就把该端点标死,换到下一个已验证端点,在已下载的部分上继续(aria2 的 .aria2 控制文件让续传天然成立);所有端点都用完才报错,下一条命令自动重新扫描:
156// TLS handshake, connect and DNS failures: exactly what the fallback route is for.
157fn transport(code: i32) -> bool {
158 matches!(code, 1 | 2 | 6 | 19)
159}
160fn mark_dead(args: &Args) {
161 let (Some(ip), Some(db)) = (args.ip, &args.db) else {
162 return;
163 };
164 if let Ok(db) = crate::store::Store::open(db) {
165 let _ = db.endpoint(
166 &ip.to_string(),
167 None,
168 Some("download transport failure"),
169 "",
170 );
171 }
172}233 let mut sni = args.sni.clone();
234 let mut tried_alt = false;
235 for attempt in 0..3 {
236 let mut code = download(
237 &args,
238 &url,
239 token.as_deref(),
240 sni.as_deref(),
241 progress.as_deref_mut(),
242 )?;
243 loop {
244 if code != 0 && args.auto && !tried_alt && transport(code) {
245 tried_alt = true;
246 sni = match &sni {
247 Some(_) => None,
248 None => Some(crate::api::FORGED_HOST.to_owned()),
249 };
250 code = download(
251 &args,
252 &url,
253 token.as_deref(),
254 sni.as_deref(),
255 progress.as_deref_mut(),
256 )?;
257 }
258 if code == 0 || !transport(code) || args.backups.is_empty() {
259 break;
260 }
261 // This endpoint is gone: resume the partial file on the next one.
262 mark_dead(&args);
263 let (ip, stored) = args.backups.remove(0);
264 if progress.is_none() {
265 eprintln!(
266 "Endpoint {} failed; resuming via {ip}",
267 args.ip.map(|i| i.to_string()).unwrap_or_default()
268 );
269 }
270 args.ip = Some(ip);
271 sni = if args.auto { stored } else { args.sni.clone() };
272 tried_alt = false;
273 code = download(
274 &args,
275 &url,
276 token.as_deref(),
277 sni.as_deref(),
278 progress.as_deref_mut(),
279 )?;
280 }
281 if code != 0 {
282 if transport(code) {
283 mark_dead(&args);
284 }
285 bail!(
286 "download failed: {} (libaria2 code {code}); partial data is kept, rerun to resume",
287 aria2_error(code)
288 );
289 }上传走的也是 deliver(可恢复会话的创建与每个分块的发送都包着同样的回退),于是 API、下载、上传三条传输路径全部具备身份回退和端点切换能力。
4. 定制 libaria2:为什么必须给 C++ 源码打补丁
对于 ls 和 upload,Rust 层的 reqwest 配合 .resolve() 就够用了;但下载大文件需要多连接分段并发和断点续传,gdirect 选择把 aria2 1.37.0 裁剪成纯 HTTP/HTTPS + OpenSSL 3 的静态库 libaria2 嵌进二进制。
问题在于:Google Drive 的下载请求经常会发生跨域名 302 重定向(例如从 www.googleapis.com 或 drive.google.com 重定向到 drive.usercontent.google.com 乃至动态生成的 *.googleusercontent.com)。在原版 aria2 里,如果你只是修改初始 URL,一旦跟随 302 跳转到新域名,aria2 又会去查系统 DNS;而如果你把 URL 里的域名硬改成 IP,TLS 证书校验又会立刻因为主机名不匹配而失败。
为此,gdirect 在构建 libaria2 时打了一个约 120 行的补丁(vendor/aria2-1.37.0.patch 配合 vendor/gdirect_route.h),在 C++ 底层做了五处改动:
(1)Google Drive 传输域名白名单
先在 vendor/gdirect_route.h 中收拢所有受控的 Google Drive 域名,并定义三个路由钩子变量:gdirect_ip 是扫出的直连 IP;gdirect_sni 是 TLS 身份(空指针 = 真实 SNI、空字符串 = 不发 SNI、主机名 = 伪造身份);gdirect_hosthdr 记住伪造时真实的 Google 主机名,专供 HTTP Host 头使用:
1// gdirect's narrow routing hook for the pinned aria2 1.37.0 build.
2#pragma once
3#include <cstring>
4#include <string>
5extern const char* gdirect_ip;
6// Empty means no override. "" as a set value means "send no SNI"; a hostname
7// means the forged identity: connect and verify as that name while the HTTP
8// Host header carries gdirect_hosthdr, the real Google host.
9extern const char* gdirect_sni;
10extern std::string gdirect_hosthdr;
11inline bool gdirect_host(const std::string& h) {
12 const std::string suffix = ".googleusercontent.com";
13 return h == "drive.google.com" || h == "drive.usercontent.google.com" ||
14 h == "www.googleapis.com" || h == "content.googleapis.com" ||
15 h == "drive.googleapis.com" ||
16 (h.size() > suffix.size() && h.compare(h.size()-suffix.size(), suffix.size(), suffix) == 0);
17}(2)在 SocketCore.cc 拦截 getaddrinfo 与 TLS SNI
SocketCore 真正调用 getaddrinfo 建立连接时,不仅命中 gdirect_host(host) 的主机名会被替换成扫描出的 gdirect_ip(并加 AI_NUMERICHOST 跳过 DNS),伪造身份下的 g.cn 也会被钉到同一个 IP 上——否则换了身份就又去查一次系统 DNS。同一处还接管了 tlsSession_->setSNIHostname():
23@@ -936,7 +942,9 @@
24 // "localhost" will produce TLS alert with GNUTLS.
25 if (tlsctx->getSide() == TLS_CLIENT && !util::isNumericHost(hostname) &&
26 hostname.find(".") != std::string::npos) {
27- rv = tlsSession_->setSNIHostname(hostname);
28+ const char* sni = gdirect_host(hostname) ? gdirect_sni : nullptr;
29+ rv = (sni && !*sni) ? TLS_ERR_OK :
30+ tlsSession_->setSNIHostname(sni ? std::string(sni) : hostname);
31 if (rv != TLS_ERR_OK) {
32 throw DL_ABORT_EX(fmt(EX_SSL_INIT_FAILURE,
33 tlsSession_->getLastErrorString().c_str()));
34@@ -1450,6 +1458,12 @@
35 hints.ai_flags = getDefaultAIFlags();
36 hints.ai_flags |= flags;
37 hints.ai_protocol = protocol;
38+ if (host && gdirect_ip && *gdirect_ip &&
39+ (gdirect_host(host) ||
40+ (gdirect_sni && *gdirect_sni && strcmp(host, gdirect_sni) == 0))) {
41+ host = gdirect_ip;
42+ hints.ai_flags |= AI_NUMERICHOST;
43+ }
44 return getaddrinfo(host, service, &hints, resPtr);
45 }- 当
gdirect_sni为nullptr(默认)时,正常发送原域名作为 SNI; - 当伪造身份生效时(
gdirect_sni指向g.cn),连接名与 SNI 都变成g.cn,证书也按g.cn校验; - 当传入
--no-sni时(gdirect_sni为空字符串""),直接返回TLS_ERR_OK跳过设置,让 OpenSSL 发出不带 SNI 扩展的ClientHello。
(3)在 Request.cc 完成身份调换:连接用 g.cn,Host 头用真实域名
伪造身份的关键一跳在 Request::parseUri:解析 URL 时如果发现是受控的 Google 域名且伪造身份已开启,就把 URI 的主机名换成 g.cn(此后连接、SNI、证书校验都按 g.cn 走),把真实主机名存进 gdirect_hosthdr;HttpRequest 拼 Host: 头时优先使用它。这样线路上看到的是一个无害的 g.cn 连接,Google 前端却仍然按真实云盘主机名路由:
58@@ -150,13 +152,24 @@
59 {
60 currentUri_ = removeFragment(srcUri);
61 uri::UriStruct us;
62- if (uri::parse(us, currentUri_)) {
63- us_.swap(us);
64- return true;
65+ if (uri::parse(us, currentUri_) && us.protocol == "https") {
66+ if (gdirect_sni && *gdirect_sni && gdirect_host(us.host)) {
67+ // Forged identity: the connection, SNI and certificate name become
68+ // gdirect_sni; the real host is kept only for the HTTP Host header.
69+ gdirect_hosthdr = us.host;
70+ us.host = gdirect_sni;
71+ us_.swap(us);
72+ return true;
73+ }
74+ // Real hosts pass through unchanged; a redirect relative to an already
75+ // swapped URI lands on the forged host and keeps its Host header.
76+ if (gdirect_host(us.host) ||
77+ (gdirect_sni && *gdirect_sni && us.host == gdirect_sni)) {
78+ us_.swap(us);
79+ return true;
80+ }
81 }
82- else {
83- return false;
84- }
85+ return false;
86 }100@@ -190,7 +192,9 @@
101 builtinHds.emplace_back("Accept-Encoding:", acceptableEncodings);
102 }
103 }
104- builtinHds.emplace_back("Host:", getHostText(getURIHost(), getPort()));
105+ builtinHds.emplace_back("Host:", gdirect_hosthdr.empty()
106+ ? getHostText(getURIHost(), getPort())
107+ : gdirect_hosthdr);(4)跨域重定向自动剥离 Authorization 头与重定向白名单
下载私有文件时,初始请求发往 https://www.googleapis.com/drive/v3/files/{id}?alt=media 并携带 Authorization: Bearer <token>。一旦 302 跳转到 *.googleusercontent.com 的临时签名 URL,如果继续携带 Authorization 头,Google 内容节点会因为同时收到两套认证信息而报错,也有跨域名泄露令牌的风险。因此补丁在 HttpRequest.cc 中规定:只有当真实主机名(gdirect_hosthdr 非空时用它,否则用 URI 主机名)是 www.googleapis.com 时才输出 Authorization: 头——注意这里必须按真实主机名判断而不是连接名,否则伪造身份下令牌会被误剥离;同时 Request.cc 限制所有重定向目标必须是 https 且满足 gdirect_host() 或已是伪造主机本身。
(5)去掉 OpenSSL 3 Legacy Provider 依赖
原版 aria2 在 OpenSSL 3 下启动时会无条件调用 OSSL_PROVIDER_load(nullptr, "legacy")(为了 BitTorrent 加密里的 RC4)。在没有安装 legacy 模块的系统上会导致启动直接崩溃。补丁用 #ifdef ENABLE_BITTORRENT 把它围了起来,使纯 HTTP 静态构建不再依赖任何外部 OpenSSL 模块。
5. 证书校验只信任 Google 自家的 GTS 根
抗审查不只是“让连接建立起来”,还要保证建立起来的连接真的连到了 Google。gdirect 曾经随包附带一份完整的 cacert.pem CA 包(数百张受信根证书),但通用 CA 包意味着:只要其中任何一家 CA 被攻破或误发证书,攻击者就能对 googleapis.com 做中间人,静默篡改下载内容或截获 OAuth 令牌。2026 年 10 月 eMudhra 中间 CA 私钥公开泄露就是现实例子——而 OpenSSL/webpki 这类客户端根本不做吊销检查,泄露的密钥在过期前一直有效。
但 Google 把我们用到的每个端点(googleapis.com、googleusercontent.com、g.cn)都签在它自家的 Google Trust Services 根下。所以 gdirect 干脆把几张 GTS 根证书直接内嵌进二进制,校验时只认这几张根,其他 CA 签发的任何链条一律拒绝:
19/// Google issues every endpoint we touch under its own Google Trust Services
20/// roots. Anchoring verification there (instead of a full CA bundle) means a
21/// compromised or mis-issuing third-party CA cannot impersonate Google for
22/// this client, even with a publicly leaked intermediate key.
23pub(crate) const GTS_PEM: &str = include_str!("gts-roots.pem");
24pub(crate) fn client_builder() -> reqwest::blocking::ClientBuilder {
25 let mut builder = Client::builder().tls_built_in_root_certs(false);
26 const BEGIN: &str = "-----BEGIN CERTIFICATE-----";
27 const END: &str = "-----END CERTIFICATE-----";
28 for block in GTS_PEM.split(BEGIN).skip(1) {
29 let end = block.find(END).expect("bundled GTS root must be complete") + END.len();
30 builder = builder.add_root_certificate(
31 reqwest::Certificate::from_pem(format!("{BEGIN}{}", &block[..end]).as_bytes())
32 .expect("bundled GTS root must parse"),
33 );
34 }
35 builder
36}reqwest 侧用 tls_built_in_root_certs(false) 关掉系统 CA 包、逐张加载 GTS 根;libaria2 侧则把同一份 PEM 写到状态数据库旁边,作为 ca-certificate 传给 aria2:
366fn bundled_ca() -> Option<PathBuf> {
367 let dir = dirs::data_local_dir()?.join("gdirect");
368 fs::create_dir_all(&dir).ok()?;
369 let path = dir.join("gts-roots.pem");
370 match fs::read(&path) {
371 Ok(existing) if existing == crate::api::GTS_PEM.as_bytes() => {}
372 _ => fs::write(&path, crate::api::GTS_PEM).ok()?,
373 }
374 Some(path)
375}这样即使审查者部署了由某家被攻破 CA 签发的假 Google 证书,gdirect 也会直接拒绝握手。随包分发的 cacert.pem 副文件从此消失;--ca-cert 仍保留为显式覆盖入口。
6. 下载防呆、大文件病毒扫描确认页与 MD5 校验
在 Rust 侧的 src/download.rs 中,围绕 libaria2 还有三层保障。
已存在文件的安全检查:如果目标文件已存在且没有对应的 .aria2 断点控制文件,先算一次本地 MD5 与云端 md5Checksum 比对——匹配则直接提示 Already downloaded,不匹配则拒绝覆盖,绝不把无关文件当成半截下载:
207 // Never treat an unrelated existing file as a resumable download.
208 let control = PathBuf::from(format!("{}.aria2", args.output.display()));
209 if args.output.exists() && !control.exists() {
210 ensure!(
211 matches_md5(&args.output, args.md5.as_deref())?,
212 "output already exists; choose another -o path"
213 );
214 if progress.is_none() {
215 println!("Already downloaded {}", args.output.display());
216 }
217 return Ok(());
218 }公开大文件的病毒扫描确认页与落盘校验:
290 let mut prefix = Vec::new();
291 fs::File::open(&args.output)?
292 .take(1024 * 1024)
293 .read_to_end(&mut prefix)?;
294 // Public downloads can return the virus-scan confirmation form with HTTP 200.
295 // Only Drive's own HTML pages are inspected; other files stay untouched.
296 if token.is_none() && html_page(&prefix) {
297 let text = String::from_utf8_lossy(&prefix);
298 if text.contains("download-form") {
299 let next = drive::confirmation(&text, &url)?;
300 ensure!(attempt < 2, "Drive confirmation loop");
301 fs::remove_file(&args.output)?;
302 url = next;
303 continue;
304 }
305 if text.contains("accounts.google.com/ServiceLogin")
306 || text.contains("Google Drive - Quota exceeded")
307 || text.contains("Google Drive - Access denied")
308 {
309 bail!(
310 "Drive returned a login/quota error page; saved for inspection at {}",
311 args.output.display()
312 );
313 }
314 }
315 ensure!(
316 args.md5.is_none() || matches_md5(&args.output, args.md5.as_deref())?,
317 "MD5 does not match Drive's checksum; the file is kept at {} for inspection",
318 args.output.display()
319 );
320 if progress.is_none() {
321 println!("Saved {}", args.output.display());
322 }
323 return Ok(());- 确认页只认真正的 HTML:未登录状态下通过分享链接下载大文件时,Google Drive 会返回 HTTP 200 的 HTML 确认页(包含
<form id="download-form">)。gdirect只在文件头部确实是<!doctype html或<html时解析该表单,拼出带confirm/uuid的真实下载地址自动重下,绝不会误伤内容里恰好带有download-form字样的普通压缩包或源码文件。 - 落盘后 MD5 校验:下载完成后再次流式计算整个文件的 MD5 并与 Drive 元数据核对,确保多连接切片拼接毫无差错。
7. 32 MiB 分块可恢复上传
上传模块(src/upload.rs)基于 Google Drive v3 的 Resumable Upload 协议。考虑到跨国直连链路的 RTT 较高,分块过小会导致大量时间浪费在等待每个块的确认上,因此将单次分块大小设为 32 MiB。所有请求都包在第 3 节的 deliver 里,所以上传同样具备身份回退和端点切换能力。
每次开始上传时,程序以 (本地绝对路径, 目标父目录 ID, 云端文件名) 为键查询本地 SQLite,向云端探测已有会话的已接收偏移量:
85 let mut session = saved.filter(|s| s.size == size && s.modified == modified);
86 if let Some(s) = &session {
87 let url = session_url(&s.url)?;
88 let response = self.deliver(|client, forged| {
89 client
90 .put(crate::api::gate(forged, &url))
91 .bearer_auth(&token)
92 .header("Content-Range", format!("bytes */{size}"))
93 .header("Content-Length", 0)
94 })?;
95 match response.status() {
96 StatusCode::OK | StatusCode::CREATED => {
97 let result = response.json()?;
98 db.unset(&key)?;
99 return Ok(result);
100 }
101 StatusCode::PERMANENT_REDIRECT => start = offset(&response, size)?,
102 StatusCode::NOT_FOUND | StatusCode::GONE => session = None,
103 _ => return Err(failure(response)),
104 }
105 }只要本地文件的大小和纳秒级修改时间没有变化,中断后重跑同一条命令就会先发送 Content-Range: bytes */{size} 探测云端进度,从 308 Permanent Redirect 返回的 Range 头里拿到断点偏移量继续上传。
8. 本机 Loopback + PKCE 授权流
在 src/auth.rs 中,gdirect login 实现了无需外部 SDK 的 OAuth 2.0 Loopback + PKCE(RFC 7636 S256) 流程:
196 let listener = TcpListener::bind(("127.0.0.1", 0))?;
197 listener.set_nonblocking(true)?;
198 let redirect = format!(
199 "http://127.0.0.1:{}/callback",
200 listener.local_addr()?.port()
201 );
202 let state = random_string()?;
203 let verifier = random_string()?;
204 let mut url = url::Url::parse("https://accounts.google.com/o/oauth2/v2/auth")?;
205 url.query_pairs_mut().extend_pairs([
206 ("client_id", client_id),
207 ("redirect_uri", redirect.as_str()),
208 ("response_type", "code"),
209 ("scope", SCOPE),
210 ("access_type", "offline"),
211 ("prompt", "consent select_account"),
212 ("state", state.as_str()),
213 ("code_challenge", challenge(&verifier).as_str()),
214 ("code_challenge_method", "S256"),
215 ]);它在 127.0.0.1:0 上开启一个临时的非阻塞 TCP 监听,校验浏览器回调的 state 参数防 CSRF,再用授权码和 code_verifier 向 https://oauth2.googleapis.com/token 换取 refresh_token。落盘的 SQLite 数据库和临时导出的 rclone 凭据文件在创建时即强制设为 0600 权限,unmount 或挂载失败时也会自动清理导出的临时凭据文件。