前言
最近半年,手上慢慢攒了不少 OpenAI、Claude Code 相关账号。自己用还好说,朋友、同学一来问「能不能借一下」,事情就热闹了:账号密码不好直接发,额度怎么分、谁用超了怎么查,靠口头约定很快就乱。
后来我把这些订阅丢进 Sub2API,给他们每人创建对应的分组,让其分发API Key。然后在Codex、Claude Code、各类AI AGENT 客户端里改个 Base URL 就能用;我这边还能看用量、控并发。
真正逼我写下这篇文章的,是最近一次搬家:Sub2API 从海外 VPS 迁到了国内服务器。 海外那会儿几乎不用操心出口 IP,上游直连就行;迁完国内,账号还在、Key 还在,朋友一请求却纷纷超时,排查才发现,国内机必须给 Sub2API 配代理,但是又不想在服务器上安装代理和Clash。
这篇文章记的就是迁机之后的补丁:不在服务器上再装一套 Clash,而是用 SSH 反向隧道,把本机已经跑通的 Clash / Clash Verge 借给国内机用。
| 问题/场景 | 海外 VPS(迁移前) | 国内服务器(迁移后) |
|---|---|---|
| OpenAI / Anthropic 等上游 | 多数能直连 | 经常超时或失败 |
| 要不要配代理 IP | 基本不用 | 需要 |
| 我这边的体感 | 装好就能给朋友用 | 服务起来了,上游却摸不到 |
Sub2API 本身只解决「多账号统一接入、按 Key 分给别人用」,不替你保证网络可达。服务器在哪,上游请求就从哪出。我原先挂在海外 VPS 上时,从没认真配过代理;迁到国内之后,管理台里账号加得再整齐也没用——流量出不去,共享等于停摆。
迁完那几天的现实约束是:
- Sub2API 已经落在国内机,短期不打算再搬回海外
- 家里电脑(闲置服务器)的 Clash 早就调顺:订阅、节点、规则都在本机
- 懒得在国内机再维护第二份 Clash / Mihomo,更不想两套订阅各飘各的
所以选了更省事的路:代理继续只活在本机,国内服务器用 SSH 反向隧道来用它。
代价也很直白:本机在线、隧道在线,代理才在线;电脑关机,远端那条代理口就跟着歇。
介绍
Sub2API 是啥
Sub2API是一套开源的 AI API 中转服务。官方定位很清楚:把 Claude、OpenAI、Gemini、Grok 等订阅统一接入,支持拼车共享、用量计量,并尽量让 Claude Code 等原生工具无缝接上。
你可以把它理解成「中间那一层」:
- 上游:你自己的订阅 / 账号池
- 下游:朋友、同学拿你发的 API Key 来调
平台:鉴权、转发、计费、限流这些脏活它扛
本文不讲怎么从零部署 Sub2API(网上教程一堆)。只讲一件事:像我这样把 Sub2API 迁到国内机之后,怎么正确挂上代理 IP,让上游重新通起来。
代理链路怎么走
朋友 / 同学的 Codex 、Claude Code、客户端
↓
Sub2API(国内服务器)
↓
服务器 127.0.0.1:17890
↓
SSH 反向隧道(-R)
↓
本机 127.0.0.1:7897
↓
Clash / Clash Verge
↓
机场节点 → OpenAI / Anthropic 等上游和「服务器上再装 Clash」比一眼就懂:
| 本方案:SSH 反向隧道 | 服务器上再装 Clash | |
|---|---|---|
| 代理跑在哪 | 本机 | 服务器 |
| 订阅 / 规则 | 只维护一份 | 两份,容易各改各的 |
| 本机关机 | 代理不可用 | 服务器自己有订阅则仍可用 |
| 额外软件 | OpenSSH 客户端就够 | 还要 Clash / Mihomo |
| 暴露面 | 默认只听服务器本机 127.0.0.1 | 取决于你怎么绑端口 |
共通准备
下文默认你在 Windows 上跑 Clash Verge,用 PowerShell 搭隧道。Linux 请直接跳到文末 补充:Linux(脚本 + systemd)。
1. 提前备好这些
占位符换成你的就行:
VPS_HOST # 服务器 IP 或域名
SSH_USER # 例:root
SSH_PORT # 例:22;非 22 就改脚本里的端口
LOCAL_CLASH # 本机 Mixed Port,例:127.0.0.1:7897
REMOTE_ENTRY # 服务器侧入口,例:127.0.0.1:17890还要备好:
- 一把已经能登录该服务器的 SSH 私钥(下文统一叫
id_ed25519,文件名可不同,路径改成你的即可) - 本机 Clash Verge 已启动,Mixed Port 已知(示例
7897) - Windows 已装 OpenSSH 客户端(设置 → 应用 → 可选功能里一般能找到)
两点别踩坑:
- Clash Verge 的 Mixed Port 常见默认是
7897(HTTP + SOCKS 合一口),以你本机设置为准 17890不是官方默认值,只是我挑的空闲端口;冲突就换,脚本和 Sub2API 一起改
工作目录
把私钥和后面的隧道脚本放一起,方便开机自启引用。PowerShell:
New-Item -ItemType Directory -Force C:\Scripts\sub2api-tunnel | Out-Null
New-Item -ItemType Directory -Force C:\Scripts\sub2api-tunnel\logs | Out-Null目录结构最终为:
C:\Scripts\sub2api-tunnel\
├── sub2api-tunnel.ps1
├── id_ed25519
├── id_ed25519.pub # 可选
└── logs\
├── 2026-08-14.log
├── 2026-08-15.log
└── ...放入私钥并收紧 ACL
把已有私钥复制到:
C:\Scripts\sub2api-tunnel\id_ed25519Windows 版 OpenSSH 会拒绝权限过宽的私钥,典型报错:UNPROTECTED PRIVATE KEY FILE! / Bad permissions。管理员 PowerShell:
$key = "C:\Scripts\sub2api-tunnel\id_ed25519"
$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
takeown /F $key
icacls $key /inheritance:r
icacls $key /remove "BUILTIN\Users"
icacls $key /remove "Authenticated Users"
icacls $key /remove "Everyone"
icacls $key /grant:r "${user}:(R)"
icacls $key确认输出里别再给 BUILTIN\Users、Authenticated Users、Everyone 读权限。
先测 Clash,再测私钥登录
curl.exe -x http://127.0.0.1:7897 https://api.ipify.org能打出机场出口 IP,再测 SSH(应免密进入,不要再要密码):
ssh -i C:\Scripts\sub2api-tunnel\id_ed25519 -o IdentitiesOnly=yes SSH_USER@VPS_HOST非 22 端口加 -p SSH_PORT。这里都过不了,后面的隧道脚本也白搭——先把密钥登录修好再继续。
手工通一次反向隧道
-R 左边务必写 127.0.0.1,别改成 0.0.0.0,否则可能把无认证代理晾到公网。跑起来后用本节下面的 ss 验收。
ssh -N -T `
-i C:\Scripts\sub2api-tunnel\id_ed25519 `
-o IdentitiesOnly=yes `
-o ServerAliveInterval=30 `
-o ServerAliveCountMax=3 `
-o ExitOnForwardFailure=yes `
-R 127.0.0.1:17890:127.0.0.1:7897 `
SSH_USER@VPS_HOST人话版:在本机开一条「只转发、不登录干活」的 SSH,让国内服务器上的 127.0.0.1:17890,实际连到你本机 Clash 的 127.0.0.1:7897。
| 片段 | 意思 |
|---|---|
-N -T | 不跑远程命令、不分配终端;窗口没输出是正常的,它在养隧道 |
-i ... | 用你准备好的私钥 |
IdentitiesOnly=yes | 只用指定这把钥匙 |
ServerAliveInterval / CountMax | 探活,死链尽快退出方便重连 |
ExitOnForwardFailure=yes | 转发失败就退出,别假装连着 |
-R 127.0.0.1:17890:127.0.0.1:7897 | 服务器本机 17890 → 经 SSH → 本机 7897 |
另开窗口登录服务器,做两步验收——先确认只绑本机,再确认代理能出网:
# 1)看监听地址:必须是 127.0.0.1:17890
ss -lnt | grep 17890期望类似:
LISTEN 0 128 127.0.0.1:17890 0.0.0.0:*0.0.0.0:17890 或 *:17890,说明监听在全网卡,立刻停隧道,检查 -R 是不是写成了 0.0.0.0:17890:...。
可选再确认「外网打不进这个口」(把下面的公网 IP 换成你的服务器 IP):
# 在服务器本机:本机回环应成功
curl -x http://127.0.0.1:17890 https://api.ipify.org
# 从你自己的另一台电脑 / 手机热点环境试公网 IP(应失败或连不上)
# curl -x http://你的服务器公网IP:17890 https://api.ipify.org本机 curl 能打出机场 IP,且 ss 只见 127.0.0.1:17890,链路才算安全通了。别关隧道窗口;测完 Ctrl + C 结束测试。
自动重连脚本
notepad C:\Scripts\sub2api-tunnel\sub2api-tunnel.ps1脚本内容如下:
$baseDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ssh = "$env:WINDIR\System32\OpenSSH\ssh.exe"
$key = Join-Path $baseDir "id_ed25519"
$logDir = Join-Path $baseDir "logs"
$server = "VPS_HOST"
$user = "root"
$sshPort = 22
$remoteBind = "127.0.0.1"
$remotePort = 17890
$localHost = "127.0.0.1"
$localPort = 7897
$retrySeconds = 5
$logRetentionDays = 7
New-Item -ItemType Directory -Force $logDir | Out-Null
function Get-LogPath {
Join-Path $logDir "$(Get-Date -Format 'yyyy-MM-dd').log"
}
function Write-TunnelLog {
param([string]$Message)
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $Message"
Add-Content -Path (Get-LogPath) -Value $line
}
function Clear-OldLogs {
$cutoff = (Get-Date).AddDays(-$logRetentionDays)
Get-ChildItem -Path $logDir -Filter "*.log" -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt $cutoff } |
Remove-Item -Force -ErrorAction SilentlyContinue
}
$cleanupJob = Start-Job -ArgumentList $logDir, $logRetentionDays -ScriptBlock {
param($dir, $days)
while ($true) {
$cutoff = (Get-Date).AddDays(-$days)
Get-ChildItem -Path $dir -Filter "*.log" -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt $cutoff } |
Remove-Item -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 3600
}
}
try {
while ($true) {
Clear-OldLogs
if (-not (Test-Path $key)) {
Write-TunnelLog "Private key not found: $key"
Start-Sleep -Seconds $retrySeconds
continue
}
$clashReady = Test-NetConnection `
-ComputerName $localHost `
-Port $localPort `
-InformationLevel Quiet `
-WarningAction SilentlyContinue
if (-not $clashReady) {
Write-TunnelLog "Local proxy $localHost`:$localPort is not ready. Retrying in $retrySeconds seconds..."
Start-Sleep -Seconds $retrySeconds
continue
}
Write-TunnelLog "Connecting SSH reverse tunnel..."
$sshLog = Get-LogPath
& $ssh `
-N `
-T `
-p $sshPort `
-i $key `
-o IdentitiesOnly=yes `
-o BatchMode=yes `
-o ServerAliveInterval=30 `
-o ServerAliveCountMax=3 `
-o ConnectTimeout=10 `
-o ExitOnForwardFailure=yes `
-R "${remoteBind}:${remotePort}:${localHost}:${localPort}" `
"${user}@${server}" `
2>> $sshLog
Write-TunnelLog "SSH disconnected. Retrying in $retrySeconds seconds..."
Start-Sleep -Seconds $retrySeconds
}
}
finally {
Stop-Job $cleanupJob -ErrorAction SilentlyContinue
Remove-Job $cleanupJob -Force -ErrorAction SilentlyContinue
}平常只改顶部:$server、$user、$sshPort、$remotePort、$localPort。脚本启动前会先探本机 7897 是否在听;日志按天落在 logs\,默认留 7 天。
手工测:
powershell.exe -ExecutionPolicy Bypass -File C:\Scripts\sub2api-tunnel\sub2api-tunnel.ps1服务器上再验一遍 17890 / ss / curl 测试;正常后 Ctrl + C 结束测试。
任务计划
Win + R → taskschd.msc → 创建任务(别选「创建基本任务」)。
| 页签 | 建议 |
|---|---|
| 常规 | 名称 Sub2API SSH Tunnel;勾选「不管用户是否登录都要运行」「使用最高权限运行」 |
| 触发器 | 开始任务:启动时 |
| 操作 | 程序 powershell.exe;参数见下;起始于 C:\Scripts\sub2api-tunnel |
| 条件 | 笔记本建议取消「仅交流电源」「改用电池则停止」 |
| 设置 | 允许按需运行;别设「运行超过某时间后停止」 |
参数:
-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "C:\Scripts\sub2api-tunnel\sub2api-tunnel.ps1"Clash Verge 打开开机启动。重启 Windows 后(或手动立即执行一次),在服务器再跑一遍 17890 / ss / curl 验收。
看日志:
Get-Content "C:\Scripts\sub2api-tunnel\logs\$(Get-Date -Format 'yyyy-MM-dd').log" -Tail 50配置Sub2API
隧道通了,前往 Sub2API 里填代理(IP管理)。加一条本地 HTTP 代理即可,例如:
名称:Local-Clash
协议:http
主机:127.0.0.1
端口:17890
用户名 / 密码:留空容器里的 127.0.0.1 是容器自己,不是宿主机。SSH 隧道挂在宿主机 127.0.0.1:17890 时,容器默认够不着。
常见处理(按你的部署选,隧道本身不用改):
- 用
host网络,让容器和宿主机共享网络命名空间 - 或配
extra_hosts: ["host.docker.internal:host-gateway"],再让应用去连宿主机可达地址——注意:服务如果只监听宿主机127.0.0.1,从 bridge 网络过去的包仍可能进不去;这是 Docker 网络边界,不是 SSH 配错了
部分问题排查
断开连接
Permission denied (publickey,...)
先单独测登录(带 -i 和 IdentitiesOnly=yes)。失败就查服务器 ~/.ssh/authorized_keys 是否包含对应公钥整行。
权限问题
UNPROTECTED PRIVATE KEY FILE / Bad permissions
回到「放入私钥并收紧 ACL」重做一遍 icacls。
端口被占用
remote port forwarding failed for listen port 17890
服务器上:
ss -lntp | grep 17890端口被占就换,例如 18080,脚本与 Sub2API 同步改。
节点超时
服务器上 17890 在听,但代理失败
先测本机 Clash:
curl.exe -x http://127.0.0.1:7897 https://api.ipify.org本机都失败,问题在 Clash / 节点,不在隧道。
连接超时
kex_exchange_identification / Connection reset
脚本若曾高频失败重连,服务器可能临时限连。先停脚本,修好根因(密钥、端口、网络),等一会儿再手工 SSH 一次。
非 22 端口
改脚本里的 $sshPort,结束旧任务后再跑一次计划任务。
不想用了怎么拆
任务计划里结束并删除 Sub2API SSH Tunnel,或:
Unregister-ScheduledTask -TaskName "Sub2API SSH Tunnel" -Confirm:$false结束残留隧道时,按命令行同时匹配 17890 与 7897 过滤,别无脑杀光所有 ssh.exe:
Get-CimInstance Win32_Process |
Where-Object {
$_.Name -eq "ssh.exe" -and
$_.CommandLine -match "17890" -and
$_.CommandLine -match "7897"
} |
ForEach-Object {
Stop-Process -Id $_.ProcessId -Force
}然后:看公钥内容 → 登录服务器只删 authorized_keys 里对应那一行 → 删本地 C:\Scripts\sub2api-tunnel → 在 Sub2API 解除绑定并删掉 127.0.0.1:17890。服务器上再 ss -lntp | grep 17890,应无输出。
收尾
回头看,其实就两段:
- 海外机时期:Sub2API + 发 Key,朋友、同学就能用,几乎不用碰代理
- 迁到国内之后:同样的服务「活着」,上游却摸不到——才补上本机 Clash + SSH 反向隧道
方案不炫技:一条 -R、一把专用私钥、一层自动重连、开机拉起来。稳久一点,记住这三句就行:
- 反向口只绑
127.0.0.1 - Clash 和隧道都要开机自启
- 私钥 ACL 别偷懒(Windows 用
icacls)
本机在线时,国内机上的 Sub2API 又能「假装」自己有一条本地 Clash——朋友那边照常用 Claude Code,我这边不用为了出网再搬回海外。迁机那几天踩过的坑,大概就这些。
补充:Linux(脚本 + systemd)
本机若是 Ubuntu / Debian 一类 Linux,思路与正文相同,只是路径、权限命令和开机方式换成 bash + systemd。下面按同样顺序给一份可抄作业版本。
工作目录
mkdir -p ~/sub2api-tunnel/logs
chmod 700 ~/sub2api-tunnel~/sub2api-tunnel/
├── sub2api-tunnel.sh
├── id_ed25519
├── id_ed25519.pub # 可选
└── logs/放入私钥
cp ~/.ssh/id_ed25519 ~/sub2api-tunnel/id_ed25519
chmod 600 ~/sub2api-tunnel/id_ed25519没有 ssh 客户端时:
sudo apt update && sudo apt install openssh-client先测 Clash,再测私钥登录
curl -x http://127.0.0.1:7897 https://api.ipify.org
ssh -i ~/sub2api-tunnel/id_ed25519 -o IdentitiesOnly=yes SSH_USER@VPS_HOST手工通一次反向隧道
-R 左边写 127.0.0.1,别写成 0.0.0.0;通了以后用 ss 确认监听地址。
ssh \
-N -T \
-i ~/sub2api-tunnel/id_ed25519 \
-o IdentitiesOnly=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-o ExitOnForwardFailure=yes \
-R 127.0.0.1:17890:127.0.0.1:7897 \
SSH_USER@VPS_HOST另开终端登录服务器,同样先验绑定、再验出网:
ss -lnt | grep 17890
# 必须看到 127.0.0.1:17890;若是 0.0.0.0:17890 / *:17890,立刻停隧道改回 -R 127.0.0.1:...
curl -x http://127.0.0.1:17890 https://api.ipify.org测完隧道窗口 Ctrl + C。
自动重连脚本
nano ~/sub2api-tunnel/sub2api-tunnel.sh#!/usr/bin/env bash
set -u
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
KEY="$BASE_DIR/id_ed25519"
LOG_DIR="$BASE_DIR/logs"
SERVER="VPS_HOST"
SSH_USER="root"
SSH_PORT="22"
REMOTE_BIND="127.0.0.1"
REMOTE_PORT="17890"
LOCAL_HOST="127.0.0.1"
LOCAL_PORT="7897"
RETRY_SECONDS="5"
LOG_RETENTION_DAYS="7"
mkdir -p "$LOG_DIR"
chmod 700 "$BASE_DIR"
chmod 600 "$KEY" 2>/dev/null || true
log_file() {
printf '%s/%s.log\n' "$LOG_DIR" "$(date +%F)"
}
write_log() {
printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$(log_file)"
}
cleanup_logs() {
local mtime_days=$((LOG_RETENTION_DAYS - 1))
if [ "$mtime_days" -lt 0 ]; then
mtime_days=0
fi
find "$LOG_DIR" \
-maxdepth 1 \
-type f \
-name '*.log' \
-mtime +"$mtime_days" \
-delete
}
cleanup_loop() {
while true; do
cleanup_logs
sleep 3600
done
}
cleanup_loop &
CLEANUP_PID=$!
cleanup_on_exit() {
kill "$CLEANUP_PID" 2>/dev/null || true
}
trap cleanup_on_exit EXIT INT TERM
while true; do
cleanup_logs
if [ ! -f "$KEY" ]; then
write_log "Private key not found: $KEY"
sleep "$RETRY_SECONDS"
continue
fi
write_log "Connecting SSH reverse tunnel..."
SSH_LOG="$(log_file)"
/usr/bin/ssh \
-N \
-T \
-p "$SSH_PORT" \
-i "$KEY" \
-o IdentitiesOnly=yes \
-o BatchMode=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-o ConnectTimeout=10 \
-o ExitOnForwardFailure=yes \
-R "${REMOTE_BIND}:${REMOTE_PORT}:${LOCAL_HOST}:${LOCAL_PORT}" \
"${SSH_USER}@${SERVER}" \
>> "$SSH_LOG" 2>&1
write_log "SSH disconnected. Retrying in ${RETRY_SECONDS} seconds..."
sleep "$RETRY_SECONDS"
donechmod 700 ~/sub2api-tunnel/sub2api-tunnel.sh
chmod 600 ~/sub2api-tunnel/id_ed25519先手工跑脚本确认 17890,再交给 systemd。
systemd 开机自启
whoami # 假设输出 ubuntu,下面路径按实际用户改
sudo nano /etc/systemd/system/sub2api-tunnel.service[Unit]
Description=Sub2API SSH Reverse Tunnel
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/sub2api-tunnel
ExecStart=/home/ubuntu/sub2api-tunnel/sub2api-tunnel.sh
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now sub2api-tunnel.service
sudo systemctl status sub2api-tunnel.service期望 Active: active (running)。常用:
sudo systemctl restart sub2api-tunnel.service
sudo journalctl -u sub2api-tunnel.service -n 50
tail -n 50 ~/sub2api-tunnel/logs/"$(date +%F).log"非 22 端口改脚本 SSH_PORT 后 sudo systemctl restart sub2api-tunnel.service。Clash 同样要开机自启。接到 Sub2API、Docker 注意点与正文相同,仍填 http://127.0.0.1:17890。
Linux 怎么拆
sudo systemctl disable --now sub2api-tunnel.service
sudo rm -f /etc/systemd/system/sub2api-tunnel.service
sudo systemctl daemon-reload
sudo systemctl reset-failed
pgrep -af 'ssh.*17890.*7897'
# 确认后再:pkill -f 'ssh.*17890.*7897'再删 authorized_keys 对应行、本地 ~/sub2api-tunnel、Sub2API 里的代理条目,最后在服务器确认 17890 已消失。