post: one hostname for a public tracker and an SSO-gated dashboard (EN+ZH) + og/banner images
Deploy / build (push) Successful in 18s

This commit is contained in:
2026-09-30 04:52:43 +08:00
parent 10500a667b
commit 54d8ccef7e
6 changed files with 476 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+21
View File
@@ -1028,6 +1028,27 @@ BANNERS['jpa-version-field-lost-update'] = {
{ n: '4', label: '409 ✓' },
],
};
BANNERS['one-hostname-public-tracker-sso-dashboard'] = {
titlebar: 'root@dsm — stats.hoelee.com · authentik outpost',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl -sI https://stats.hoelee.com/script.js' },
{ t: 'ok', text: '200 — the tracker stays public for every visitor' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl -sI https://stats.hoelee.com/' },
{ t: 'dim', text: '302 → auth.hoelee.com/if/flow/auth-stats/ (SSO)' },
{ t: 'err', text: 'mode=forward_single → app paths 404 behind a "healthy" SSO chain' },
{ t: 'hl', text: 'in proxy mode the outpost IS the reverse proxy' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'skip_path_regex · ^/script\\.js$ ^/api/send ^/api/heartbeat$' },
{ t: 'ok', text: 'mode=proxy + internal_host → one hostname, no second subdomain ✓' },
],
flow: [
{ n: '1', label: 'tracker public' },
{ n: '2', label: 'dashboard gated' },
{ n: '3', label: 'one hostname' },
{ n: '4', label: 'skip paths' },
{ n: '5', label: 'verified ✓' },
],
};
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
+5
View File
@@ -316,6 +316,11 @@ TERMINALS['jpa-version-field-lost-update'] = `
<div class="line"><span class="prompt">&nbsp;</span><span class="cmd">@Version → UPDATE … WHERE version = 3</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">→ 409 Conflict, not a lost update ✓</span></div>`;
TERMINALS['one-hostname-public-tracker-sso-dashboard'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">curl -sI https://stats.hoelee.com/script.js | head -1</span><span class="fix">→ 200</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">mode=forward_single: SSO 302 ✓ · every app path 404</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">mode=proxy + internal_host=http://umami:3000</span><span class="fix">→ dashboard 200 ✓</span></div>`;
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
@@ -0,0 +1,280 @@
---
title: "One Hostname for a Public Tracker and an SSO-Gated Dashboard"
description: "A web-analytics tracker has to be public and its dashboard must not be. How I put both on one hostname with an authentik proxy provider and path-based skip rules."
pubDate: 2026-09-30
category: devops
tags: ["umami", "authentik", "sso", "nginx", "docker", "privacy"]
ogImage: "/og/one-hostname-public-tracker-sso-dashboard.png"
banner: "/banners/one-hostname-public-tracker-sso-dashboard.png"
draft: false
---
Every analytics tool has the same awkward shape. The half that *collects* data
has to be reachable by a stranger's browser on every page load. The half that
*shows* the data must be reachable by nobody but me.
That normally means two hostnames: a public ingest endpoint and a private
dashboard. I did not want two hostnames. I wanted one DNS record, one
certificate, one thing to remember — and a dashboard I could open from a hotel
wifi in Kuala Lumpur without publishing a login page to the internet.
This is the shape that ended up working, and the two traps that made a
perfectly healthy-looking configuration serve 404s for half an hour.
## The constraint list
- **The tracker must be public.** `script.js` and the two ingest endpoints are
fetched by every visitor's browser. If they are behind auth, you collect
nothing and you will not notice for days.
- **The dashboard must not be public.** It shows every visitor's country, city,
referrer and page path. A default admin login on a public URL is a gift to
whoever finds the subdomain in a certificate-transparency log.
- **No wildcard DNS.** `*.hoelee.com` does not resolve, so every hostname is an
explicit DNS record plus a certificate. A second hostname is real work, and it
doubles the surface I have to keep patched.
- **This hostname does not go through Cloudflare.** It is a direct A record to my
router, so there is no WAF, no bot protection and no `CF-Connecting-IP` header
to lean on. Everything below has to work with plain nginx headers.
## Attempt 1 — two hostnames
The conventional split is what PostHog and Sentry do: `i.posthog.com` for ingest,
`app.posthog.com` for the UI. That is a real requirement when ingest is served
from a CDN at thousands of requests per second and the app is a stateful
database client. My blog gets a handful of visits a day. I was copying an
architecture that solves a problem I do not have, and paying for it with a second
DNS record, a second certificate and a second thing to break.
Two hostnames is the *scale* answer, not the *requirement*. One hostname can
carry both, split by path.
## Attempt 2 — nginx basic auth (this one does not work, and here is why)
My first instinct was the cheapest possible gate: an nginx container in front of
the analytics app, with `auth_basic` on everything except the three tracker
paths. It is four lines of config and I have used it before.
It breaks the dashboard completely, and the failure is confusing enough to be
worth writing down.
The app's own front end talks to its own API with a bearer token:
```
GET /api/websites HTTP/1.1
Authorization: Bearer <token>
```
A browser sends **one** `Authorization` header per request. When the front end
adds its bearer token, that header replaces the basic-auth credentials — so the
gate reads a bearer token where it expects a base64 user:password pair, decides
the request is unauthenticated, and returns 401. The dashboard shell loads
(that request carries basic auth), and then every single API call fails. You get
a UI that renders its layout and shows nothing, which reads as "the analytics
tool is broken" rather than "my gate is fighting my app".
Cookie-based auth does not have this problem, because the credentials live in a
`Cookie` header that the app's own tokens never touch. So: cookie auth, or no
gate.
## Attempt 3 — an nginx allowlist gate (works, but the dashboard goes LAN-only)
The next version dropped basic auth entirely and made the gate a pure allowlist:
`/script.js`, `/api/send` and `/api/heartbeat` pass through, everything else gets
403. That is genuinely safe — the tracker is public, the dashboard is not
reachable at all — and it is a fine permanent answer if you only ever look at
your stats from inside your own network.
I wanted to see the dashboard from anywhere, so this became the fallback rather
than the destination. I stopped the container and kept it, which turned out to be
the right call later: rollback was one `docker start` and one config line.
## Attempt 4 — authentik proxy provider with path-based skip rules
I already run authentik for single sign-on across about twenty self-hosted apps.
Most of them are gated the same way: the reverse proxy forwards the request to
authentik's embedded outpost, the outpost checks for a session cookie, and an
unauthenticated visitor gets a 302 to the login flow and back.
The proxy provider has a field built for exactly this problem:
`skip_path_regex`. One regex per line. Any path that matches is forwarded
straight to the app with **no authentication at all**; anything that does not
match is bounced to the SSO login. So the split stops being an nginx concern and
becomes an application-level policy.
```
mode: proxy
external_host: https://stats.hoelee.com
internal_host: http://umami:3000
skip_path_regex: ^/script\.js$
^/api/send
^/api/heartbeat$
^/mcp(/|$)
```
That is the whole policy: the tracker's asset, its ingest endpoint, its health
endpoint and its MCP endpoint are public; every other path on that hostname
requires SSO.
The nginx vhost then points at the outpost instead of at the app:
```nginx
location / {
proxy_set_header Host $http_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;
proxy_pass http://localhost:10000; # the authentik outpost
}
```
## Trap 1 — the provider mode that 404s the whole app behind a "working" SSO
I built the new provider by cloning one that already worked in my instance. That
is normally the fastest and safest way to add an app, and it is exactly how I got
this wrong.
The provider I copied used `mode: forward_single` with an empty `internal_host`.
That is correct for apps that handle their own login (a browser app, a monitoring
UI behind its own session) — the outpost only has to answer "is this visitor
allowed?", and the app is reached by some other route. But it means the outpost
has **no upstream to proxy to**. When the app you are gating has no login of its
own — a self-hosted analytics dashboard, for instance — the outpost *is* the
proxy, and with an empty `internal_host` it has nowhere to send the request.
The symptom is nasty because the auth half looks perfect:
```
$ curl -sI https://stats.hoelee.com/ | head -1
HTTP/2 302 # → auth.hoelee.com/if/flow/auth-stats/
$ curl -s -o /dev/null -w '%{http_code}' https://stats.hoelee.com/script.js
404 # ← but the app is not there
```
302 to the login flow, the branded login page renders, the flow completes — and
every application path returns 404. Two things made the diagnosis fast once I
looked for them:
1. **The `x-powered-by` header says which hop answered.** The 404 carried
`x-powered-by: authentik`, which proves the outpost produced it and the app
never saw the request. Without that header I would have been debugging the
application's routing.
2. **Compare against a provider that works.** The same anonymous request against
an app that is known good ends at the login flow with a 200. Mine ended at
404 — same shape, different last hop.
The fix is one field pair: `mode: proxy` with a real `internal_host`.
```
mode: proxy # not forward_single
internal_host: http://umami:3000 # the outpost proxies to the container
```
`forward_single` = "the app is somewhere else, just check the visitor".
`proxy` = "you are the reverse proxy, send the request to `internal_host`".
Pick by asking who serves the response.
## Trap 2 — the outpost needs a minute, and it lies convincingly
After changing a provider, the embedded outpost takes one to two minutes to pick
up the new configuration. During that window the hostname answers like this:
```
302 → /flows/-/default/authentication/ # a default flow, slug "-"
404 # on every app path
```
That is not the new configuration failing — it is the old configuration not yet
being replaced. I lost time on this twice: once concluding the wiring was broken,
once concluding the mode change had not taken effect. Now I change one thing,
wait ninety seconds, and only then judge the result. The same applies to
attaching a new provider to the outpost: the provider exists in the API
immediately, and starts answering requests a minute later.
## What I actually verified, and from where
Internal checks are worthless for this class of change. My instance has two
ingress layers on some hostnames (an nginx vhost and a tunnel rule straight to a
container), so a request from inside the LAN can pass while the public path
bypasses the gate entirely. Everything below was measured from the public
internet, on the real hostname.
| Check | Result |
|---|---|
| `GET /` (anonymous) | 302 → outpost → the app's own login flow → **200** |
| `GET /login`, `/api/websites` | 302, gated |
| `GET /script.js` | **200** — the tracker still loads for visitors |
| `GET /api/heartbeat` | **200** |
| `POST /api/send` with a bogus site id | **400 from the app**, not 403 from a gate — proof the request reaches the app |
| `POST /mcp` with the API key | **200**, `text/event-stream` |
| `POST /mcp` without the key | **401** `Missing bearer API key` — the app's own auth, not the SSO gate |
| A real pageview | recorded with `country=MY region=MY-07 city=George Town`, browser, OS and referrer intact |
That last row is the one that matters. Because this hostname does not go through
Cloudflare, the visitor's IP has to survive two proxy hops via
`X-Forwarded-For`, and the app has to be told to read that header
(`CLIENT_IP_HEADER=x-forwarded-for`) instead of the Cloudflare one. A pageview
counter that increments while every visitor is attributed to a container IP
looks like success and is useless. Recording the right city is the proof.
## What I would do differently
- **Keep the previous gate stopped, not deleted.** The nginx allowlist container
from attempt 3 is still on disk, stopped. Rollback is `docker start` plus one
line in the vhost — thirty seconds, versus rebuilding it from memory under
pressure.
- **Rotate the app's default credentials before the hostname is reachable, not
after.** I had a public URL and a default `admin`/`admin`-style login live at
the same time for part of an afternoon, and a new subdomain shows up in
certificate-transparency logs within hours.
- **Change one field, then wait.** Two of my three wrong conclusions in this
session came from reading a result before the system had finished applying it.
- **Test the app's own login flow too.** SSO passing does not mean the app works:
the gate can be perfect and the session cookie it sets can still be rejected
downstream. I verified the tracker path with curl and the dashboard with a real
browser, and only then called it done.
## The trade-offs, honestly
- **Double login.** The analytics app has no OIDC support, so SSO grants access
to the route and the app still asks for its own username and password. One
extra click, once per browser. Per-account two-factor authentication is
available inside the app, which is the part that actually protects the data.
- **The dashboard is as available as the outpost.** If authentik is down, the
dashboard is down. The tracker fails silently at the same time, which is
survivable: pages still load, the beacon just does not land.
- **I kept one break-glass path.** The app still publishes a port on the LAN,
which bypasses SSO entirely. It is not reachable from the internet (verified
from an external host), it still requires the app's own credentials, and I
would rather have it than be locked out of my own analytics by an SSO
misconfiguration.
## The result
One hostname, one DNS record, one certificate. No second subdomain, no extra
container — the gate is a feature of the SSO instance I was already running. The
tracker is reachable by every visitor's browser, the dashboard is reachable by
me from anywhere, and an anonymous visitor gets a login page instead of a
dashboard.
The measurable version: three tracker paths and the MCP endpoint answer
correctly to anonymous requests, every other path on the hostname returns 302 to
SSO, and pageviews land with the visitor's real city and referrer — which is the
only reason the whole exercise was worth doing.
If you are self-hosting analytics (or anything with a public ingest half), try
one hostname with path-based skip rules before you add a second DNS record. The
two-hostname split is an architecture for companies whose ingest traffic pays for
a CDN. Yours probably is not.
## Want this for your business?
If you want to know what your website is actually doing without handing your
visitors' data to an ad network — or you have an internal tool that should never
be reachable from the internet — I set up self-hosted, cookie-free analytics and
SSO gates like the one in this post: one hostname, no consent banner, no
third-party script phoning home from every page.
**WhatsApp: [+60 12-797 2969](https://wa.me/60127972969)** · **Email: [[email protected]](mailto:[email protected]?subject=Self-hosted%20analytics%20and%20SSO)** · **[hoelee.com](https://hoelee.com)**
Website design and development is my main line of work; server hardening and
self-hosted infrastructure is the other half of it.
@@ -0,0 +1,170 @@
---
title: "公开的埋点与要 SSO 的仪表盘,怎么共用一个域名"
description: "埋点必须公开,仪表盘绝不能公开。我用 authentik 的 proxy provider 加路径放行规则,把两者放在同一个域名上——不用第二个子域,也不用第二个证书。"
pubDate: 2026-09-30
category: devops
tags: ["umami", "authentik", "sso", "nginx", "docker", "privacy"]
ogImage: "/og/one-hostname-public-tracker-sso-dashboard.png"
banner: "/banners/one-hostname-public-tracker-sso-dashboard.png"
draft: false
---
几乎每个统计工具都长成一个尴尬的形状:负责**收集**数据的那一半,必须让陌生人的浏览器在每次打开页面时都能访问;负责**展示**数据的那一半,必须只有我能访问。
通常这意味着两个域名:一个公开的埋点入口,一个私有的仪表盘。我不想要两个域名。我只想要一条 DNS 记录、一张证书、一件要记住的事——同时能在吉隆坡某家酒店的 wifi 上打开仪表盘,而不用把一个登录页发布到公网。
下面就是最后跑通的形状,以及两个让「看起来完全正常」的配置连续半小时返回 404 的坑。
## 约束清单
- **埋点必须公开。** `script.js` 和两个收集端点由每个访客的浏览器抓取。如果它们被鉴权挡住,你就什么都收不到,而且好几天都不会发现。
- **仪表盘绝不能公开。** 它显示每个访客的国家、城市、来源和页面路径。一个默认管理员登录挂在公网 URL 上,就是送给任何在证书透明度日志里翻到你子域的人。
- **没有泛解析 DNS。** `*.hoelee.com` 不解析,所以每个域名都是一条显式 DNS 记录加一张证书。第二个域名是实打实的工作量,还让我要维护的攻击面翻倍。
- **这个域名不走 Cloudflare。** 它是一条直指我家路由器的 A 记录,所以没有 WAF、没有 bot 防护,也没有 `CF-Connecting-IP` 头可以依赖。下面所有东西都只能靠普通 nginx 头工作。
## 方案一:两个域名
业界常见的拆法是 PostHog 和 Sentry 的做法:`i.posthog.com` 收数据,`app.posthog.com` 看数据。当收集端由 CDN 承载、每秒几千请求、而应用端是个有状态数据库客户端时,这是真需求。我的博客一天只有个位数访问。我是在抄一个解决「我没有的问题」的架构,代价是第二条 DNS 记录、第二张证书、第二个会坏的地方。
两个域名是**规模**的答案,不是**需求**。一个域名就能同时承载两者,按路径分开就行。
## 方案二:nginx basic auth(这个方案行不通,原因值得记下来)
我第一反应是用最便宜的挡法:在统计应用前面放一个 nginx 容器,除了三个埋点路径以外全挂 `auth_basic`。四行配置,我以前用过。
它会把仪表盘彻底弄坏,而且坏的方式足够让人困惑,值得写下来。
应用自己的前端会用 bearer token 调自己的 API:
```
GET /api/websites HTTP/1.1
Authorization: Bearer <token>
```
浏览器每个请求只发**一个** `Authorization` 头。当前端加上它的 bearer token 时,这个头就**顶掉**了 basic auth 的凭据——于是网关读到的是一个 bearer token,却期望它是 base64 的 user:password,判定为未鉴权,返回 401。仪表盘的外壳能加载(那个请求带着 basic auth),然后每一个 API 调用都失败。你得到的是一个渲染出框架、里面什么都没有的界面,读起来像「统计工具坏了」,而不是「我的网关在跟我的应用打架」。
基于 cookie 的鉴权没有这个问题,因为凭据放在 `Cookie` 头里,应用自己的 token 永远不会碰它。所以:要么 cookie 鉴权,要么不要网关。
## 方案三:nginx 白名单网关(能用,但仪表盘变成只能内网访问)
下一版彻底去掉 basic auth,把网关做成纯白名单:`/script.js`、`/api/send`、`/api/heartbeat` 放行,其余一律 403。这确实安全——埋点公开,仪表盘根本不可达——如果你只在自己网络里看数据,这完全可以作为最终方案。
但我想在任何地方都能看仪表盘,所以它变成了退路而不是终点。我把容器停掉但保留着,后来证明这个决定是对的:回滚只需要一次 `docker start` 加一行配置。
## 方案四:authentik proxy provider + 按路径放行
我本来就在用 authentik 给二十来个自托管应用做单点登录。它们大多用同一种方式挡:反向代理把请求转给 authentik 的 embedded outpost,outpost 检查会话 cookie,未登录的访客被 302 送到登录流程,登录后再送回来。
proxy provider 上有一个正好为这个问题准备的字段:`skip_path_regex`。一行一条正则。**任何匹配的路径都会完全不鉴权**直接转发给应用;不匹配的一律弹到 SSO 登录。于是「哪部分公开」不再是 nginx 的事,而变成了应用层的策略。
```
mode: proxy
external_host: https://stats.hoelee.com
internal_host: http://umami:3000
skip_path_regex: ^/script\.js$
^/api/send
^/api/heartbeat$
^/mcp(/|$)
```
这就是全部策略:埋点脚本、收集端点、健康检查端点和 MCP 端点公开;这个域名上的其他所有路径都要过 SSO。
nginx 的 vhost 则从指向应用改成指向 outpost:
```nginx
location / {
proxy_set_header Host $http_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;
proxy_pass http://localhost:10000; # the authentik outpost
}
```
## 坑一:一个 provider 模式,让「正常的 SSO」把整个应用变成 404
我建新 provider 的方式是克隆一个已经能用的。通常这是加应用最快也最安全的做法,而这正是我踩坑的原因。
我抄的那个 provider 用的是 `mode: forward_single`,`internal_host` 是空的。对于自带登录的应用(比如一个浏览器应用、一个自己有会话的监控界面),这是对的——outpost 只需要回答「这个访客允许吗」,应用由别的路径抵达。但它意味着 outpost **没有可代理的上游**。当你挡的应用自己没有登录(比如自托管的统计仪表盘),outpost **就是**那个反向代理,而 `internal_host` 为空时它无处可送。
症状之所以难缠,是因为鉴权那一半看起来完美:
```
$ curl -sI https://stats.hoelee.com/ | head -1
HTTP/2 302 # → auth.hoelee.com/if/flow/auth-stats/
$ curl -s -o /dev/null -w '%{http_code}' https://stats.hoelee.com/script.js
404 # ← 但应用不在这里
```
302 到登录流程、品牌登录页正常渲染、流程走完——而每一个应用路径都返回 404。一旦开始找,有两样东西让定位变得很快:
1. **`x-powered-by` 头会告诉你哪一跳应答的。** 那个 404 带着 `x-powered-by: authentik`,证明它是 outpost 产生的,应用根本没收到请求。没有这个头,我就会去查应用的路由。
2. **拿一个能用的 provider 对照。** 同样的匿名请求打到已知正常的应用,会停在登录流程并返回 200。我的是 404——形状一样,最后一跳不同。
修复只是一对字段:`mode: proxy` 加一个真实的 `internal_host`。
```
mode: proxy # 不是 forward_single
internal_host: http://umami:3000 # outpost 把请求代理到这个容器
```
`forward_single` = 「应用在别处,你只负责检查访客」。
`proxy` = 「你就是反向代理,把请求送到 `internal_host`」。
怎么选:问一句**谁来产生响应**。
## 坑二:outpost 需要一分钟,而它会很有说服力地骗你
改完 provider 之后,embedded outpost 需要一到两分钟才会拉到新配置。在这个窗口里,域名会这样应答:
```
302 → /flows/-/default/authentication/ # 一个默认流程,slug 是 "-"
404 # 所有应用路径
```
这不是新配置失败,而是旧配置还没被替换。我在这上面浪费了两次时间:一次是判断接线坏了,一次是判断模式修改没生效。现在的做法是改一处、等九十秒、然后才判断结果。把新 provider 挂到 outpost 上也一样:provider 在 API 里立刻存在,但要一分钟后才开始应答请求。
## 我实际验证了什么,以及从哪里验证
这类改动用内网自测毫无意义。我的环境里有些域名有两层入口(一个 nginx vhost,加一条直连容器的隧道规则),所以从内网发的请求可以通过,而公网路径完全绕开网关。下面全部是从公网、用真实域名测的。
| 检查 | 结果 |
|---|---|
| 匿名 `GET /` | 302 → outpost → 该应用自己的登录流程 → **200** |
| `GET /login`、`/api/websites` | 302,被挡 |
| `GET /script.js` | **200**——埋点脚本对访客仍然加载 |
| `GET /api/heartbeat` | **200** |
| 用假 site id `POST /api/send` | **应用返回 400**,不是网关返回 403——证明请求真的到了应用 |
| 带 API key `POST /mcp` | **200**,`text/event-stream` |
| 不带 key `POST /mcp` | **401** `Missing bearer API key`——应用自己的鉴权,不是 SSO 网关 |
| 一次真实 pageview | 记录到 `country=MY region=MY-07 city=George Town`,浏览器、系统和来源都完整 |
最后一行才是关键。因为这个域名不走 Cloudflare,访客 IP 必须靠 `X-Forwarded-For` 穿过两跳代理,并且要告诉应用去读这个头(`CLIENT_IP_HEADER=x-forwarded-for`)而不是读 Cloudflare 的那个。一个数字在涨、但所有访客都被归到容器 IP 上的统计,看起来像成功,其实毫无用处。**能记录到正确的城市才是证据。**
## 我下次会怎么做
- **把上一个网关停掉,而不是删掉。** 方案三那个 nginx 白名单容器还在磁盘上,处于停止状态。回滚是 `docker start` 加 vhost 里改一行——三十秒,而不是在压力下凭记忆重建它。
- **在域名可达之前轮换应用的默认凭据,而不是之后。** 我有一个下午让公网 URL 和默认的管理员登录同时存在;而新子域几个小时内就会出现在证书透明度日志里。
- **一次只改一个字段,然后等。** 这次会话里三个错误结论,有两个来自在系统还没应用完变更时就去读结果。
- **也要测应用自己的登录流程。** SSO 通过不代表应用能用:网关可能完全正确,而它设下的会话 cookie 在下游被拒。我用 curl 验证埋点路径、用真实浏览器验证仪表盘,然后才敢说做完了。
## 说清楚取舍
- **双重登录。** 统计应用不支持 OIDC,所以 SSO 只解决了「能不能到这个路由」,应用仍会要它自己的用户名密码。每个浏览器多一次点击。真正保护数据的是应用内可以按账号开启的两步验证。
- **仪表盘的可用性等于 outpost 的可用性。** authentik 挂了,仪表盘就挂了;同时埋点会静默失败,这还能接受:页面照常打开,只是数据没落库。
- **我保留了一条后门。** 应用仍在局域网里发布一个端口,完全绕开 SSO。它从公网不可达(用外部主机验证过),仍然需要应用自己的凭据,但我宁可留着它,也不想因为一次 SSO 配置失误把自己锁在自己的统计之外。
## 结果
一个域名、一条 DNS 记录、一张证书。没有第二个子域,没有额外容器——网关就是我本来就在跑的 SSO 实例的一个功能。埋点对每个访客的浏览器可达,仪表盘对我随处可达,匿名访客看到的是登录页而不是仪表盘。
可以量化的版本:三个埋点路径加 MCP 端点对匿名请求应答正确,域名上其余所有路径 302 到 SSO,pageview 带着访客真实城市和来源落库——这也是整件事值得做的唯一理由。
如果你也在自托管统计(或任何有「公开收集端」的东西),先试试用一个域名加路径放行规则,再考虑加第二条 DNS 记录。两域名的拆法是给那些收集流量足以养一个 CDN 的公司用的架构。你的多半不是。
## 需要为你的业务做这个吗?
如果你想真正知道网站在发生什么,又不想把访客数据交给广告网络——或者你有一个绝不该从公网访问的内部工具——我可以帮你搭自托管、无 cookie 的统计,以及像这篇里那样的 SSO 网关:一个域名、不需要同意横幅、没有第三方脚本在每一页偷偷回连。
**WhatsApp:[+60 12-797 2969](https://wa.me/60127972969)** · **邮箱:[[email protected]](mailto:[email protected]?subject=Self-hosted%20analytics%20and%20SSO)** · **[hoelee.com](https://hoelee.com)**
网站设计与开发是我的主业;服务器加固与自托管基础设施是它的另一半。