681 lines
31 KiB
Markdown
681 lines
31 KiB
Markdown
# 关于租户官网服务器端 nginx 的配置
|
||
|
||
> 适用场景:多租户官网系统。访客访问租户域名(如 `ceshi.yunzer.com.cn`)时,由 nginx 转发给 Go 服务,Go 按请求 Host 识别租户,用标签引擎解析 `themes/{模板编码}/` 模板后直出 HTML。
|
||
>
|
||
> 本文给出宝塔环境下两个主域名(`dh2.fun`、`yunzer.com.cn`)的完整 nginx 配置、设计原则与注意事项。
|
||
|
||
---
|
||
|
||
## 一、架构原理
|
||
|
||
```
|
||
访客浏览器
|
||
│ https://ceshi.yunzer.com.cn/
|
||
▼
|
||
nginx(宝塔)
|
||
│ 全部请求 proxy_pass → Go 服务(upstream 统一管理地址)
|
||
│ 透传 Host 头(Go 靠它识别租户)
|
||
▼
|
||
Go 服务(beego,默认 8081 端口)
|
||
│ resolveTid:按 Host 查库识别租户
|
||
│ tagengine:渲染 themes/{模板编码}/ 下的模板
|
||
▼
|
||
直出 HTML(无需前端 SPA)
|
||
```
|
||
|
||
### 1.1 Go 侧提供的站点路由
|
||
|
||
| 路径 | 说明 |
|
||
|---|---|
|
||
| `GET /` | 租户官网首页(渲染 `index.html` 模板) |
|
||
| `GET /news` | 新闻列表(`?page=N` 分页) |
|
||
| `GET /news/:id` | 文章详情 |
|
||
| `GET /page/:path` | 单页(如 `/page/about`) |
|
||
| `/themes/*` | 模板静态资源(beego `SetStaticPath("/themes", "themes")`,对应 `go/themes/` 目录) |
|
||
| `/uploads/*` | 上传文件静态访问 |
|
||
| `/backend/*`、`/platform/*` 等 | 管理端接口(nginx 以 `/api/` 前缀转发并剥掉前缀) |
|
||
|
||
> Go 的官网渲染路由在 `APP_MODE=all`(默认值)或 `APP_MODE=index` 时注册。
|
||
|
||
### 1.2 Go 按 Host 识别租户的规则(tenant_site.go)
|
||
|
||
按顺序匹配:
|
||
|
||
1. dev 环境:支持 `X-Tenant-ID` 请求头切换租户(生产无效);
|
||
2. `localhost` / `127.0.0.1` → 租户 1;
|
||
3. `yz_system_domain_pool.main_domain` 命中(status=1)→ 租户 1(平台主站);
|
||
4. `yz_system_tenant_domain.full_domain` 命中(status=1、未软删)→ 对应租户;
|
||
5. 全部未命中 → 返回"站点不存在"404 页(**不再默认回落租户 1**)。
|
||
|
||
所以平台端"域名发放"时必须保证 `yz_system_tenant_domain.full_domain` 精确等于实际访问域名(如 `ceshi.yunzer.com.cn`)。
|
||
|
||
---
|
||
|
||
## 二、配置核心策略(为什么这么写)
|
||
|
||
### 2.1 upstream 统一管理后端地址 —— 迁移只改一行
|
||
|
||
所有 `proxy_pass` 不写死 IP,统一引用 upstream。迁移服务器时只改 upstream 里的 `server` 一行。
|
||
|
||
**upstream 写在哪里**:直接写在宝塔对应站点的配置文件里、**整个文件最顶部(第一个 `server {` 块之前)**,不需要配置到其他地方。宝塔站点配置文件会被 include 进主配置的 `http {}` 上下文,`upstream` 定义在 server 块之外即合法。常见报错:`unknown "go_backend_dh2" upstream`(顶部漏了 upstream 定义)、`"upstream" directive is not allowed here`(误写进了 server 块内)。
|
||
|
||
**注意:两个站点配置文件都会被 include 进同一个 http 上下文,upstream 重名会导致 `duplicate upstream` 启动失败,必须各起各的名字**(本文用 `go_backend_dh2` / `go_backend_yunzer`)。
|
||
|
||
### 2.2 默认全量转发 —— 以后加功能零配置
|
||
|
||
不采用"白名单式"逐个转发(`location /news`、`location /page/`……),而是:
|
||
|
||
- nginx 只保留**必须拦截**的规则:敏感文件/目录返回 404、`/api/` 剥前缀;
|
||
- 其余一切请求走兜底 `location /` 转发给 Go;
|
||
- Go 对不认识的路径返回自己的 404 页,行为闭环。
|
||
|
||
这样以后 Go 增加任何路由(新功能页面、新接口页),**nginx 一行都不用改**。
|
||
|
||
### 2.3 管理端 SPA 的放置建议
|
||
|
||
主域名与所有子域名都转发给 Go 后,这些域名上就不能再跑 history 模式的 SPA 了。建议:
|
||
|
||
- platform / backend 管理端部署在**专属子域名**(如 `admin.yunzer.com.cn`)或独立站点;
|
||
- nginx 里为它单独建一个 `server_name` 精确匹配的 server 块。nginx 匹配顺序是**精确 server_name 优先于正则**,不会被"所有子域名"的正则块抢走;
|
||
- 若确实需要静态文件与 Go 共存(纯静态、无 SPA 深链接),可用折中写法:
|
||
|
||
```nginx
|
||
location / {
|
||
try_files $uri @go; # 本地存在的文件先出静态,其余转 Go
|
||
}
|
||
location @go {
|
||
proxy_pass http://go_backend_yunzer;
|
||
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 X-Forwarded-Proto $scheme;
|
||
}
|
||
```
|
||
|
||
### 2.4 `/api/` 的尾部斜杠不能删
|
||
|
||
```nginx
|
||
location /api/ {
|
||
proxy_pass http://go_backend_xxx/; # 尾部 / 负责把 /api/backend/login 映射成 /backend/login
|
||
...
|
||
}
|
||
```
|
||
|
||
Go 的路由注册在 `/backend/*`、`/platform/*`(没有 `/api` 前缀),靠这个尾部斜杠剥前缀。
|
||
|
||
### 2.5 七牛图片统一通过官网域名代理
|
||
|
||
完整配置已在两个主域名的 `server` 块中加入 `location /qiniu/`。该规则会把官网域名下的 `/qiniu/` 请求通过 **HTTP** 代理到历史七牛域名 `http://7colud.yunzer.cn/`,并去掉 `/qiniu/` 前缀;浏览器仍通过官网 HTTPS 接收图片。
|
||
|
||
例如,浏览器请求:
|
||
|
||
```text
|
||
https://yunzer.com.cn/qiniu/2026/06/02/1780371759517804300.png
|
||
```
|
||
|
||
nginx 实际请求:
|
||
|
||
```text
|
||
http://7colud.yunzer.cn/2026/06/02/1780371759517804300.png
|
||
```
|
||
|
||
**存储配置填写规则**:平台后台“存储配置”中的 `qiniu_domain` 必须与实际复制的主站配置一致,且**末尾不要加 `/`**:
|
||
|
||
- 复制 `yunzer.com.cn` 配置时填写 `https://yunzer.com.cn/qiniu`;
|
||
- 复制 `dh2.fun` 配置时填写 `https://dh2.fun/qiniu`。
|
||
|
||
Go 官网渲染会将数据库中已有的 `http(s)://7colud.yunzer.cn/路径`(兼容历史拼写 `7cloud.yunzer.cn`)替换为此配置的地址;之后新上传的七牛文件也会直接使用该代理地址。`location /qiniu/` 的 `proxy_pass` **末尾斜杠不能删除**,否则七牛会收到包含 `/qiniu/` 的错误对象路径。
|
||
|
||
---
|
||
|
||
## 三、完整配置 —— 宝塔站点 `dh2.fun`
|
||
|
||
文件位置:宝塔 → 网站 → dh2.fun → 设置 → 配置文件。整份替换。
|
||
|
||
```nginx
|
||
# ===== 后端 Go 服务地址统一定义,迁移时只改这一行 =====
|
||
upstream go_backend_dh2 {
|
||
server 10.31.100.2:8081; # 若 nginx 与 Go 同机,建议改为 127.0.0.1:8081(迁移零改动)
|
||
}
|
||
|
||
# ==================== 主域名 dh2.fun(全量托管给 Go)====================
|
||
server {
|
||
listen 80;
|
||
listen 443 ssl;
|
||
listen 443 quic;
|
||
http2 on;
|
||
|
||
# 只匹配主域名(子域名由下方独立 server 块处理)
|
||
server_name dh2.fun;
|
||
|
||
#CERT-APPLY-CHECK--START
|
||
include /www/server/panel/vhost/nginx/well-known/dh2.fun.conf;
|
||
#CERT-APPLY-CHECK--END
|
||
include /www/server/panel/vhost/nginx/extension/dh2.fun/*.conf;
|
||
|
||
#SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则
|
||
#error_page 404/404.html;
|
||
|
||
#HTTP_TO_HTTPS_START
|
||
set $isRedcert 1;
|
||
if ($server_port != 443) {
|
||
set $isRedcert 2;
|
||
}
|
||
if ( $uri ~ /\.well-known/ ) {
|
||
set $isRedcert 1;
|
||
}
|
||
if ($isRedcert != 1) {
|
||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||
}
|
||
#HTTP_TO_HTTPS_END
|
||
|
||
ssl_certificate /www/server/panel/vhost/cert/dh2.fun/fullchain.pem;
|
||
ssl_certificate_key /www/server/panel/vhost/cert/dh2.fun/privkey.pem;
|
||
ssl_protocols TLSv1.2 TLSv1.3;
|
||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||
ssl_prefer_server_ciphers on;
|
||
ssl_session_tickets on;
|
||
ssl_session_cache shared:SSL:10m;
|
||
ssl_session_timeout 10m;
|
||
add_header Strict-Transport-Security "max-age=31536000";
|
||
error_page 497 https://$host$request_uri;
|
||
#SSL-END
|
||
|
||
# --- 安全拦截:敏感文件 ---
|
||
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$ {
|
||
return 404;
|
||
}
|
||
|
||
# --- 安全拦截:敏感目录 ---
|
||
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ {
|
||
return 404;
|
||
}
|
||
|
||
# SSL 证书验证目录
|
||
location ~ \.well-known {
|
||
allow all;
|
||
}
|
||
|
||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||
return 403;
|
||
}
|
||
|
||
# /api/ 转发并剥掉 /api 前缀(尾部斜杠不能删)
|
||
location /api/ {
|
||
proxy_pass http://go_backend_dh2/;
|
||
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 X-Forwarded-Proto $scheme;
|
||
}
|
||
|
||
# 七牛图片代理:/qiniu/ 前缀会被剥掉,转发到七牛对象路径
|
||
# 存储配置 qiniu_domain 填 https://dh2.fun/qiniu(末尾不要加 /)
|
||
location /qiniu/ {
|
||
proxy_pass http://7colud.yunzer.cn/;
|
||
proxy_set_header Host 7colud.yunzer.cn;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
|
||
# 浏览器缓存图片 7 天,减少重复请求
|
||
expires 7d;
|
||
add_header Cache-Control "public, max-age=604800";
|
||
}
|
||
|
||
# 其余一切请求全部交给 Go:首页、/news、/page/xxx、/themes/ 静态资源、
|
||
# /uploads/ 以及以后新增的任何路由,自动生效,nginx 永远不用再改
|
||
location / {
|
||
proxy_pass http://go_backend_dh2;
|
||
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 X-Forwarded-Proto $scheme;
|
||
}
|
||
|
||
access_log /www/wwwlogs/dh2.fun.log;
|
||
error_log /www/wwwlogs/dh2.fun.error.log;
|
||
}
|
||
|
||
# ==================== 租户官网渲染:*.dh2.fun 所有子域名 ====================
|
||
server {
|
||
listen 80;
|
||
listen 443 ssl;
|
||
http2 on;
|
||
|
||
# 匹配所有子域名,如 ceshi.dh2.fun(租户域名发放后无需再改 nginx)
|
||
server_name ~^(?<subdomain>.+)\.dh2\.fun$;
|
||
|
||
#CERT-APPLY-CHECK--START
|
||
include /www/server/panel/vhost/nginx/well-known/dh2.fun.conf;
|
||
#CERT-APPLY-CHECK--END
|
||
|
||
#HTTP_TO_HTTPS_START
|
||
set $isRedcert 1;
|
||
if ($server_port != 443) {
|
||
set $isRedcert 2;
|
||
}
|
||
if ( $uri ~ /\.well-known/ ) {
|
||
set $isRedcert 1;
|
||
}
|
||
if ($isRedcert != 1) {
|
||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||
}
|
||
#HTTP_TO_HTTPS_END
|
||
|
||
# 注意:子域名 HTTPS 要求泛域名证书(*.dh2.fun),见文档"注意事项"
|
||
ssl_certificate /www/server/panel/vhost/cert/dh2.fun/fullchain.pem;
|
||
ssl_certificate_key /www/server/panel/vhost/cert/dh2.fun/privkey.pem;
|
||
ssl_protocols TLSv1.2 TLSv1.3;
|
||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||
ssl_prefer_server_ciphers on;
|
||
ssl_session_cache shared:SSL:10m;
|
||
ssl_session_timeout 10m;
|
||
add_header Strict-Transport-Security "max-age=31536000";
|
||
error_page 497 https://$host$request_uri;
|
||
|
||
location ~ \.well-known {
|
||
allow all;
|
||
}
|
||
|
||
# 全部请求转发给 Go,由 Go 按 Host(如 ceshi.dh2.fun)识别租户并渲染官网
|
||
location / {
|
||
proxy_pass http://go_backend_dh2;
|
||
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 X-Forwarded-Proto $scheme;
|
||
}
|
||
|
||
access_log /www/wwwlogs/dh2.fun.tenant.log;
|
||
error_log /www/wwwlogs/dh2.fun.tenant.error.log;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 四、完整配置 —— 宝塔站点 `yunzer.com.cn`
|
||
|
||
文件位置:宝塔 → 网站 → yunzer.com.cn → 设置 → 配置文件。整份替换。
|
||
|
||
```nginx
|
||
# ===== 后端 Go 服务地址统一定义,迁移时只改这一行 =====
|
||
# 注意:不能与 dh2.fun 配置里的 upstream 同名,否则 nginx 报 duplicate upstream
|
||
upstream go_backend_yunzer {
|
||
server 10.31.100.2:8081; # 若 nginx 与 Go 同机,建议改为 127.0.0.1:8081(迁移零改动)
|
||
}
|
||
|
||
# ==================== 主域名 yunzer.com.cn(全量托管给 Go)====================
|
||
server {
|
||
listen 80;
|
||
listen 443 ssl;
|
||
listen 443 quic;
|
||
http2 on;
|
||
|
||
# 只匹配主域名(子域名由下方独立 server 块处理)
|
||
server_name yunzer.com.cn;
|
||
|
||
#CERT-APPLY-CHECK--START
|
||
include /www/server/panel/vhost/nginx/well-known/yunzer.com.cn.conf;
|
||
#CERT-APPLY-CHECK--END
|
||
include /www/server/panel/vhost/nginx/extension/yunzer.com.cn/*.conf;
|
||
|
||
#SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则
|
||
#error_page 404/404.html;
|
||
|
||
#HTTP_TO_HTTPS_START
|
||
set $isRedcert 1;
|
||
if ($server_port != 443) {
|
||
set $isRedcert 2;
|
||
}
|
||
if ( $uri ~ /\.well-known/ ) {
|
||
set $isRedcert 1;
|
||
}
|
||
if ($isRedcert != 1) {
|
||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||
}
|
||
#HTTP_TO_HTTPS_END
|
||
|
||
ssl_certificate /www/server/panel/vhost/cert/yunzer.com.cn/fullchain.pem;
|
||
ssl_certificate_key /www/server/panel/vhost/cert/yunzer.com.cn/privkey.pem;
|
||
ssl_protocols TLSv1.2 TLSv1.3;
|
||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||
ssl_prefer_server_ciphers on;
|
||
ssl_session_tickets on;
|
||
ssl_session_cache shared:SSL:10m;
|
||
ssl_session_timeout 10m;
|
||
add_header Strict-Transport-Security "max-age=31536000";
|
||
error_page 497 https://$host$request_uri;
|
||
#SSL-END
|
||
|
||
# --- 安全拦截:敏感文件 ---
|
||
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$ {
|
||
return 404;
|
||
}
|
||
|
||
# --- 安全拦截:敏感目录 ---
|
||
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ {
|
||
return 404;
|
||
}
|
||
|
||
# SSL 证书验证目录
|
||
location ~ \.well-known {
|
||
allow all;
|
||
}
|
||
|
||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||
return 403;
|
||
}
|
||
|
||
# /api/ 转发并剥掉 /api 前缀(尾部斜杠不能删)
|
||
location /api/ {
|
||
proxy_pass http://go_backend_yunzer/;
|
||
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 X-Forwarded-Proto $scheme;
|
||
}
|
||
|
||
# 七牛图片代理:/qiniu/ 前缀会被剥掉,转发到七牛对象路径
|
||
# 存储配置 qiniu_domain 填 https://yunzer.com.cn/qiniu(末尾不要加 /)
|
||
location /qiniu/ {
|
||
proxy_pass http://7colud.yunzer.cn/;
|
||
proxy_set_header Host 7colud.yunzer.cn;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
|
||
# 浏览器缓存图片 7 天,减少重复请求
|
||
expires 7d;
|
||
add_header Cache-Control "public, max-age=604800";
|
||
}
|
||
|
||
# 其余一切请求全部交给 Go:首页、/news、/page/xxx、/themes/ 静态资源、
|
||
# /uploads/ 以及以后新增的任何路由,自动生效,nginx 永远不用再改
|
||
location / {
|
||
proxy_pass http://go_backend_yunzer;
|
||
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 X-Forwarded-Proto $scheme;
|
||
}
|
||
|
||
access_log /www/wwwlogs/yunzer.com.cn.log;
|
||
error_log /www/wwwlogs/yunzer.com.cn.error.log;
|
||
}
|
||
|
||
# ==================== 租户官网渲染:*.yunzer.com.cn 所有子域名 ====================
|
||
server {
|
||
listen 80;
|
||
listen 443 ssl;
|
||
http2 on;
|
||
|
||
# 匹配所有子域名,如 ceshi.yunzer.com.cn(租户域名发放后无需再改 nginx)
|
||
server_name ~^(?<subdomain>.+)\.yunzer\.com\.cn$;
|
||
|
||
#CERT-APPLY-CHECK--START
|
||
include /www/server/panel/vhost/nginx/well-known/yunzer.com.cn.conf;
|
||
#CERT-APPLY-CHECK--END
|
||
|
||
#HTTP_TO_HTTPS_START
|
||
set $isRedcert 1;
|
||
if ($server_port != 443) {
|
||
set $isRedcert 2;
|
||
}
|
||
if ( $uri ~ /\.well-known/ ) {
|
||
set $isRedcert 1;
|
||
}
|
||
if ($isRedcert != 1) {
|
||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||
}
|
||
#HTTP_TO_HTTPS_END
|
||
|
||
# 注意:子域名 HTTPS 要求泛域名证书(*.yunzer.com.cn),见文档"注意事项"
|
||
ssl_certificate /www/server/panel/vhost/cert/yunzer.com.cn/fullchain.pem;
|
||
ssl_certificate_key /www/server/panel/vhost/cert/yunzer.com.cn/privkey.pem;
|
||
ssl_protocols TLSv1.2 TLSv1.3;
|
||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||
ssl_prefer_server_ciphers on;
|
||
ssl_session_cache shared:SSL:10m;
|
||
ssl_session_timeout 10m;
|
||
add_header Strict-Transport-Security "max-age=31536000";
|
||
error_page 497 https://$host$request_uri;
|
||
|
||
location ~ \.well-known {
|
||
allow all;
|
||
}
|
||
|
||
# 全部请求转发给 Go,由 Go 按 Host(如 ceshi.yunzer.com.cn)识别租户并渲染官网
|
||
location / {
|
||
proxy_pass http://go_backend_yunzer;
|
||
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 X-Forwarded-Proto $scheme;
|
||
}
|
||
|
||
access_log /www/wwwlogs/yunzer.com.cn.tenant.log;
|
||
error_log /www/wwwlogs/yunzer.com.cn.tenant.error.log;
|
||
}
|
||
```
|
||
|
||
> 若管理端(platform/backend 前端)部署在专属子域名(如 `admin.yunzer.com.cn`),在对应站点再加一个 `server_name admin.yunzer.com.cn;` **精确匹配**的 server 块指向其静态目录即可——精确匹配优先于子域名正则块,互不干扰。
|
||
|
||
---
|
||
|
||
## 五、租户自有域名(扑底站点 + 自动 HTTPS)
|
||
|
||
> 前面两节的站点只匹配 `yunzer.com.cn` / `*.yunzer.com.cn` / `dh2.fun` / `*.dh2.fun`。租户绑定自己的域名(如 `www.客户公司.com`)后,解析过来会落到 nginx 的默认站点,命中不了上面的任何 `server_name`。本节的扑底站点解决两件事:**让未知域名也能进 Go**,以及**为这些域名提供 HTTPS 证书**。
|
||
|
||
### 5.1 工作流程
|
||
|
||
1. 租户在后台「我的域名」点「绑定自有域名」,填写完整域名;
|
||
2. 系统返回 CNAME 目标 / A 记录 IP(来自 `app.conf` 的 `customdomain_cname_target`、`customdomain_a_ips`);
|
||
3. 租户到域名服务商添加解析,回来点「检测解析」;
|
||
4. Go 用 `net.LookupCNAME` / `net.LookupHost` 校验解析是否指向平台;
|
||
5. 校验通过后 Go 异步向 Let's Encrypt 发起 HTTP-01 申请,CA 回访 `http://域名/.well-known/acme-challenge/<token>`;
|
||
6. nginx 扑底站点把该路径**明文**反代给 Go,Go 应答 token;
|
||
7. 签发成功后证书写入 `{ssl_cert_dir}/{域名}/fullchain.pem` 与 `privkey.pem`,记录置 `ssl_status=2`、`status=1`,域名生效;
|
||
8. 每 12 小时扫一次,到期前 30 天自动续期。
|
||
|
||
### 5.2 app.conf 需要填的五项
|
||
|
||
```ini
|
||
# 租户子域名 CNAME 到这个主机名(该主机名需已 A 记录指向本服务器)
|
||
customdomain_cname_target = sites.yunzer.com.cn
|
||
|
||
# 根域名只能用 A 记录,这里写本服务器公网 IP(多个用英文逗号分隔)
|
||
customdomain_a_ips = 1.2.3.4
|
||
|
||
# Let's Encrypt 账号邮箱
|
||
acme_email = ops@yunzer.com.cn
|
||
|
||
# 留空使用正式环境;联调先填 staging,避免打满正式环境频控
|
||
# acme_directory = https://acme-staging-v02.api.letsencrypt.org/directory
|
||
acme_directory =
|
||
|
||
# 证书落盘目录,Go 进程必须可写,nginx 必须可读
|
||
ssl_cert_dir = /www/wwwroot/ssl-certs
|
||
```
|
||
|
||
`customdomain_cname_target` 建议单独用一个平台子域(如 `sites.yunzer.com.cn`),指向服务器 IP。以后换服务器只改这条 A 记录,所有租户的 CNAME 都跟着走,不用逐个通知租户改解析。
|
||
|
||
### 5.3 扑底站点配置
|
||
|
||
新建一个宝塔站点(域名可随便填一个不用的,例如 `custom-domains.local`),或直接在任一站点配置文件里追加下面内容。**注意 upstream 名字不能与 `go_backend_dh2` / `go_backend_yunzer` 重复。**
|
||
|
||
```nginx
|
||
upstream go_backend_custom {
|
||
server 127.0.0.1:8081;
|
||
}
|
||
|
||
# SNI 决定用哪个域名的证书目录;无 SNI 的请求回落到 _default
|
||
map $ssl_server_name $cert_host {
|
||
"" _default;
|
||
default $ssl_server_name;
|
||
}
|
||
|
||
# ---------- HTTP:只负责 ACME 验证与明文访问,不做整站跳转 ----------
|
||
server {
|
||
listen 80 default_server;
|
||
server_name _;
|
||
|
||
# 关键:ACME 验证不能 301 到 HTTPS。首次签发时还没有证书,
|
||
# 跳过去 CA 会因为证书不可信而验证失败。
|
||
location /.well-known/acme-challenge/ {
|
||
proxy_pass http://go_backend_custom;
|
||
proxy_set_header Host $host;
|
||
}
|
||
|
||
location / {
|
||
proxy_pass http://go_backend_custom;
|
||
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 X-Forwarded-Proto $scheme;
|
||
}
|
||
|
||
access_log /www/wwwlogs/custom-domain.log;
|
||
error_log /www/wwwlogs/custom-domain.error.log;
|
||
}
|
||
|
||
# ---------- HTTPS:按 SNI 从证书目录取对应域名的证书 ----------
|
||
server {
|
||
listen 443 ssl default_server;
|
||
http2 on;
|
||
server_name _;
|
||
|
||
ssl_certificate /www/wwwroot/ssl-certs/$cert_host/fullchain.pem;
|
||
ssl_certificate_key /www/wwwroot/ssl-certs/$cert_host/privkey.pem;
|
||
ssl_protocols TLSv1.2 TLSv1.3;
|
||
ssl_session_cache shared:SSL:10m;
|
||
ssl_session_timeout 10m;
|
||
|
||
# 这里不要加 HSTS:证书是自动签的,一旦某个域名签发失败又被浏览器记住 HSTS,
|
||
# 租户连 HTTP 兜底访问都用不了。
|
||
|
||
location / {
|
||
proxy_pass http://go_backend_custom;
|
||
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 X-Forwarded-Proto $scheme;
|
||
}
|
||
|
||
access_log /www/wwwlogs/custom-domain.ssl.log;
|
||
error_log /www/wwwlogs/custom-domain.ssl.error.log;
|
||
}
|
||
```
|
||
|
||
### 5.4 上线前必做的四件事
|
||
|
||
1. **放一份 `_default` 兜底证书**。`ssl_certificate` 用了变量,nginx 启动时不校验文件是否存在,但握手时找不到文件会直接失败。没有 SNI 的客户端(老设备、直接用 IP 访问)会落到 `_default`:
|
||
|
||
```bash
|
||
mkdir -p /www/wwwroot/ssl-certs/_default
|
||
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||
-keyout /www/wwwroot/ssl-certs/_default/privkey.pem \
|
||
-out /www/wwwroot/ssl-certs/_default/fullchain.pem \
|
||
-subj "/CN=invalid"
|
||
```
|
||
|
||
2. **nginx 版本 ≥ 1.15.9**。`ssl_certificate` 支持变量是从这个版本开始的,宝塔默认的 1.2x 都满足。低版本会报 `invalid number of arguments` 或直接不生效。
|
||
|
||
3. **证书目录权限**。Go 进程要能创建子目录与写文件,nginx 要能读:
|
||
|
||
```bash
|
||
mkdir -p /www/wwwroot/ssl-certs
|
||
chown -R <go进程用户>:www /www/wwwroot/ssl-certs
|
||
chmod 755 /www/wwwroot/ssl-certs
|
||
```
|
||
|
||
4. **公网 80 与 443 都要可达**。HTTP-01 验证走 80 端口,被防火墙或安全组挡住会签发失败。
|
||
|
||
### 5.5 default_server 的影响范围
|
||
|
||
`listen 80 default_server` 会把「所有没匹配到具体 server_name 的请求」都吃掉。原来这类请求落到宝塔默认站点(通常是一个空白页或宝塔欢迎页)。如果服务器上还有其他靠默认站点兜底的业务,先确认它们都有精确的 `server_name`,否则会被这个扑底站点抢走。nginx 的匹配优先级是:精确 `server_name` > 前导通配 > 尾随通配 > 正则 > `default_server`,前面两节的配置都是精确匹配或正则,不受影响。
|
||
|
||
### 5.6 排查
|
||
|
||
| 现象 | 原因 | 处理 |
|
||
|---|---|---|
|
||
| 「解析检测」提示当前解析到别的 IP | 解析没生效或填错 | 等 TTL 过期;`dig +short 域名` 核对 |
|
||
| 「解析检测」提示平台尚未配置解析目标 | `app.conf` 两项为空 | 填 `customdomain_cname_target` / `customdomain_a_ips` 后重启 Go |
|
||
| 证书状态一直「签发中」 | CA 回访拿不到 token | `curl http://域名/.well-known/acme-challenge/test` 应到 Go 而非 301 |
|
||
| 证书「签发失败」,原因含 403/404 | 80 端口不可达或被 HTTPS 跳转拦了 | 检查安全组、确认 challenge 的 location 在跳转规则之前 |
|
||
| 浏览器提示证书名称不匹配 | 落到了 `_default` 自签证书 | 说明 SNI 对应目录没有证书,看该域名的 `ssl_status` 与 `ssl_error` |
|
||
| 签发失败后短时间点不动 | 失败冷却 30 分钟 | 等冷却结束再点,或先用 staging 联调 |
|
||
|
||
> 签发失败原因会写进 `yz_system_tenant_domain.ssl_error`,前端「证书」列悬浮即可看到。Let's Encrypt 对同一账号每小时失败次数有限制,所以代码里加了 30 分钟冷却,避免租户反复点击把额度打满。
|
||
|
||
---
|
||
|
||
## 六、重要注意事项
|
||
|
||
1. **子域名必须用泛域名证书**。租户域名是二级域名(`ceshi.yunzer.com.cn`),单域名证书会导致 HTTPS 证书错误。需在宝塔为 `*.yunzer.com.cn`、`*.dh2.fun` 申请泛域名证书(DNS 验证),并替换配置中的证书路径。
|
||
2. **upstream 不能重名**。两个站点配置都在同一 http 上下文,本文分别用 `go_backend_dh2`、`go_backend_yunzer`;重名会 `duplicate upstream` 启动失败。
|
||
3. **`/api/` 和 `/qiniu/` 的 `proxy_pass` 尾部斜杠不能删**。`/api/` 的斜杠负责剥掉 `/api` 前缀(Go 路由注册在 `/backend/*`、`/platform/*`);`/qiniu/` 的斜杠负责剥掉 `/qiniu` 前缀,使七牛收到正确对象路径。兜底 `location /` 的 `proxy_pass` 则**不带**尾部斜杠(原样转发 URI)。
|
||
4. **存储域名必须与复制的主站配置对应**。使用 `yunzer.com.cn` 配置则后台“存储配置”中的 `qiniu_domain` 填 `https://yunzer.com.cn/qiniu`;使用 `dh2.fun` 配置则填 `https://dh2.fun/qiniu`。均不带末尾 `/`。
|
||
5. **不要再给官网域名加 `js|css` 正则缓存 location**。正则 location 优先级高于兜底前缀 location,会拦截 `/themes/xxx/static/main.js` 去本地 root 找文件导致 404。模板静态资源由 Go(beego)直接提供。
|
||
6. **`proxy_set_header Host $host;` 必须保留**。Go 完全依赖 Host 识别租户;若透传成 upstream 地址,所有请求都会被识别失败。
|
||
7. **Go 侧前置条件**:
|
||
- 启动模式 `APP_MODE` 为 `all`(默认)或 `index`,否则官网渲染路由未注册;
|
||
- `go/themes/` 目录与 Go 服务启动目录同级(模板文件在 **Go 项目目录** `go/themes/{编码}/`,与 platform/backend 前端项目无关);
|
||
- 数据库已执行 `sql/yz_cms_frontend_tables.sql`。
|
||
8. **域名发放数据必须精确**。`yz_system_tenant_domain.full_domain` 要与实际访问域名完全一致(不带协议、不带端口、不带路径),且 `status=1`、`delete_time` 为 NULL。
|
||
9. **DNS 泛解析**。`*.yunzer.com.cn`、`*.dh2.fun` 需要配置泛解析 A 记录指向服务器,否则新发放的子域名无法访问(与 nginx 无关)。
|
||
10. **HSTS 提醒**。配置了 `Strict-Transport-Security`,一旦浏览器记住,证书出问题期间该域名将无法用 HTTP 访问;测试阶段可先注释掉该行。
|
||
11. **宝塔面板操作提示**。直接编辑站点配置文件后,用 `nginx -t` 校验再重载;宝塔后续"站点设置"的某些操作可能重写配置文件,改前建议备份。
|
||
|
||
---
|
||
|
||
## 七、验证与排查
|
||
|
||
### 7.1 部署后验证顺序
|
||
|
||
```bash
|
||
# 1. 绕过 nginx 直接验证 Go(在服务器上执行)
|
||
curl -H "Host: ceshi.yunzer.com.cn" http://10.31.100.2:8081/
|
||
|
||
# 2. 验证 nginx → Go 链路
|
||
curl -k -H "Host: ceshi.yunzer.com.cn" https://127.0.0.1/
|
||
|
||
# 3. 验证七牛代理:应返回图片内容或 200/304,而不是 Go 的 404 页面
|
||
curl -I https://yunzer.com.cn/qiniu/2026/06/02/1780371759517804300.png
|
||
|
||
# 4. 自有域名:先确认扑底站点能把 challenge 明文送到 Go(不应返回 301)
|
||
curl -i -H "Host: 待绑域名" http://127.0.0.1/.well-known/acme-challenge/probe
|
||
|
||
# 5. 自有域名:确认 Go 能按 Host 命中租户
|
||
curl -H "Host: 待绑域名" http://127.0.0.1:8081/
|
||
|
||
# 6. 浏览器访问 https://ceshi.yunzer.com.cn
|
||
```
|
||
|
||
### 7.2 常见现象对照表
|
||
|
||
| 现象 | 原因 | 处理 |
|
||
|---|---|---|
|
||
| Go 页面显示"站点不存在" | Host 在 `yz_system_tenant_domain` / 域名池中查不到 | 核对 `full_domain` 拼写、`status`、`delete_time` |
|
||
| beego 默认 404 | `APP_MODE` 不含 `all`/`index`,渲染路由未注册 | 检查 Go 启动环境变量 |
|
||
| nginx 404(未到 Go) | 请求被本地静态/正则规则拦截 | 检查是否残留 `try_files /index.html`、`js|css` 正则 location |
|
||
| 浏览器证书错误 | 非泛域名证书 | 申请 `*.域名` 泛域名证书并替换路径 |
|
||
| 502 Bad Gateway | Go 服务未启动或 upstream 地址错误 | `curl http://127.0.0.1:8081/` 验证服务存活,改 upstream |
|
||
| 首页正常但样式丢失 | `/themes/` 未到达 Go | 确认兜底转发存在且无 js/css 正则拦截 |
|
||
|
||
---
|
||
|
||
## 八、迁移服务器清单(以迁移到 192.168.1.101 为例)
|
||
|
||
1. **新机器部署 Go**:复制 `go/` 目录(含 `themes/`、`conf/app.conf`),修改 `app.conf` 数据库连接为新库地址,启动服务;
|
||
2. **新库初始化**:导入业务数据,执行 `sql/` 下全部建表/变更 SQL(尤其 `yz_cms_frontend_tables.sql`、`alter_tenant_domain_add_custom.sql`);
|
||
3. **nginx**:
|
||
- nginx 与 Go 同机 → upstream 用 `127.0.0.1:8081`,配置零改动;
|
||
- 跨机 → 只改三个 upstream 块中的 `server` 一行(含扑底站点的 `go_backend_custom`);
|
||
4. **证书**:新机器宝塔上为两个域名重新申请/导入证书(含泛域名),路径与配置保持一致;自有域名的证书目录 `/www/wwwroot/ssl-certs` 整个复制过去(含 `_account/key.pem`——换了账号私钥等于换 ACME 账号,历史签发记录与频控额度都会重置);
|
||
5. **DNS**:将 `dh2.fun`、`yunzer.com.cn` 及泛解析 `*` 的 A 记录改指向新服务器 IP;`customdomain_cname_target` 对应子域的 A 记录也要改,租户侧的 CNAME 不用动;
|
||
6. **验证**:按第七节顺序逐一验证;
|
||
7. 确认无误后到域名服务商处调低旧解析 TTL、观察切换。
|
||
|
||
---
|
||
|
||
*本文档对应代码:`go/controllers/tenant_site.go`(Host 识别与渲染)、`go/services/acme_cert.go`(自动签发与续期)、`go/services/domain_verify.go`(解析检测)、`go/controllers/acme_challenge.go`(HTTP-01 应答)、`go/pkg/tagengine/`(标签引擎)、`go/themes/default/`(内置默认模板)、`sql/yz_cms_frontend_tables.sql`(建站数据表)、`sql/alter_tenant_domain_add_custom.sql`(自有域名字段)。*
|