SaaS 平台捆绑多域名的解决方案

0x1 简介

起因看到 jeff 的一个帖子 需求：给网站绑一万个域名，自动生成 HTTPS 证书 勾起来自己的某些回忆，我分享下我的方案吧.
这个方案 2019 年已经被投入生产环境并稳定运行。不过，遗憾的是现在这个项目因为某些原因我决定停了，有兴趣的小伙伴可以私聊下。开发了一个外卖平台，可以支持多个用户， 每个用户拥有独立后台，都可以拥有自己独立的域名。比如，如果我有一家叫做 AABBCC 的商店，平台会为我分配一个二级域名，比如 AABBCC.example.com。当然，这个商店也可以有自己的域名，比如AABBCC.com，而这两个域名实际上指向同一个站点。

0x2 简单思考

为了实现以上方案，首先要考虑如何实现 SaaS 的多用户，因为每个用户是一个独立的个体，数据也需要分开储存。如何选择数据库呢？比如 MySQL 每个用户为一个表？或者是 mongodb 每个用户建立一个库？这里我选择了后者，原因如下，虽然是 SaaS 系统，但是每个用户有自己独立的数据库方便后期的维护.
比如用户有自己的特殊要求，可以单独定制一些功能。这些功能可以在他特有的版本内增加某些特殊的需求.

如何实现访问域名，跳转到对应的网站，这里有两种方法:

反向代理

路由

这里我更倾向于反向代理，因为每个用户有自己独立的程序，互不影响，不会因为一套系统挂了其他的商户跟着一起连坐。当然针对于 100 个用户的完全没有问题。因为程序是用 Nodejs 为后端，内存占用基本可以无视了.

反向代理这里我对 NGINX 还是比较熟悉的，这里就使用 OpenResty 跟 NGINX 基本一样，但是他支持 lua 模块，这里可以使用 lua 模块进行简单分流.
比如 当 域名AABBCC.example.com 或者 AABBCC.com 访问时，我只需要在某个地方找到对应的后端地址，把请求放过去。是不是有种 key-value 的感觉？嗯 对，就是 Redis, Redis 可以帮助我们做很多的事情.

既然解决了域名的问题，那 SSL 签名是不是也很重要？那显然啊，这样的服务当然要给网站上个绿锁了。这里选择 大名鼎鼎的
Let's Encrypt, 感谢提供免费 3 个月的 ssl 证书，当然还要考虑到续签的问题....

0x3 解决问题

第一个问题当然就是 OpenResty 安装的问题，这里我选择的是宝塔面板，因为是很久远的 版本了。重点是 看一下 lua 模块是否合适。这里找不到以前写的一键脚本了... 各位看官动手能力应该就能解决.

下面讲解一下，如何实现域名的捆绑，首先要给自己一个泛域名，比如*.example.com, 这个域名用来承载AABBCC.example.com, 当访问本域名的时候要自动反向代理到 相应的后端.
根据前面提高的 key-value 原则，这里进行一次 key-value 查询操作，如果 ok 返回相应的端口并赋给下游的代理，如果没有那就返回错误.

Copy
set $subdomain default;
access_by_lua '
local redis = require "resty.redis"
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.say("failed to connect: ", err)
return
end
local host = ngx.var.host;
local res, err = red:get(host)
if type(err)=="nil" then
if res ~= ngx.null then
ngx.log(ngx.INFO, "[web.xxx.fr]取出域名对应端口 => ", res)
ngx.var.subdomain = "http://127.0.0.1:"..res
else
ngx.log(ngx.INFO, "[web.xxx.fr]端口未找到 => ", res)
ngx.var.subdomain = "http://127.0.0.1:9998"
end
else
ngx.log(ngx.INFO, "[web.xxx.fr]错误信息 => ", err)
ngx.var.subdomain = "http://127.0.0.1:9998"
end
';

location / {
proxy_pass $subdomain;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Origin "";
add_header X-Cache $upstream_cache_status;
}

至此为止，简单的分流就做好了.

如何自动开始绿锁呢，项目用的 node.js 来写的，虽然有个库叫做 GreenLock, 经过测试并没有适合我的项目.. 作为小项目的话 应该问题不大，至少是 4 年前 没有....

这里选择一个 https://github.com/auto-ssl/lua-resty-auto-ssl 这个库来实现。安装过程省略。这里选择 redis-adaptor, 这样所有证书会进入 redis, 随时可以更改。虽然这个东西有自动续期，当然对于我来说自动续期不是我自己期待的。要做到聪明的自动续期。于是就用 nodejs 写了个定时任务.

使用 lua-resty-auto-ssl 要是想自动续签，看了他们的源码，续签的时候需要拿到一个叫做 secret 的东西。于是就写了个这样的代码，每次都会拿到，最新的 secret, 然后发送给服务器进行续命操作.

Copy
const getToken = () => {
return new Promise((resolve, reject) => {
httpRequest.get(`http://127.0.0.1:${HOOK_SERVER_PORT}/getSecret`)
.then((response) => {
resolve(response.data.trim());
}).catch((e) => {
reject(e);
});
});
}

续签的命令，非常重要！！！要配置一下 hook 的服务器 并开放 8999 端口，这里也是通过 调用脚本实现的，目的是开启一个叫做 '/getSecret' 供下面续签命令执行

Copy
export HOOK_SERVER_PORT=8999;
export HOOK_SECRET=前面获取到的;
/resty-auto-ssl路径/dehydrated -c --accept-terms --no-lock -d 域名 --config xxxx -x --challenge http-01 --hook xxxx

每天晚上 无人的时候跑个定时任务，如果有需要续签的，就需要 reload 一下服务器。反正是脚本定时任务完成.

对了 续签的端口 为 8999 当然可以根据喜好看下配置来修改.
到此为止要求就做到了.

这样证书是在 redis 里面的，然后证书可以替换成 自己的证书。作为客户捆绑域名还是很简单的，直接 cname 到 你的服务器，服务器会自动处理这一些。单机部署还是舒服的.

0x4 总结

其实在选择这方案之前 也考虑过 Caddy, 但是那时候的 Caddy 并不很稳定，续签经常出现卡顿，于是放弃。跑向啃源码的道路。但是正因为看了这样友好的开源项目，自己得到了不少提升。这个续签的方案，网上倒是有不少人提到，但是 自助脚本 hook 到 secret 进行主动续期 没人提过，稍微分享一下吧。有空了我上个 Demo, 因为光纤断了......