前言

最近半年,手上慢慢攒了不少 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_ed25519

Windows 版 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\UsersAuthenticated UsersEveryone 读权限。


先测 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 + Rtaskschd.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
用户名 / 密码:留空

Sub2API 在 Docker 里?

容器里的 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,...)

先单独测登录(带 -iIdentitiesOnly=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

结束残留隧道时,按命令行同时匹配 178907897 过滤,别无脑杀光所有 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,应无输出。


收尾

回头看,其实就两段:

  1. 海外机时期:Sub2API + 发 Key,朋友、同学就能用,几乎不用碰代理
  2. 迁到国内之后:同样的服务「活着」,上游却摸不到——才补上本机 Clash + SSH 反向隧道

方案不炫技:一条 -R、一把专用私钥、一层自动重连、开机拉起来。稳久一点,记住这三句就行:

  1. 反向口只绑 127.0.0.1
  2. Clash 和隧道都要开机自启
  3. 私钥 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

展开: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"
done

chmod 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.target
sudo 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_PORTsudo 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 已消失。

Last modification:August 14, 2026
如果觉得我的文章对你有用,您可以给博主买一杯果汁,谢谢!