cf-saas-tenant-router 部署教程:Cloudflare for SaaS 多租户路由,客户域名一条 CNAME 即接入

前言

做「给别人提供服务」的东西,迟早会撞上一个问题:客户想用自己的域名接入,但客户的域名不在你的 Cloudflare 账号里。

传统做法是让客户把域名 NS 迁过来——不现实。正确姿势是 Cloudflare 官方的 Cloudflare for SaaS:你提供入口,客户只加一条 CNAME 指向你,证书由 Custom Hostname 自动签发。

cf-saas-tenant-router 就是把这套能力做成了开箱即用的多租户系统:

  • 项目地址:https://github.com/lzmss-love/cf-saas-tenant-router
  • 技术栈:Cloudflare Workers + D1(三张表)+ Custom Hostnames + GitHub Actions
  • 定位:客户域名不托管 CF,加一条 CNAME 即可把流量接入你的 Worker,按 Host 分流转发到各自的上游(tgproxy / cf-erp / cf-subcloud 等均可)

整条链路:

1
2
3
4
5
6
7
8
9
客户域名(NS 留在客户自己的 DNS)
│ CNAME → opt.<宿主域名>(优选 IP,灰云 A 记录)
▼
Cloudflare 边缘 ── Custom Hostname(证书自动签发)── */* route
▼
saas-tenant-router Worker
├─ 按 Host 查 D1 租户表 → 转发上游
├─ Origin 改写 + 页面域名替换 + WebSocket 透传
└─ bridge cap 重算(客户手里任何域名版本的接入凭证都能过认证)

部署完成后你会得到:

资源 值
管理面板 https://<宿主域名>(ADMIN_TOKEN 账号密码登录)
客户自助接入页 https://<宿主域名>/join
Worker saas-tenant-router,*/* 路由挂宿主 zone
D1 saas-tenants(租户 / 接入码 / 请求日志三张表)
DNS service(fallback origin)、customers、opt*(优选入口,可多条)

功能特性

管理面板(四个标签页)

标签 功能
租户 增删改查、详情(直接给出客户要加的 CNAME 指令)、启用/停用(suspended 即停止转发)、删除
接入码 生成 8 位码发给客户;客户在 /join 输码 + 域名自助完成接入;一次性、防重放、可作废
自定义主机名 创建/删除 Custom Hostname;证书与主机名状态一目了然;详情弹窗直接给出客户要补加的全部 DNS 指令(验证 TXT / DCV 委派 CNAME)
优选 多路管理(opt / opt2 / opt3…,A 记录或 CNAME 双形态)+ 在线测速(当前线路真实延迟)+ 快捷切换社区优选域名

几个设计细节(源码确认)

  • 接入码防呆:8 位随机码,字符表去掉了 0 O 1 I L 这类易混淆字符,口头报给客户也不会念错
  • 租户状态机:pending(没配上游,仅标记)/ active(配了 upstream,正常转发)/ suspended(管理员停用,立即停止转发)/ 软删除
  • 上游两级配置:租户表里每行可配 upstream_base(租户专属),没配则回落到全局变量 TENANT_UPSTREAM_BASE;两者都没有时返回提示页,而不是报 1016
  • 面板与 API 只认宿主域名:管理接口只在宿主 zone(及可选的 workers.dev 后缀)上响应,且需要 ADMIN_TOKEN(格式 账号:密码),客户域名上根本访问不到
  • 兼容严格上游:对 tgproxy 这类做 bridge 校验的服务,Worker 会按上游真实 hostname 重算 cap——客户手里任何域名版本的接入凭证都能过认证

部署前准备(人工三件套,无法 API 化)

无论用哪种部署方式,每个 Cloudflare 账号都要先手工完成这三步:

  1. 宿主域名加入该 CF 账号(NS 托管,Free 计划即可)
  2. Enable Cloudflare for SaaS:控制台 → SSL/TLS → Custom Hostnames → Enable(需要绑卡——这是 CF 唯一不给 API 的步骤)
  3. Fallback Origin:同一页面 → Fallback Origin 栏填 service.<你的域名> → 等 Active。注意这一步只能控制台点,API 会报 10405(CF 硬限制)

然后建一个 API Token(控制台 → My Profile → API Tokens)。CLOUDFLARE_API_TOKEN 一共需要 11 项权限,建 Token 时对着下面的清单逐项勾选即可(级别列标注了它挂在「账户」还是「区域」下):

# 级别 权限名 用途
1 账户 D1 Write 自动建 / 迁移 saas-tenants 数据库
2 账户 D1 Read 查询现有 D1 实例,避免重复建库
3 账户 Workers Scripts Write wrangler deploy 部署 / 更新 Worker 代码
4 账户 Workers Scripts Read 读取现有 Worker 与 secret 状态
5 区域 DNS Write 自动建 / 改 service、customers、opt* 记录
6 区域 DNS Read 读取现有 A / CNAME 记录做幂等比对
7 区域 SSL and Certificates Write 面板创建 / 删除 Custom Hostname(缺了会 403)
8 区域 SSL and Certificates Read 面板展示证书签发状态
9 区域 Workers Routes Write 写入 */* 路由
10 区域 Workers Routes Read 校验路由是否已挂到本 Worker
11 区域 Zone Read 自动查 zone id

Zone Resources 只选你的宿主 zone(账户级的 D1 / Workers Scripts 权限跟随账户,区域级权限锁定该 zone),最小权限。


部署路线 A:本机一条命令初始化(推荐)

仓库是模板化的:拿到任何新 CF 账号,域名/账号/D1 全部自动配置,代码零修改。

1
2
3
4
5
6
7
8
9
# 1) clone 到一个新目录(重要:不要在已有部署的目录里跑)
git clone https://github.com/lzmss-love/cf-saas-tenant-router.git my-new-saas
cd my-new-saas && npm i

# 2) 一键初始化(Git Bash 语法)
CF_API_TOKEN=xxx node scripts/init.mjs <你的宿主域名>

# PowerShell 写法:
# $env:CF_API_TOKEN="xxx"; node scripts/init.mjs <你的宿主域名>

init.mjs 会自动完成:查 zone id → 建 D1 → 重写本目录的 wrangler.jsonc(routes / D1 / vars 全部指向新账号)→ 建 DNS 记录(fallback origin / customers / opt)→ 打印剩余的手动步骤。

然后收尾两件事:

1
2
npx wrangler deploy          # 部署 Worker + 路由
npm run optimize # 跑一次优选,把最快的 IP 写进 opt 记录

⚠️ init.mjs 会重写当前目录的 wrangler.jsonc。在 clone 出来的新目录里跑,不要在已有部署的目录执行,否则当前账号的配置会被覆盖。


部署路线 B:手动部署(想搞懂每一步)

1
2
3
4
5
npm i
npx wrangler d1 create saas-tenants
# 把输出的 database_id 填进 wrangler.jsonc(从 wrangler.template.jsonc 复制一份开始改)
npx wrangler d1 migrations apply saas-tenants --remote
npx wrangler deploy

三个 secret(存 CF 侧)

1
2
3
npx wrangler secret put ADMIN_TOKEN        # 面板登录,格式 账号:密码
npx wrangler secret put CF_API_TOKEN # DNS / Custom Hostnames 操作
npx wrangler secret put TGPROXY_SECRET # tgproxy 的 SECRET,用于 bridge cap 重算(不接 TG 可不配)

关键纪律:一切凭证走 secret,不要在 Dashboard 建 plain text 变量——wrangler deploy 会用 wrangler.jsonc 的 vars 段整体替换明文变量,Dashboard 里建的会被清掉。secret 不受影响。

wrangler.jsonc 的 vars 段(配置全在这)

变量 说明
OWN_ZONE / OWN_ZONE_ID 宿主域名与 zone id
CNAME_TARGET 客户 CNAME 指向的入口,默认 opt.<宿主域名>
OWN_WORKERSDEV_SUFFIX workers.dev 验证子域后缀(可选,测试期用)
TENANT_UPSTREAM_BASE 全局默认上游(可选,租户级 upstream_base 优先)
OPT_PRESETS 社区优选域名预设(如 vivid43.cf.090227.xyz)
OPT_TEST_HOST 在线测速用的已激活租户域名(可选)

配置全部在 vars 里,代码零硬编码。改完 npx wrangler deploy 生效。

验证

1
2
curl https://<宿主域名>/health
# {"ok":true,...}

浏览器打开 https://<宿主域名>,能用 ADMIN_TOKEN 登录面板即部署成功。


部署路线 C:GitHub Actions 全自动(push 即上线)

仓库自带 .github/workflows/deploy.yml,push 到 master(或手动触发)自动完成全流程:

1
2
查 zone id → 建/找 D1 → 渲染配置 → 部署 Worker → D1 迁移
→ 自动写入运行时密钥 → 幂等补齐 DNS 基础记录

一次性配置

  1. 建部署专用 API Token:权限与上文「部署前准备」的 11 项清单完全一致(D1 / Workers Scripts / DNS / SSL and Certificates / Workers Routes 的读写 + Zone Read),Zone Resources 选宿主 zone
  2. GitHub 仓库 → Settings → Secrets and variables → Actions,配这些 repository secret:
Secret 值 必填
CF_ZONE 宿主域名(如 xxx.com) ✅
CLOUDFLARE_API_TOKEN 上一步的 token ✅
CLOUDFLARE_ACCOUNT_ID Account ID ✅
CF_ADMIN_TOKEN 面板密码(账号:密码) ✅
CF_DNS_TOKEN 专用 DNS token(不填则复用部署 token) ⬜
CF_TGPROXY_SECRET TG WEB 代理密码(不接 TG 不用填) ⬜
CF_WORKERS_DEV_SUFFIX workers.dev 子域后缀(测试期才用) ⬜
CF_OPT_TEST_HOST 在线测速用的已激活租户域名 ⬜

zone id / D1 id / CNAME target 都不用填——Actions 自动解析,D1 不存在会自动创建。

单仓库管多个 CF 账号(Environments)

这是这套 CI 的亮点:每个账号一个 GitHub Environment,手动触发时 target 自由输入账号名:

  1. Settings → Environments → New environment,名称填账号名(如 maowei)
  2. 在该 environment 的 Secrets 里配置该账号的一套 secrets
  3. Actions → Deploy → Run workflow → target 填 maowei → 部署到对应 CF 账号

指定 target 时,zone/D1 会按 zone 名在目标账号实时解析(忽略仓库级回退 ID,避免跨账号错绑路由)。新增账号不需要改任何代码;push 自动部署不受影响(无 target = 仓库级 secrets = 主账号)。


使用说明

新客户接入流程(约 3 分钟)

  1. 面板「接入码」→ 生成 → 发给客户(复制按钮自带接入地址)

  2. 客户打开 https://<宿主域名>/join → 输接入码 + 自己的域名 → 页面直接给出要加的 CNAME:

    1
    <客户域名>  CNAME  opt.<宿主域名>
  3. 客户在他的 DNS 服务商添加这条记录。注意两点:不能用 A 记录;apex 根域名不支持——让客户用子域(如 app.客户域名)

  4. 面板「自定义主机名」→ 为该 host 创建 Custom Hostname → 点详情,把弹窗里的 DNS 指令发给客户补加(验证 TXT 或 DCV 委派 CNAME,按客户情况二选一)

  5. 等证书与主机名两个状态都变绿。如果证书一直「待验证」:查客户域名的 CAA 记录(会沿 DNS 树向上继承),需要授权签发 CA,例如加 0 issue "ssl.com",或删除现有 CAA

完事之后 Worker 已经自动识别新租户,流量按其 upstream_base 转发。

日常管理

  • 停用某个客户:租户页 → 停用(状态变 suspended,立即停止转发,且不影响其 Custom Hostname 证书)

  • 给某客户换上游:编辑该租户的 upstream_base,立即生效,无需重新部署

  • 查某个客户为什么打不开:D1 里有 req_log 请求日志(method / path / query / UA / IP / status),直接看客户端的真实请求形态:

    1
    2
    npx wrangler d1 execute saas-tenants --remote \
    --command "SELECT * FROM req_log ORDER BY id DESC LIMIT 20"
  • 面板按钮没反应:面板是单文件 HTML 内嵌 JS,先跑 node scripts/check-embedded-js.mjs 做语法检查再部署

优选 IP 维护

优选解决的是「国内访问 CF 边缘入口慢」:客户域名解析到测速优选的 CF IP,SNI 路由不变。

1
CF_API_TOKEN=xxx npm run optimize
  • 对 24 个候选 IP 测 TLS 握手 → 自动把最快的写入优选记录(IP 没变则跳过写入)
  • 换 IP 只改 DNS 记录,客户域名不用动,代码不用动
  • 优选入口支持两种形态:A 记录(直指优选 IP)或 CNAME(指社区优选域名如 *.cf.090227.xyz,自动更新更省心);面板更新时保持原类型
  • 建议每周跑一次;如果某天客户集体打不开(IP 失效),立即跑一次
  • 非标端口的反代节点(如 IP:26524)对 TG WEB 类客户端无用(固定 443 且无端口字段),仅适合支持自定义端口的客户端

面板「优选」标签页也可以直接做多路管理(opt / opt2 / opt3… 增删改、切换 A/CNAME、在线测速),不用碰命令行。


常见问题速查

现象 原因 / 处理
客户域名返回占位页 “Connection” bridge cap 与 hostname 不匹配——Worker 会自动重算;若仍出现,查 req_log 看请求形态
面板登录失败 ADMIN_TOKEN 是 secret(账号:密码);确认没在 Dashboard 建同名明文变量(会被部署清掉)
Custom Hostname 操作 403 CF_API_TOKEN 缺 Zone → SSL and Certificates → Edit 权限,重建 token 更新 secret
证书一直「待验证」 客户域名或其上级的 CAA 记录未授权签发 CA——加 0 issue "ssl.com" 或删除现有 CAA
Fallback Origin API 报 10405 该端点不接受 API Token(CF 硬限制)——必须控制台手点
wrangler deploy 后变量消失 部署整体替换明文 vars——凭证一律走 secret
客户用 apex 根域名接入失败 Custom Hostname 不支持 apex,让客户改用子域

安全注意事项

  • ADMIN_TOKEN 用随机长串,它是面板与管理 API 的唯一门禁;管理接口本身也只在宿主域名上暴露
  • CF_API_TOKEN 最小权限:Zone Resources 锁定宿主 zone,别用 Global API Key
  • 凭证一律走 secret,不要写进 wrangler.jsonc 明文 vars(会被 push 和部署清掉,还可能随模板仓库泄漏)
  • 客户接入码一次性 + 可作废,泄露了直接在面板作废重新生成
  • req_log 是诊断表,会记录客户请求的 path 与 UA——自己留档没问题,对外分享日志时注意脱敏

小结

这套项目把「Cloudflare for SaaS 多租户」这件事从一堆控制台操作变成了三条可重复的路:本机 init 一键初始化、手动部署搞懂原理、Actions push 即上线,再配上接入码自助接入、面板化租户管理、自动优选 IP。适合的场景很明确:你要给多个客户/多个域名提供同一种入口服务(代理入口、自建应用、API 网关都行),又不想让客户迁移域名托管。

部署口径总结成一行:宿主域名托管 + Enable for SaaS + Fallback Origin 三件套 → init 或 Actions → 发接入码 → 客户加一条 CNAME → 建 Custom Hostname → 双绿灯收工。


参考资料

推荐链接