用 Caddy + FRP + CLIProxyAPI,将本地 AI 服务安全发布到公网

最近我把本地高性能主机上的 CLIProxyAPI 通过一台有公网 IP 的阿里云 ECS 暴露到了公网,整体方案比“直接开放本地端口”稳妥很多,也更适合长期维护。

这篇文章就按实际落地过程,完整记录一下 Caddy + FRP + CLIProxyAPI 的部署思路和操作步骤。

一、整体目标

我当前有两台机器:

  • A:阿里云 ECS
    • 有公网 IP
    • 负责域名入口、HTTPS 证书、反向代理
  • B:本地高性能主机
    • 没有公网 IP
    • 负责真正运行 CLIProxyAPI

最终链路如下:

1
2
3
4
5
用户 / Claude Code / 浏览器
↓ HTTPS
阿里云 ECS A(Caddy + frps)
↓ FRP 隧道
本地服务器 B(frpc + CLIProxyAPI)

最终对外访问地址:

1
https://ai.YOUR_DOMAIN

二、为什么不用直接暴露本地服务

如果把本地服务器直接发布到公网,通常会遇到几个问题:

  • 本地机器没有公网 IP
  • 家宽、校园网、办公网往往都在 NAT 后面
  • HTTPS 证书、域名解析和端口映射维护麻烦
  • 直接暴露本地服务,安全边界不好控制

所以更合理的思路是:

  • A 负责公网入口
  • B 负责实际算力和服务
  • FRP 把 B 的本地端口映射到 A
  • Caddy 统一处理 HTTPS 和反向代理

这样公网始终只需要暴露 A,B 可以继续只作为内网服务节点存在。

三、最终请求链路

实际请求会这样走:

1
2
3
4
5
6
7
Claude Code / 浏览器
-> https://ai.YOUR_DOMAIN
-> A:443(Caddy)
-> A:127.0.0.1:18317
-> frps / frpc 隧道
-> B:127.0.0.1:8317
-> CLIProxyAPI

也就是说:

  • 公网访问的是 ai.YOUR_DOMAIN
  • A 上的 Caddy 负责 HTTPS
  • A 上的 127.0.0.1:18317 是 FRP 暴露出来的业务端口
  • B 上真正提供服务的是 127.0.0.1:8317

四、准备工作

1. 配置域名解析

先把子域名解析到阿里云 ECS 的公网 IP。

例如:

1
ai.YOUR_DOMAIN -> YOUR_SERVER_IP

2. 放行安全组端口

A 的安全组至少需要放行:

1
2
3
4
80/tcp
443/tcp
7000/tcp
22/tcp

说明:

  • 80/443 给 Caddy 使用
  • 7000 给 frps 使用
  • 22 给 SSH 使用,建议限制来源 IP

五、A 机配置:Caddy + frps

1. 安装 Caddy

在 A 上执行:

1
2
3
4
5
6
7
8
9
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy

caddy version

2. 安装 frps

1
2
3
4
5
6
7
8
9
10
FRP_VER=0.68.1
ARCH=amd64

cd /tmp
curl -LO "https://github.com/fatedier/frp/releases/download/v${FRP_VER}/frp_${FRP_VER}_linux_${ARCH}.tar.gz"
tar -xzf "frp_${FRP_VER}_linux_${ARCH}.tar.gz"

sudo mkdir -p /opt/frp /etc/frp
sudo install -m 0755 "/tmp/frp_${FRP_VER}_linux_${ARCH}/frps" /opt/frp/frps
sudo useradd --system --no-create-home --shell /usr/sbin/nologin frp 2>/dev/null || true

3. 配置 frps

编辑配置文件:

1
sudo nano /etc/frp/frps.toml

写入:

1
2
3
4
5
6
7
8
9
10
11
bindAddr = "0.0.0.0"
bindPort = 7000

proxyBindAddr = "127.0.0.1"

auth.method = "token"
auth.token = "REPLACE_WITH_YOUR_FRP_TOKEN"

allowPorts = [
{ single = 18317 }
]

这里有两个关键点:

  • proxyBindAddr = "127.0.0.1":让业务端口只绑定在 A 本机,不直接对公网开放
  • allowPorts = 18317:只允许映射指定端口,避免随意暴露其他服务

4. 配置 frps systemd 服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
sudo tee /etc/systemd/system/frps.service >/dev/null <<'EOF'
[Unit]
Description=frp server
After=network.target
Wants=network.target

[Service]
Type=simple
User=frp
Group=frp
ExecStart=/opt/frp/frps -c /etc/frp/frps.toml
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now frps
sudo systemctl status frps --no-pager

5. 配置 Caddy

编辑:

1
sudo nano /etc/caddy/Caddyfile

写入:

1
2
3
4
5
6
7
8
9
10
11
12
{
email YOUR_EMAIL
}

ai.YOUR_DOMAIN {
encode gzip zstd

@denyMgmt path /management* /v0/management*
respond @denyMgmt 403

reverse_proxy 127.0.0.1:18317
}

这个配置的作用是:

  • ai.YOUR_DOMAIN 自动签发 HTTPS 证书
  • 把公网请求转发到 A 本机 127.0.0.1:18317
  • 屏蔽 CLIProxyAPI 的管理接口,避免直接对公网开放

应用配置:

1
2
3
4
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl enable --now caddy
sudo systemctl reload caddy
sudo systemctl status caddy --no-pager

六、B 机配置:CLIProxyAPI + frpc

1. 准备 CLIProxyAPI

如果已经下载好二进制,可以直接使用。

例如:

1
2
mkdir -p ~/tools/CLIProxyAPI
cd ~/tools/CLIProxyAPI

确认可执行文件存在,例如:

1
/home/YOUR_USERNAME/tools/CLIProxyAPI/cli-proxy-api

2. 准备配置目录

建议把 CLIProxyAPI 的配置和认证文件统一放在:

1
~/.cli-proxy-api

执行:

1
mkdir -p ~/.cli-proxy-api

3. 配置 CLIProxyAPI

编辑:

1
nano ~/.cli-proxy-api/config.yaml

写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
host: "127.0.0.1"
port: 8317

auth-dir: "/home/YOUR_USERNAME/.cli-proxy-api"

api-keys:
- "REPLACE_WITH_A_LONG_RANDOM_CLIENT_KEY"

request-retry: 3

quota-exceeded:
switch-project: true
switch-preview-model: true

remote-management:
allow-remote: false
secret-key: "REPLACE_WITH_A_LONG_RANDOM_MGMT_KEY"
disable-control-panel: false

建议提前生成两串随机密钥:

1
2
openssl rand -hex 24
openssl rand -hex 24

分别填到:

  • api-keys
  • remote-management.secret-key

这里建议特别注意两点:

  • host: "127.0.0.1" 表示 CLIProxyAPI 只监听本机
  • allow-remote: false 表示管理接口不允许远程控制

4. 完成 Claude 登录授权

如果 CLIProxyAPI 需要接入 Claude,可以执行:

1
/home/YOUR_USERNAME/tools/CLIProxyAPI/cli-proxy-api -config /home/YOUR_USERNAME/.cli-proxy-api/config.yaml -claude-login -no-browser

完成授权后,认证文件会写入:

1
~/.cli-proxy-api/

5. 配置 CLIProxyAPI 的 systemd 服务

编辑:

1
sudo nano /etc/systemd/system/cli-proxy-api.service

写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Unit]
Description=CLIProxyAPI
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=YOUR_USERNAME
Environment=HOME=/home/YOUR_USERNAME
WorkingDirectory=/home/YOUR_USERNAME/.cli-proxy-api
ExecStart=/home/YOUR_USERNAME/tools/CLIProxyAPI/cli-proxy-api -config /home/YOUR_USERNAME/.cli-proxy-api/config.yaml
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

启动:

1
2
3
4
sudo systemctl daemon-reload
sudo systemctl enable --now cli-proxy-api
sudo systemctl status cli-proxy-api --no-pager
journalctl -u cli-proxy-api -n 50 --no-pager

先在 B 本机验证服务:

1
2
ss -lntp | grep 8317
curl http://127.0.0.1:8317/v1/models -H "Authorization: Bearer REPLACE_WITH_A_LONG_RANDOM_CLIENT_KEY"

如果能返回模型列表,说明 CLIProxyAPI 在本机已经正常工作。

6. 安装 frpc

1
2
3
4
5
6
7
8
9
10
FRP_VER=0.68.1
ARCH=amd64

cd /tmp
curl -LO "https://github.com/fatedier/frp/releases/download/v${FRP_VER}/frp_${FRP_VER}_linux_${ARCH}.tar.gz"
tar -xzf "frp_${FRP_VER}_linux_${ARCH}.tar.gz"

sudo mkdir -p /opt/frp /etc/frp
sudo install -m 0755 "/tmp/frp_${FRP_VER}_linux_${ARCH}/frpc" /opt/frp/frpc
sudo useradd --system --no-create-home --shell /usr/sbin/nologin frp 2>/dev/null || true

7. 配置 frpc

编辑:

1
sudo nano /etc/frp/frpc.toml

写入:

1
2
3
4
5
6
7
8
9
10
11
12
serverAddr = "YOUR_SERVER_IP"
serverPort = 7000

auth.method = "token"
auth.token = "REPLACE_WITH_YOUR_FRP_TOKEN"

[[proxies]]
name = "cliproxyapi"
type = "tcp"
localIP = "127.0.0.1"
localPort = 8317
remotePort = 18317

参数说明:

  • serverAddr:A 的公网 IP
  • serverPort:A 上 frps 的监听端口
  • auth.token:必须和 A 上配置完全一致
  • localPort = 8317:B 上 CLIProxyAPI 的监听端口
  • remotePort = 18317:映射到 A 本机的业务端口

8. 配置 frpc systemd 服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
sudo tee /etc/systemd/system/frpc.service >/dev/null <<'EOF'
[Unit]
Description=frp client
After=network.target
Wants=network.target

[Service]
Type=simple
User=frp
Group=frp
ExecStart=/opt/frp/frpc -c /etc/frp/frpc.toml
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now frpc
sudo systemctl status frpc --no-pager
journalctl -u frpc -n 50 --no-pager

七、验证整条链路

1. 先验证 B 本机服务

1
curl http://127.0.0.1:8317/v1/models -H "Authorization: Bearer REPLACE_WITH_A_LONG_RANDOM_CLIENT_KEY"

2. 再验证 A 上的 FRP 映射端口

在 A 上执行:

1
2
ss -lntp | grep -E ':7000|:18317'
curl http://127.0.0.1:18317/v1/models -H "Authorization: Bearer REPLACE_WITH_A_LONG_RANDOM_CLIENT_KEY"

如果这一步成功,说明:

1
A:127.0.0.1:18317 -> B:127.0.0.1:8317

已经打通。

3. 最后验证公网 HTTPS 入口

1
curl https://ai.YOUR_DOMAIN/v1/models -H "Authorization: Bearer REPLACE_WITH_A_LONG_RANDOM_CLIENT_KEY"

如果能够正常返回模型列表,说明公网访问链路已经全部成立。

八、Claude Code 如何接入

如果后续希望 Claude Code 直接走自己的代理地址,可以在配置里设置:

1
2
3
4
5
6
{
"env": {
"ANTHROPIC_BASE_URL": "https://ai.YOUR_DOMAIN",
"ANTHROPIC_AUTH_TOKEN": "REPLACE_WITH_A_LONG_RANDOM_CLIENT_KEY"
}
}

如果你还做了模型映射,也可以继续追加对应环境变量。

九、常见问题

1. frpc 一直连不上 frps

优先检查:

  • A 的安全组是否放行 7000/tcp
  • A 本机防火墙是否拦截
  • B 到 A 的网络是否可达

可以直接测试:

1
nc -vz YOUR_SERVER_IP 7000

2. token in login doesn't match token from configuration

这个报错基本就是 A 和 B 两边的 auth.token 不一致。

检查:

  • /etc/frp/frps.toml
  • /etc/frp/frpc.toml

确保两边完全相同。

3. curl https://ai.YOUR_DOMAIN 返回 502

通常说明上游链路某一段没通,优先排查:

1
2
3
4
5
6
7
8
# B
ss -lntp | grep 8317
systemctl status cli-proxy-api --no-pager
systemctl status frpc --no-pager

# A
ss -lntp | grep 18317
journalctl -u caddy -n 50 --no-pager

4. 不希望管理接口暴露到公网

推荐直接在 Caddy 层拦截:

1
2
@denyMgmt path /management* /v0/management*
respond @denyMgmt 403

同时 CLIProxyAPI 本身也尽量保持:

  • host: 127.0.0.1
  • allow-remote: false

十、总结

这套方案非常适合下面这种场景:

  • 有一台公网服务器
  • 有一台高性能但无公网 IP 的本地主机
  • 想把本地 AI 服务稳定发布到公网
  • 想统一管理 HTTPS、域名和入口安全

最终思路可以概括成一句话:

用阿里云 ECS 做公网入口,用本地高性能主机做实际服务节点,再通过 FRP 和 Caddy 把两者安全串起来。

这样做的好处也很明确:

  • 公网入口清晰
  • 本地服务不用直接暴露
  • HTTPS 统一管理
  • 后续扩展其他子域名服务也会比较方便

如果后续继续扩展 rustdesk.YOUR_DOMAINcloud.YOUR_DOMAIN 或其他服务,这套架构也可以直接复用。