前几天听说了 Surge 这个据称是 macOS 下最好的网络分析工具,试用了一下发现确实好用,而且关键的是颜值很在线,深得我心,遂入正,从 Clash 完全转来了 Surge。
虽然但是,Surge 存在一个很大的问题,就是虽然功能强大,但是它的配置实在是太不现代化了,一个形如 .ini 的格式,但是又允许重复键名,这让自动化解析、生成它的配置文件多了许多困难,最终我在尝试 Python 的 Configparser 发现行不通后,还是老老实实的回到了使用 f.write() 和字符串、正则表达式重新构建了整个配置文件。
折腾完了基本的配置之后,显然就要开始折腾一些高级功能了。在使用 Surge 之前,我就听闻过它可以通过 MiTM 中间人攻击来解锁一些应用的高级功能,于是就开始了这次的折腾。
CAUTION
This project is for educational purposes only. Please do not use it for commercial purposes.
完整的配置教程、脚本代码、镜像源代码均已开源:
https://github.com/zhuozhiyongde/Unlocking-Raycast-With-Surge ↗
利用 Surge 的 MiTM 功能拦截请求,并利用 Docker 服务模拟后端操作,从而实现 Raycast 的激活。
Docker 服务是一个简单的 Raycast ↗ 的 API 代理。它允许您在不订阅的情况下使用包括 Raycast AI ↗ 、翻译、同步在内的 Pro 功能(但是都需要你拥有自己的 API Key)。实现原理如下:
- Raycast Pro:在服务端修改
/me、/ai/models等关键请求的返回字段。 - Raycast AI:对于非 AI Completions 之外的某些请求的返回值修改关键字段,而对于 AI Completions 的请求,将 Raycast 的请求转换格式转发到 OpenAI 的 API,响应后再实时转换格式返回。
- Translator:目前的翻译功能实现也是基于 OpenAI 的,会很慢,你可以自行换成别的商业 API 。
- Sync:基于本地 JSON 处理,没有用到数据库,避免了额外的配置,但是会有一定的性能损失。
Docker 服务修改自 yufeikang/raycast_api_proxy ↗。由于我在迁移的时候出现了问题,新建此仓库后失去了 fork 的属性。但保留了 .git 贡献历史。
在开始之前,请确保你拥有以下条件:
- Surge 激活版本
- 一台云服务器,拥有 Docker、公网 IP
- 一个域名
- 一个 Open AI 格式的 API Key 与对应的 Base URL
本文用到的的服务器配置为:Ubuntu 22.04,1Panel + OpenResty + Docker
鉴于会折腾 MiTM 的朋友应该是有一定基础的,本文我主要给出具体的配置文件,还有自己的一些踩坑。
整体的配置思路如下:
Raycast - Surge MiTM 覆写请求 URL - 云服务器 - Nginx 反向代理 - Docker 后端。
使用方法#
在服务端启动后端服务#
由于 Raycast 的 AI 功能请求与返回都进行了包装,直接请求类似 OpenAI 的接口并不可行,需要使用一个转接器来模拟 Raycast 的后端转接格式,这就需要我们自行搭建并运行后端。
基于 Docker 的方法#
此 Docker 镜像修改自 yufeikang/raycast_api_proxy ↗,修复了一些迁移到服务器上会导致的问题。
docker run --name Raycast \ -e OPENAI_API_KEY=sk-xxxx \ -e OPENAI_BASE_URL=https://api.openai.com/v1/ \ -p 12443:80 -e LOG_LEVEL=INFO -d arthals/raycast-backend:latestbash
这会在你的服务器上的 12443 端口启动一个 Raycast 的 API 代理服务,你需要自行修改 OPENAI_API_KEY 与 OPENAI_BASE_URL。
这里存在两处需要注意的地方:
- 原仓库给的示例是
OPENAI_API_BASE,但是由于openai库升级了,所以现在需要使用OPENAI_BASE_URL。 - 原仓库给的示例有一个
--dns 1.1.1.1的配置,但是由于众所周知的原因,腾讯云的服务器无法访问这个 DNS 服务器,所以你需要移除这个配置。
注意 OPENAI_BASE_URL 的计算方式如下:
如果你的 API 请求的 URL 是 https://api-proxy.com/v1/chat/completions ↗ ,那么就是 https://api-proxy.com/v1/ ↗
为了使用此代理,你还需要按照下文设置 Surge MiTM 与服务器 Nginx 配置以支持流式传输。
SSL 证书直接通过 1Panel 的 Nginx(Openresty)搞定就行。与 Surge 和服务器后端均无关。
本仓库只支持服务器使用,本地使用需要能自行签发 SSL 证书。若本地使用建议参照原仓库操作。
基于 Python + PM2 的方法#
你亦可以简单的使用 Python 和 PM2 来启动服务:
# 安装依赖pip install -r requirements.txt# 启动服务pm2 start 'OPENAI_API_KEY="sk-xxxx" OPENAI_BASE_URL="https://api.openai.com/v1/" uvicorn app.main:app --host 0.0.0.0 --port 12443 --reload' --name Raycastbash
Nginx 服务端配置#
按照正常的 1Panel + OpenResty 配置一个反向代理网站,将 https://custome-backend.self.com/ 代理到 http://127.0.0.1:12443/ 即可。
特别地,你需要修改反向代理的配置文件,移除默认的压缩算法以支持流式传输,你可以参见:https://github.com/lobehub/lobe-chat/discussions/531 ↗
修改后的反向代理配置文件示例如下:
location ^~ / { proxy_pass http://127.0.0.1:12443; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_http_version 1.1; add_header Cache-Control no-cache; proxy_cache off; # 关闭缓存 proxy_buffering off; # 关闭代理缓冲 chunked_transfer_encoding on; # 开启分块传输编码 tcp_nopush on; # 开启TCP NOPUSH选项,禁止Nagle算法 tcp_nodelay on; # 开启TCP NODELAY选项,禁止延迟ACK算法 keepalive_timeout 300; # 设定keep-alive超时时间为65秒}nginx
Surge MiTM 配置与激活脚本#
本质是劫持 Raycast 的请求,将请求转发到服务器上。配置如下:
[MITM]skip-server-cert-verify = trueh2 = truehostname = *.raycast.comca-passphrase = ...ca-p12 = MIIKP...
[Script]raycast-activate-backend.raycast.com = type=http-request,pattern=^https://backend.raycast.com,max-size=0,debug=1,script-path=activator.jsplaintext
请注意,你需要将 ca-passphrase 与 ca-p12 替换为你自己的 Surge CA 密码与证书。
其中用到的 activator.js 脚本修改自 wibus-wee/activation-script ↗,你需要自行修改其中的 https://custome-backend.self.com 为你的服务器地址。
请将 activator.js 放置在 Surge 配置文件夹下。
Activator.js 内容如下(亦可在本仓库找到):
'use strict'
function transformToString(obj) { if (typeof obj === 'object') { return JSON.stringify(obj) } return obj}/** * 构建 Surge 响应体 * * @param props 响应体属性 * @description 该函数将会自动将对象转换为 JSON 字符串,因此你可以直接传入对象 */function buildResponse(props) { if (props.body) { props.body = transformToString(props.body) } // console.log(props.body); $done({ ...props })}/** * 发送通知 * * @param title 标题 * @param subtitle 副标题 * @param body 内容 * @description 该函数将会自动将对象转换为 JSON 字符串,因此你可以直接传入对象 */function sendNotification(title, subtitle, body) { title = transformToString(title) subtitle = transformToString(subtitle) body = transformToString(body) $notification.post(title, subtitle, body)}const methods = ['get', 'put', 'delete', 'head', 'options', 'patch', 'post']/** * 发送请求 * @param props 请求参数 * @param callback 回调函数 */const httpClient = {}for (let method of methods) { // @ts-ignore httpClient[method] = (props, callback) => { $httpClient[method](props, callback) }}
/** * @url https://backend.raycast.com/api/ */function raycastActivate() { $done({ url: $request.url.replace('https://backend.raycast.com', 'https://custome-backend.self.com'), headers: $request.headers, body: $request.body })}
const activator = { raycast: { base: 'https://backend.raycast.com/api/', activate: [ { base: '*', func: raycastActivate } ] }}
const url = $request.url/** * Determine whether the URL matches the base */function isMatchBase(url, base) { if (Array.isArray(base)) { for (let item of base) { if (url.includes(item)) { return true } } return false } return url.includes(base)}/** * Automatic execution of the corresponding function according to the URL */function launch() { for (let module in activator) { if (isMatchBase(url, activator[module].base)) { for (let key in activator[module]) { if (key === 'base') continue if (Array.isArray(activator[module][key])) { for (let custom of activator[module][key]) { // 检查 custom.base 是否为通配符 '*',如果是,则匹配任何以 activator[module].base 开头的URL if (custom.base === '*' && url.startsWith(activator[module].base)) { return custom.func() } // 否则,检查精确匹配 else if (url === `${activator[module].base}/${custom.base}`) { return custom.func() } } continue } if (typeof activator[module][key] === 'object') { // 检查是否为通配符 '*',如果是,则匹配任何以 activator[module].base 开头的URL if (activator[module][key].base === '*' && url.startsWith(activator[module].base)) { return activator[module][key].func() } if (url === `${activator[module].base}/${activator[module][key].base}`) { return activator[module][key].func() } } else if (!url.includes(`${activator[module].base}/${key}`)) { continue } if (typeof activator[module][key] === 'function') { return activator[module][key]() } } } } console.log(`[activator] ${url} is not matched`) returnDefaultResponse() $done() return}function returnDefaultResponse() { console.log(`[activator] returnDefaultResponse: ${url} - ${$request.method.toLowerCase()}`) // @ts-expect-error httpClient[$request.method.toLowerCase()]( { url: $request.url, headers: $request.headers }, (err, response, _data) => { if (!_data) { console.log('returnDefaultResponse: _data is null', err) buildResponse({ status: 500, body: {} }) } buildResponse({ status: response.status, headers: response.headers, body: _data }) } )}
launch()js
Credit#
部分信息可能已经过时