跳到正文

把公开状态页放到独立域名

目标是这样一个部署:访客打开 https://status.example.com/,直接看到某一个公开 状态页 —— 地址栏里既没有 /status,也没有 #/<slug>;而同一个域名上,控制台、管理 API、Agent 通道,以及你建的其它状态页,一概不可达。

本文给出 nginx 和 Caddy 的完整配置,两种拓扑各一份。

也许你不需要这一篇

如果你只是想让访客打开本服务器的根地址就看到状态板,不必搭反代:在控制台里编辑那个 状态页,打开「设为首页」即可。之后未登录的访客访问 / 会被跳转到这一页,页面右上角带 一个登录入口;已登录的管理员访问 / 仍然进控制台。整台服务器只能有一个状态页作为首页。

但要注意区别,它正是本文存在的理由:「设为首页」是一次 302 跳转,访客最终停在 /status/#/<slug> —— 地址栏里 /status#/<slug> 两样都在,只是入口变成了根 地址。而且它是同域名方案,控制台和管理接口仍在这个域名下,只是不再占据根地址。

只有当你要求「地址栏干干净净」,或者要求状态页所在的域名上根本够不到控制台、管理 API 和 Agent 通道时,才需要本文的反代方案 —— 那也是本文所有配置都写 console: false 的原因:那个域名上 /login 是被刻意封死的,状态页就不该再显示登录入口。

控制台可以直接生成

不想手抄的话:控制台「公共状态页」列表里,每一行都有「反代配置」按钮 —— 填个域名就能 拿到下面这份配置,slug 已经替你填好,nginx / Caddy 与两种拓扑都能切。本文解释这份配置 为什么这么写,以及生成之后怎么验收。

开始之前

  1. 在控制台里建好并发布一个状态页,记下它的 slug(下文一律用 home-lab 举例)。
  2. 反代机器能访问 Server。下文写成 127.0.0.1:12450;分机部署就换成内网地址。
  3. 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 字段就是 干这个的:

js
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

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

txt
# /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/ 下的一个子目录,三种来源任选:

bash
# 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 一样:

js
window.NETTACT_STATUS_CONFIG = { apiBase: '', page: 'home-lab', console: false }

这样复制过来的文件保持原样,换页面时只改反代配置一处。apiBase 仍然留空:接口由同一个 vhost 反代过去,页面与接口同源,不涉及跨域。

也可以不反代接口

apiBase 直接写成 Server 的对外地址也能跑通 —— 公开接口带 Access-Control-Allow-Origin: *,浏览器不会拦。但那意味着 Server 必须对公网可达, 本文要避免的正是这一点。保持 apiBase: '' 并让反代转发。

nginx

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

txt
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:

bash
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.htmlconfig.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 秒轮询一轮,正常 访客的量很小,给它加一条限流不影响使用:

nginx
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 作用域了。给控制台单独一个域名,或者干脆 只在内网访问。

配置清单以各二进制 --help 输出为单一事实来源