把公开状态页放到独立域名
目标是这样一个部署:访客打开 https://status.example.com/,直接看到某一个公开 状态页 —— 地址栏里既没有 /status,也没有 #/<slug>;而同一个域名上,控制台、管理 API、Agent 通道,以及你建的其它状态页,一概不可达。
本文给出 nginx 和 Caddy 的完整配置,两种拓扑各一份。
也许你不需要这一篇
如果你只是想让访客打开本服务器的根地址就看到状态板,不必搭反代:在控制台里编辑那个 状态页,打开「设为首页」即可。之后未登录的访客访问 / 会被跳转到这一页,页面右上角带 一个登录入口;已登录的管理员访问 / 仍然进控制台。整台服务器只能有一个状态页作为首页。
但要注意区别,它正是本文存在的理由:「设为首页」是一次 302 跳转,访客最终停在 /status/#/<slug> —— 地址栏里 /status 和 #/<slug> 两样都在,只是入口变成了根 地址。而且它是同域名方案,控制台和管理接口仍在这个域名下,只是不再占据根地址。
只有当你要求「地址栏干干净净」,或者要求状态页所在的域名上根本够不到控制台、管理 API 和 Agent 通道时,才需要本文的反代方案 —— 那也是本文所有配置都写 console: false 的原因:那个域名上 /login 是被刻意封死的,状态页就不该再显示登录入口。
控制台可以直接生成
不想手抄的话:控制台「公共状态页」列表里,每一行都有「反代配置」按钮 —— 填个域名就能 拿到下面这份配置,slug 已经替你填好,nginx / Caddy 与两种拓扑都能切。本文解释这份配置 为什么这么写,以及生成之后怎么验收。
开始之前
- 在控制台里建好并发布一个状态页,记下它的 slug(下文一律用
home-lab举例)。 - 反代机器能访问 Server。下文写成
127.0.0.1:12450;分机部署就换成内网地址。 - Server 自己不要直接暴露在公网。 这篇文章的隔离完全由这台反代提供;如果 Server 的原始端口同时也能从公网直连,控制台就还在外面,白名单等于没写。把它绑在 回环或内网,或者让它的原域名只对你自己开放。
只放行四个只读接口
公开状态页在运行时只请求四个匿名 GET 接口:
/api/v1/public/pages/<slug>
/api/v1/public/pages/<slug>/agent-statuses
/api/v1/public/pages/<slug>/target-statuses
/api/v1/public/pages/<slug>/incidents下面的配置把 slug 写死在规则里,其余路径一律 404。写死是关键的一步:白名单如果 写成 /api/v1/public/pages/,任何人猜到别的 slug 就能在这个域名上读到你别的状态页。
被挡在外面的是:
/api/v1/auth/*、/api/v1/sites、/api/v1/agents/*等所有会话接口 —— 它们本来 就返回 401,但在这个域名上连 401 都不该出现;/api/v1/enroll与/api/v1/agent/ws—— Agent 注册与长连接;/api/v1/events—— 控制台的 SSE 推送;- 控制台前端本身。注意 Server 上的
/和/assets/*是控制台的资源,状态页应用 住在/status/下,所以下面的两种方案都要显式地把根路径指向状态页那一份。
让 / 就是这一页:config.js 里的 page
状态页应用走 hash 路由(#/<slug>)。hash 永远不会发到服务器,所以「这个域名要显示 哪一页」反代无从知道,只能由页面自己的运行时配置回答。config.js 里的 page 字段就是 干这个的:
window.NETTACT_STATUS_CONFIG = { apiBase: '', page: 'home-lab', console: false }apiBase留空表示同源取数据 —— 下面两种方案都由这台反代同源提供那四个接口, 所以都保持空值。page是地址里没有#/<slug>时显示的页面。它只是默认值,不是锁:访客手动敲#/other仍然会去请求other。「这个域名只放出去一个页面」是由上面的接口白名单 保证的 —— 越过白名单的 slug 拿不到数据,页面显示「页面不存在」。
config.js 不参与打包,是构建产物里一个可以直接改的文件 —— 不过下面两种方案都不用改它: 都让反代直接下发这一行,构建产物保持原样。
方案 A:全部反代自 Server(推荐)
前端资源也从 Server 取。Server 把状态页应用挂在 /status/,而这个应用的资源引用是 相对路径,所以只要把域名根路径映射到上游的 /status/,它在根路径下就能正常工作。 好处是升级 Server 时你什么都不用做,前端和后端永远是配套的一份。
nginx
# /etc/nginx/conf.d/status.example.com.conf
#
# 本域名只发布 home-lab 这一个状态页。换页面时,下面每一处 home-lab 都要改。
upstream nettact_home_lab {
server 127.0.0.1:12450;
keepalive 16; # 每个访客每 30 秒轮询一轮,复用连接省掉反复握手
}
server {
listen 443 ssl;
http2 on; # nginx < 1.25.1 请写成:listen 443 ssl http2;
server_name status.example.com;
ssl_certificate /etc/letsencrypt/live/status.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/status.example.com/privkey.pem;
# 1) 运行时配置由反代直接下发,覆盖上游那一份 —— 它指定了根路径显示哪个页面。
location = /config.js {
default_type application/javascript;
add_header Cache-Control "no-store" always;
return 200 'window.NETTACT_STATUS_CONFIG = { apiBase: "", page: "home-lab", console: false };';
}
# 2) 唯一放行的四个只读接口。slug 写死,别的状态页在这个域名上不存在。
location ~ ^/api/v1/public/pages/home-lab(/(agent-statuses|target-statuses|incidents))?$ {
limit_except GET HEAD { deny all; }
proxy_pass http://nettact_home_lab; # 不带 URI:原样透传路径
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 3) 页面本体:根路径、favicon 与 assets 映射到上游的 /status/。
# 只放行公开状态页构建产物,不暴露控制台的其他静态文件。
location = / {
proxy_pass http://nettact_home_lab/status/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
}
location = /favicon.svg {
proxy_pass http://nettact_home_lab/status/favicon.svg;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
}
location ^~ /assets/ {
proxy_pass http://nettact_home_lab/status/assets/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
}
# 4) 其余全部关闭:控制台、管理 API、Agent 通道、别的状态页。
location / { return 404; }
}
server {
listen 80;
server_name status.example.com;
return 301 https://$host$request_uri;
}匹配顺序上有两处值得说明,改动时不要破坏:
location = /是精确匹配,先于location /命中,所以根路径去的是/status/而不是控制台首页;location ~ ...正则先于location /前缀兜底,所以四个公开接口能过,/api/下的其它路径落到return 404。
Caddy
# /etc/caddy/Caddyfile
#
# 本域名只发布 home-lab 这一个状态页。换页面时,下面每一处 home-lab 都要改。
# 证书由 Caddy 自动申请与续期,80 端口自动跳转到 443。
status.example.com {
encode zstd gzip
# 1) 唯一放行的四个只读接口。
@public {
method GET HEAD
path_regexp ^/api/v1/public/pages/home-lab(/(agent-statuses|target-statuses|incidents))?$
}
handle @public {
reverse_proxy 127.0.0.1:12450
}
# 2) 运行时配置由反代下发,指定根路径显示哪个页面。
handle /config.js {
header Content-Type "application/javascript"
header Cache-Control "no-store"
respond `window.NETTACT_STATUS_CONFIG = { apiBase: "", page: "home-lab", console: false };` 200
}
# 3) 页面本体:根路径、favicon 与 /assets/ 映射到上游的 /status/ 下。
handle /assets/* {
rewrite * /status{uri}
reverse_proxy 127.0.0.1:12450
}
handle /favicon.svg {
rewrite * /status/favicon.svg
reverse_proxy 127.0.0.1:12450
}
handle / {
rewrite * /status/
reverse_proxy 127.0.0.1:12450
}
# 4) 其余全部关闭。handle 块互斥且按书写顺序匹配,所以这个兜底必须放最后。
handle {
respond 404
}
}方案 B:静态托管一份页面(反代只转发接口)
反代机器上放一份构建好的状态页应用,只把那四个接口转发给 Server。适合反代与 Server 之间只想开一条窄通道、或者页面要放到 CDN / 对象存储的场景。
代价是这份静态文件是你自己的副本:Server 升级后要记得重新复制一次。NetTact 处于 开发阶段、不写向后兼容代码,前后端版本对不上时公开接口的字段可能直接变化,而方案 A 不存在这个问题。
先取到文件。status/ 是构建产物 dist/ 下的一个子目录,三种来源任选:
# a) 从已部署的 Server 上直接复制(web console 安装目录,默认 <db 目录>/webui)
docker compose exec server ls /data/webui # 列出已安装的版本目录
docker compose cp server:/data/webui/<版本>/status ./nettact-status
# b) 下载发布包。<web-console 版本> 不是 Server 的版本号 —— 它是 Server 编译时
# 钉住的那个 web-console tag,也就是上面 a) 里列出的那个目录名(启动日志里也有)。
curl -fsSLO https://d.nettact.org/web-console/<web-console 版本>/web-console-dist-<web-console 版本>.tar.gz
tar xzf web-console-dist-<web-console 版本>.tar.gz # 解出 index.html、assets/、status/
# c) 从源码构建
cd web-console && npm run build # 产出 web-console/dist/status/把 status/ 的内容放到反代机器的 /var/www/nettact-status/。不用改里面的 config.js —— 下面的配置里,/config.js 由反代直接下发,和方案 A 一样:
window.NETTACT_STATUS_CONFIG = { apiBase: '', page: 'home-lab', console: false }这样复制过来的文件保持原样,换页面时只改反代配置一处。apiBase 仍然留空:接口由同一个 vhost 反代过去,页面与接口同源,不涉及跨域。
也可以不反代接口
把 apiBase 直接写成 Server 的对外地址也能跑通 —— 公开接口带 Access-Control-Allow-Origin: *,浏览器不会拦。但那意味着 Server 必须对公网可达, 本文要避免的正是这一点。保持 apiBase: '' 并让反代转发。
nginx
server {
listen 443 ssl;
http2 on;
server_name status.example.com;
ssl_certificate /etc/letsencrypt/live/status.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/status.example.com/privkey.pem;
root /var/www/nettact-status; # dist/status/ 的副本
# 页面本体、favicon、运行时配置与带哈希资源,其余 404。
location = / {
try_files /index.html =404;
add_header Cache-Control "no-store" always;
}
location = /favicon.svg {
try_files /favicon.svg =404;
}
# 运行时配置由反代下发,而不是从磁盘读:复制来的 config.js 里 page 是空的,
# 忘了改就会看到「未指定状态页」。
location = /config.js {
default_type application/javascript;
add_header Cache-Control "no-store" always;
return 200 'window.NETTACT_STATUS_CONFIG = { apiBase: "", page: "home-lab", console: false };';
}
location ^~ /assets/ {
# 文件名带内容哈希,可以放心长期缓存
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# 唯一放行的四个只读接口。
location ~ ^/api/v1/public/pages/home-lab(/(agent-statuses|target-statuses|incidents))?$ {
limit_except GET HEAD { deny all; }
proxy_pass http://10.0.0.5:12450;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / { return 404; }
}Caddy
status.example.com {
encode zstd gzip
root * /var/www/nettact-status
@public {
method GET HEAD
path_regexp ^/api/v1/public/pages/home-lab(/(agent-statuses|target-statuses|incidents))?$
}
handle @public {
reverse_proxy 10.0.0.5:12450
}
handle /config.js {
header Content-Type "application/javascript"
header Cache-Control "no-store"
respond `window.NETTACT_STATUS_CONFIG = { apiBase: "", page: "home-lab", console: false };` 200
}
handle /assets/* {
header Cache-Control "public, max-age=31536000, immutable"
file_server
}
handle /favicon.svg {
file_server
}
handle / {
header Cache-Control "no-store"
file_server
}
handle {
respond 404
}
}验收
改完 reload,逐条对一遍。前五条应该是 200,后面几条必须是 404:
H=https://status.example.com
curl -so /dev/null -w '%{http_code} /\n' $H/
curl -s $H/config.js # 应含 page: "home-lab" 与 console: false
curl -so /dev/null -w '%{http_code} favicon\n' $H/favicon.svg
curl -so /dev/null -w '%{http_code} page\n' $H/api/v1/public/pages/home-lab
curl -so /dev/null -w '%{http_code} targets\n' $H/api/v1/public/pages/home-lab/target-statuses
curl -so /dev/null -w '%{http_code} 别的状态页\n' $H/api/v1/public/pages/other-page
curl -so /dev/null -w '%{http_code} 控制台 API\n' $H/api/v1/sites
curl -so /dev/null -w '%{http_code} 登录\n' $H/api/v1/auth/login
curl -so /dev/null -w '%{http_code} Agent 通道\n' $H/api/v1/agent/ws
curl -so /dev/null -w '%{http_code} Agent 注册\n' $H/api/v1/enroll
curl -so /dev/null -w '%{http_code} 控制台 SSE\n' $H/api/v1/events
curl -so /dev/null -w '%{http_code} 旧路径\n' $H/status/最后用浏览器打开 https://status.example.com/:地址栏应当保持干净的根路径,页面直接 渲染 home-lab。如果看到的是「未指定状态页」,说明 config.js 里的 page 没生效 —— 先 curl $H/config.js 看下发的是哪一份(方案 A 常见原因是 location = /config.js 被写成了前缀匹配,请求落到了上游)。
一些细节
多个状态页,多个域名。 每个域名一份上面的配置,改三处即可:下发的 page、接口 白名单里的正则,以及 upstream 块的名字 —— upstream 是 nginx 全局 http 作用域里 的,两份配置同名会让 nginx 直接拒绝启动。它们互不可见。
缓存。 assets/ 下的文件名带内容哈希,可以长期缓存;index.html 与 config.js 必须 no-store,否则 Server 升级后浏览器会拿着旧的资源名去请求已经不存在的文件。两种方案的配置里都显式写出了 这两个头(方案 A 的 index.html 另外还继承了上游的 no-store)。
搜索引擎。 状态页的 HTML 自带 <meta name="robots" content="noindex"> —— 它默认 不是给搜索引擎收录的。如果你希望这个域名被收录,方案 B 直接改静态文件里的这一行;方案 A 需要在反代上用 sub_filter 去掉它,注意同时设 proxy_set_header Accept-Encoding "";, 否则上游返回的是压缩过的正文,sub_filter 匹配不到。
限流(可选)。 这是一个匿名接口,面向不确定数量的访客。页面每 30 秒轮询一轮,正常 访客的量很小,给它加一条限流不影响使用:
limit_req_zone $binary_remote_addr zone=nettact_public:10m rate=2r/s;
# 然后在四个接口的 location 里:
limit_req zone=nettact_public burst=10 nodelay;favicon。 构建产物包含 favicon.svg,状态页用相对地址 ./favicon.svg 引用它, 因此直接挂在 /status/ 或复制到其他路径前缀时都能正常解析。上面的配置只精确放行 /favicon.svg 与状态页必需资源;如果沿用旧配置,请补上对应规则,否则浏览器仍会拿到 404。
别把控制台也挂在这个域名上。 控制台用的是 HttpOnly 会话 Cookie,一旦与公开页面同域, 状态页的访客与你的管理会话就共享同一个 Cookie 作用域了。给控制台单独一个域名,或者干脆 只在内网访问。