# 工作台线上出口：ts.net被墙 → frp反向隧道方案

> 2026-09-08 实战验证。前置知识：工作台后端跑在 Hermes Mac 的 8765 端口（销售工作台服务.py），AI 助手经 8642 网关。

## 一、为什么 ts.net 不可用（诊断结论）

Tailscale Funnel 的公网域名 `macbook-air-2.tail6864e8.ts.net` 在国内被**双层封堵**，不能做线上多销售的 API 出口：

1. **DNS 分层封**：不同 DNS 结果不一致。阿里 DNS 223.5.5.5 能解析到 103.84.155.x；腾讯 DNS 119.29.29.29 返回 NXDOMAIN；云函数（腾讯云网络）解析返回 ENOTFOUND。
2. **IP 封**：就算解析到 IP，直连 SSL 握手被掐断（`SSL_ERROR_SYSCALL`）。
3. 只有装了 Tailscale 的设备（用 MagicDNS 100.100.100.100 本地解析）能连——所以「本机 curl 通」是假象。

**分层 DNS 测试脚本**（判断某域名是否国内被墙）：
```bash
nslookup <域名> 223.5.5.5      # 阿里DNS
nslookup <域名> 119.29.29.29  # 腾讯DNS（常NXDOMAIN）
nslookup <域名> 8.8.8.8      # 海外DNS
# 用阿里解析到的IP直连，测IP是否真可达
curl --resolve <域名>:443:<IP> https://<域名>/   # SSL被掐=IP也封
```
结论判据：不同 DNS 结果不一致 + IP 直连 SSL 失败 → 域名被墙，只能换隧道方案。

## 二、frp 反向隧道架构

原理：**出站流量国内不受限**，所以让 Mac 主动连出去到一个国内服务器，把 8765 映射到公网。

```
销售员浏览器 → https://api.biorisales.site (Caddy HTTPS, 443)
              → 127.0.0.1:8765 (frp隧道)
              → Mac 8765 (工作台服务) → 8642 (Hermes网关) → DeepSeek
```

组件（2026-09-08 实际部署）：
- **CVM**：腾讯云轻量服务器 123.207.243.239，Ubuntu 24.04 x86_64，SSH 私钥在 Mac `~/.ssh/HermesAIcopilot.pem`（登录用户 ubuntu，sudo 免密）。
- **frps**（服务端，CVM 上）：`bindPort=7000` + token 鉴权，systemd 自启。
- **frpc**（客户端，Mac 上）：`~/frp/frpc.toml`，launchd 自启 `com.biori.frpc`。
- **Caddy**（CVM 上）：反向代理 `api.biorisales.site → 127.0.0.1:8765`，自动 HTTPS。
- **DNS**：`api.biorisales.site` A 记录 → 123.207.243.239（DNSPod，域名 biorisales.site）。
- **前端**：`CHAT_APIS` 首选 `https://api.biorisales.site/api/chat`，删掉失效 ts.net 端点。

## 三、关键配置

### frps.toml（CVM /etc/frp/frps.toml）
```toml
bindPort = 7000
auth.method = "token"
auth.token = "<32位随机token>"
```

### frpc.toml（Mac ~/frp/frpc.toml）
```toml
serverAddr = "123.207.243.239"
serverPort = 7000
auth.method = "token"
auth.token = "<同token>"
loginFailExit = false   # 断线自动重连，别用默认的失败即退

[[proxies]]
name = "workbench"
type = "tcp"
localIP = "127.0.0.1"
localPort = 8765
remotePort = 8765
```

### frps.service（CVM systemd）
```ini
[Unit]
Description=frps
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/frps -c /etc/frp/frps.toml
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```

### com.biori.frpc.plist（Mac ~/Library/LaunchAgents/）
```xml
<!-- Label=com.biori.frpc, ProgramArguments=/Users/liuxinyuan/frp/frpc -c /Users/liuxinyuan/frp/frpc.toml,
     RunAtLoad=true, KeepAlive=true -->
```

### Caddyfile（CVM /etc/caddy/Caddyfile）
```
{
    acme_ca https://acme-v02.api.letsencrypt.org/directory
    email admin@biorisales.site
}
api.biorisales.site {
    reverse_proxy 127.0.0.1:8765
}
```

## 四、关键坑（踩过）

1. **证书签发卡 NXDOMAIN**：Caddy 自动签 Let's Encrypt 前，DNS 必须先指向服务器 IP，且 **80 端口防火墙放行**（HTTP-01 挑战要用）。DNS 刚加的记录对海外 resolver 有传播延迟，让 Caddy 重试几次即可。
2. **staging 证书**：若 Caddy 日志出现 `acme-staging-v02`，说明用的是测试 CA（浏览器不信任），需在 Caddyfile 全局块显式 `acme_ca https://acme-v02.api.letsencrypt.org/directory` 强制生产 CA。
3. **防火墙端口**：CVM 需开 7000(frpc 控制) + 80 + 443。8765 不用对外开（Caddy 走 localhost 转发）。Lighthouse 默认只开 22/80/443，7000 要手动加。
4. **curl 不走代理**：系统代理（mihomo/随云 7897）会掐断 ts.net 的 SSL；但 curl 默认不走系统代理，所以「本机 curl 通」掩盖了浏览器走代理被拦的问题。排障「服务正常但用户打不开」先查代理 + 分层 DNS。
5. **frpc 断线退出**：frp v0.71 默认 `loginFailExit`，连不上即退。加 `loginFailExit = false` 让它重试。

## 五、部署/排障命令速查

```bash
# frp 下载（GitHub release 被墙，走镜像）
curl -sSL -o frp.tar.gz "https://ghfast.top/https://github.com/fatedier/frp/releases/download/v0.71.0/frp_0.71.0_<linux_amd64|darwin_arm64>.tar.gz"

# CVM 侧
sudo systemctl status frps --no-pager
sudo journalctl -u caddy -n 30 | grep -iE 'obtain|challenge|issued|error'

# 隧道验证（从 CVM curl 应通到 Mac 8765）
ssh -i ~/.ssh/HermesAIcopilot.pem ubuntu@123.207.243.239 "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8765/"

# HTTPS 全链路验证
curl -sS -o /dev/null -w "HTTP %{http_code} 耗时 %{time_total}s" https://api.biorisales.site/api/chat

# Mac 侧 frpc 状态
launchctl list | grep biori.frpc
```

## 六、云函数探针（判断某域名是否从腾讯云可达）

CloudBase 环境有云函数（`tcb fn`）。可部署一个探针函数测域名可达性：
```js
const https = require('https');
exports.main = async () => new Promise(r => {
  const t = Date.now();
  https.get('https://<域名>/', res => r({ok:true, status:res.statusCode, ms:Date.now()-t}))
       .on('error', e => r({ok:false, err:e.message}));
});
```
部署 `tcb fn deploy <名> --dir <目录> -e <envId>`，调用 `tcb fn invoke <名>`。ENOTFOUND = 该域名在腾讯云网络 DNS 解析不了。
