为了看一眼拓扑,我的面板揣着一把全网通行证


我有一张自建的组网:几台机器跨着国内外,中间一个控制面(registry)记着谁是谁、谁能当出口、谁被隔离。节点每 20 秒去注册一次,顺带把最新的 peer 列表读回去。

我还有一个管理面板,上面有一页「组网监控」——就是把控制面的成员表拉过来,画成拓扑图。只读,看看谁在线、链路时延多少。

问题是:为了这一页只读的图,面板必须在配置文件里存一枚 UNET_TOKEN。而那枚 token,同时能签发入网密钥、能改全网设置。

一枚 token,两种权力

先说清楚为什么这事值得改。控制面上,鉴权中间件长这样:

auth := func(h http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if r.Header.Get("X-Unet-Token") != token {
            http.Error(w, "unauthorized", 401)
            return
        }
        h(w, r)
    }
}

然后所有接口都套上它:读成员表的 /api/nodes 套它,签发一次性入网密钥的 /api/enroll-keys 也套它,改节点设置(出口开关、隔离)的 /api/settings 还是它。

于是这枚 token 的真实含义是“全网通行证”:拿到它的人可以给自己签一把密钥、把一台机器塞进这张网,或者把某个节点设成出口。而我的监控页想要的,只是那张表里的名字和时延。

这不是洁癖问题。想读这张表的东西不止一个——控制面自带的面板要读,我这个外部面板也要读,将来可能还有别的。每多一个读者,就多一份全网通行证躺在某台机器的配置文件里。 整张网的安全性,被押在了每一个读者的运维水平上。

为什么不是“前端直连”

顺带说一个更早就被否掉的方案:让浏览器直接去请求控制面。

不行。浏览器要请求,token 就得下发到浏览器;任何一个能打开面板的人按下 F12 就拿到了。所以这一页从一开始就是后端代理:token 只待在服务端,浏览器用的是面板自己的登录态。代价是多一跳,换来的是秘密不出服务器。

但代理只解决了“谁能看到 token”,没解决“这枚 token 权力太大”。

两条路,我选了贵的那条

便宜的那条:给控制面加一枚只读 token,/api/nodes 认它,写接口仍要主 token。十几行就能改完,泄露面从“能改全网”降到“能看拓扑”。

干净的那条:让读接口认 OIDC——面板带一枚身份令牌来,控制面按公开的 JWKS 验签放行。

我选了后者,理由很具体:控制面本来就会验 OIDC。它有一条叫“登录即入网”的路径——新机器带一枚 id_token 来注册,验过就换发长期凭据,省掉手工发密钥。验证器、允许名单、惰性拉 discovery,全都写好了,只是只挂在注册那一个接口上,读接口完全没用上。

同一套机制,已经在那儿了,却只服务了一半的场景。这种时候第二条路的边际成本比看上去低得多。

而且它比只读 token 多解决了一件事:两边不再共享任何秘密。只读 token 仍然是一个“两边都得存一份、改一次要同时改两处、泄露了不知道是谁泄的”的字符串。而签名方案里,私钥只在面板这一边,控制面只需要知道 issuer 的地址——那是公开信息。

关键的一步:面板本来就是签发方

改造能成立,靠一个巧合般的事实:我的面板自己就是那个 OIDC issuer。控制面配置里那个 -oidc-issuer,指的就是这个面板。

也就是说,面板不需要去跟谁申请凭据——它自己就是发凭据的那个。给自己签一枚就行:

tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
    "iss": t.Issuer,     // 就是本面板
    "sub": t.Subject,    // 一个明确的服务主体,比如 sso-panel
    "aud": t.Audience,   // 下游期望的 client_id
    "iat": now.Unix(),
    "nbf": now.Add(-30 * time.Second).Unix(),
    "exp": now.Add(5 * time.Minute).Unix(),
})
tok.Header["kid"] = kid

几个细节都是有代价才记住的:

kid 必须带。 下游按 kid 从 JWKS 里挑公钥。少了它,密钥轮换之后对端会撞上“挨个试”的模糊逻辑,而不同库在这件事上的行为并不一致。

nbf 往前挪半分钟。 两台机器时钟差几秒是常态,而 nbf 落在未来会被对端直接判为无效。这类失败在日志里长得像“凭据不对”,能查一下午。

签 5 分钟就够。 这枚 token 的全部用途是发一次 HTTP 请求。它一旦泄露就是一张下游通行证,没有任何理由让它活得久。

不要用登录用户的身份

一个岔路口:既然用户登录面板时本来就有 id_token,为什么不拿用户那枚去请求下游?

两个理由。一,用户的 token 几分钟就过期,而这一页是轮询的,后台刷新时根本没有用户在场。二,那会把“谁能读拓扑”绑定到“谁恰好登录着”。下游的允许名单里放一个明确的服务主体,比放一群会变动的人清楚得多——出了事,日志里是一行 经 sub:sso-panel 读取,而不是猜是谁点开了页面。

只放开读,不放开写

控制面这边,我加的是一个并列的中间件,而不是把原来的改宽:

authRead := func(h http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if r.Header.Get("X-Unet-Token") == token {
            h(w, r); return          // 老路径原样保留,是回滚保障
        }
        if raw := bearer(r.Header.Get("Authorization")); raw != "" && oa.enabled() {
            if who, err := oa.check(r.Context(), raw); err == nil {
                h(w, r); return
            } else {
                log.Printf("read %s:id_token 未通过(%v)", r.URL.Path, err)
            }
        }
        http.Error(w, "unauthorized", 401)
    }
}

然后只挂到 /api/nodes。签密钥、改设置、改分流规则,仍然只认共享 token。

这条界线是我想得最久的地方。允许名单原本的含义是“谁有资格把机器接进这张网”,如果我顺手让它也能改全网设置,就等于把两种完全不同的授权混成一个名单。名单管的是入网资格,不是运维资格。

那行 log.Printf 也是刻意的:读不到的时候,“名单里没有你”和“token 过期了”在 HTTP 层长得一模一样——都是 401——但排查方向完全相反。

还有一处克制:面板侧签不出 token 时,我没有让它悄悄回退到共享 token。回退看着很贴心,实际上会把“新机制坏了”藏成“一切正常”,直到某天两条路一起失效才暴露。

上线:顺序不能反

改完两边,推的时候有个硬顺序:先推控制面,再推面板。

反过来的话,面板已经改用新身份、而控制面还不认,那一页就会空窗。而按正确顺序推,中间态是“控制面既认新的也认老的”——完全安全。老路径我原样留着,这也是回滚保障:新的哪里不对,把面板的配置去掉就退回去了。

推之前还有一个必须先验的前提:控制面要能主动访问到 issuer 去拉 JWKS。我的控制面在境外,面板在国内还带着个非标端口,这不是理所当然的。先 curl 一次 discovery,拉不到就整条路走不通,得退回只读 token 那个方案。

上线后的验证,我看的是这三行:

shared-token:200      # 老路径没断
no-cred:401           # 没凭据进不来
bad-bearer:401        # 乱写的令牌进不来

以及最后那行真正的证据——面板拉一次拓扑,控制面日志里出现:

read /api/nodes:经 sub:sso-panel 读取

走的是新通道。而面板那台机器的配置文件里,从头到尾没有出现过任何一枚共享密钥。

小结

一枚 token 同时管着“看”和“改”,就注定要么读者拿得太多,要么写者放得太宽。拆开它的成本,往往比想象中低——尤其当你发现所需的机制其实早就在系统里,只是被挂在了一个接口上。

(本文中的主机名、域名、密钥与具体标识均已脱敏。)