Celium 账号与注册

Celium 不把身份外包给任何人:没有 OAuth、没有第三方目录服务,登录不需要任何外部 服务可达。一个账号就是本服务器状态文件里的一行:用户名、密码哈希,以及它拥有的 机器。这是产品决定,不是实现细节——一个"自托管"的网络,如果登录依赖别人的服务, 那它就不是自托管的。

数据模型

Account --拥有--> User --拥有--> Node
   |                |
   |                +-- ACL 规则写的就是它(group: / login name)
   +-- 登录、签发节点密钥、邀请他人

User 这层间接是有意的:节点归属、节点记录、ACL 组都是围绕"tailnet 用户"设计的, 而账号是"登录方式"。把两者分开,意味着改密码或停用登录不会让已经注册的机器失去 归属——那台机器仍然是同一个用户的资产。

账号还可以邀请其他人:第一个账号通过引导(bootstrap)产生,此后的每个账号都 是因为已有用户为它背书才出现的。邀请码默认一次性,可设置次数与有效期。

HTTP API

所有接口都是普通 JSON over HTTP,浏览器、CLI、手机客户端用同一套。认证是 Authorization: Bearer <session token>——不用 cookie,因此没有 CSRF 面。

公开(无需凭据)

方法路径说明
GET/api/server-info域名、需要 pin 的控制公钥、是否开放注册、首个账号是否成为管理员
POST/api/register{username, password, displayName?, email?, invite?} → 账号 + 会话令牌
POST/api/login{username, password, label?} → 会话令牌
POST/api/device/start{machineKey, hostname, os?, arch?, version?, description?} → 设备码、轮询密钥、审批 URL
POST/api/device/poll{deviceSecret}waiting / approved(携带节点密钥)/ denied / expired

/api/server-info 是客户端第一次接触服务器时唯一需要的东西:它让"首次连接"不必 手工拷贝控制公钥,同时不泄露任何非公开信息(这些值本来就会打印在服务器启动日志 和首页上)。

需要登录

方法路径说明
GET/api/me当前账号 + 它拥有的机器
POST/api/logout结束当前会话
POST/api/password{currentPassword, newPassword},改密码会注销其它会话
GET/POST/api/invites列出 / 创建邀请({note?, maxUses?, expiresInHours?}
DELETE/api/invites/{code}撤销邀请
GET/POST/api/node-keys列出 / 签发节点密钥(机器入网凭据)
DELETE/api/node-keys/{value}撤销节点密钥
GET/api/nodes本账号拥有的机器
POST/api/device/approve{code},批准一台正在等待的机器(把它绑定到本账号)
POST/api/device/deny{code},拒绝它

需要管理员

方法路径说明
GET/api/accounts所有账号及其机器数
POST/api/accounts/{id}/disable停用 / 启用账号(会立刻注销其会话)
POST/api/accounts/{id}/admin授予 / 撤销管理员(不允许撤销最后一个)
POST/api/accounts/{id}/password管理员重置密码(用于用户被锁在外面的情况)

管理员还可以继续使用既有的 /admin/api/(bearer admin token),以及 /admin 控制台:

方法路径说明
GET/admin/api/accounts所有账号及其机器数
GET/admin/api/invites全部邀请码(不只是自己创建的)

后一条是刻意分开的:邀请码是一扇门,而一位已经离职的同事留下的未使用邀请码,正是 需要被找出来并撤销的东西——只让创建者看见,就等于看不见。

控制台(/admin)已经把账号与邀请纳入管理:停用/启用、授予/撤销管理员、重置密码、 查看全部邀请码。这三类动作都不需要管理员本人拥有账号,只需要部署自己的 admin token——这正是"唯一账号的密码丢了"那天需要的路径。

用浏览器把机器接入

上面那条路径要求你在那台机器上拥有账号:要么先登录,要么让管理员签发节点密钥。 一台没有人坐在面前的服务器(机架里的机器、CI 里的容器、刚开的 VPS)两样都没有,而账号 的主人在别处、面前正好有一个浏览器。设备授权流程(RFC 8628 的形状)就是为这种情况存在 的,celium up 在发现本机没有会话时自动走这条路:

$ celium up --control-url=https://control.example.com --trust-server-info
celium: this machine is not logged in. Approve it in a browser:

  https://login.celium.cn/a/7k4m-p2qr

  Open that URL and sign in to your Celium account; the page shows which
  machine it is about and asks you to approve or deny it.

  Check that the page describes *this* machine:
    hostname     web-01
    fingerprint  3f9a2c4d1b70
    machine key  mkey:...

命令行在这里只做两件事:POST /api/device/start 拿到一个码,然后轮询 POST /api/device/poll。两次调用都不带任何凭据——这正是要点:这台机器上不需要、 也不会留下任何账号凭据。人在浏览器里打开 /a/<code>(由本服务器渲染,因为账号就在 这里),页面显示机器的主机名、平台、机器密钥与它自己报的来源地址,要求输入账号密码, 然后给出 Approve / Deny 两个按钮。批准时服务器签发一把一次性、预授权的节点密钥, 归批准者所有;客户端在下一次轮询里拿到它,交给守护进程注册——与 --auth-key 走的是 同一条路,注册流程一行没改。

决定原因
码是 8 位、32 字符表(去掉 0/O/1/l/I),共 40 bit人要在另一块屏幕上把它念出来或抄下来,所以必须短;而它不是凭据——批准要账号密码,领取要轮询密钥——所以它只需要"不好猜中某个待批请求",配合按来源地址的限速(8 次失败后停 5 分钟)就足够了。32 个符号意味着按字节取模是均匀的
轮询用另一把 256 bit 的随机密钥,只回给发起者、只存哈希码会出现在屏幕、URL、聊天记录里;如果它同时是领取凭据,任何看到 URL 的人都能在批准后把节点密钥截走。两把值分开,看得到码的人拿不到凭据
审批页显示主机名、平台、机器密钥及其指纹、来源地址、请求时间、客户端自述盲批准的页面就是漏洞本身。人必须能判断"这是不是我刚才在那台机器上跑的 celium up",所以命令行也打印同一把指纹供比对
页面没有任何 JavaScript,且带 default-src 'none' 的 CSP页面上的每个值都来自未认证的客户端;html/template 做转义,CSP 让"即使注入了也没有脚本可跑"成为浏览器强制的事实
批准页要输密码,而不是用浏览器会话账号 API 刻意不用 cookie(见上),浏览器里没有会话可携带;为此发明一个 cookie 或 local storage 令牌,等于为两个按钮新增一类凭据。附带好处是:批准/拒绝都需要密码,所以一台没人看管的已登录浏览器无法被用来放行一台陌生机器
码 10 分钟过期(Config.DeviceCodeTTL),轮询间隔 5 秒(Config.DevicePollInterval10 分钟够人走到另一台设备上登录并读完页面,又短到让留在终端回滚或聊天记录里的码很快失效;5 秒是"点完批准最多等这么久"的延迟上限。服务端不强制间隔——一次轮询只读内存、不改状态
待批请求只存在内存里,重启即失效它是一次几分钟的交互窗口,重跑一条 celium up 就能重来;而写进状态文件意味着把一份未被批准的记录(以及为它签发的凭据)持久化,并且存储层每次变更都重写整个文件——一台每几秒轮询一次的机器会变成磁盘写循环。代价只有一处:已批准但还没被领走的请求需要重新走一遍,而那次批准签发的节点密钥是普通 auth key,已经落盘,账号里查得到
/api/device/start 限流:每个来源最多 8 个待批、全服最多 1024 个它无法要求凭据(这是整件事的前提),因此必须限制它能占用多少内存

审批 URL 的来源由 Config.LoginURL 决定(celium-control --login-url=https://login.celium.cn)。 不配置时取发起请求的 Host 与协议,这是服务器唯一能确定"自己确实在这个地址上提供服务" 的取值;放在反向代理后面、或对外名字与节点拨号名字不一致时,才需要显式配置。客户端不会 硬编码任何主机名,只打印服务器返回的那个 URL。

一条命令把机器接入

# 1. 注册(第一个账号自动成为管理员)
curl -sX POST https://control.example.com/api/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"correct-horse-1"}'
# → {"token":"...","account":{...},"isFirstUser":true}

# 2. 签发这台机器的入网密钥
curl -sX POST https://control.example.com/api/node-keys \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"description":"alice laptop","preauthorized":true}'
# → {"Value":"celiumkey-...","...":...}

# 3. 用这个密钥把节点拉起来
celium up --control-url=https://control.example.com \
          --control-key=controlkey:... \
          --auth-key=celiumkey-...

邀请别人:

curl -sX POST https://control.example.com/api/invites \
  -H "Authorization: Bearer $TOKEN" -d '{"note":"for bob"}'
# → {"code":"abcd-efgh-..."}  交给对方,让他注册时带上 invite 字段

安全上的具体选择

选择原因
密码用 bcrypt(cost 12)每个哈希自带盐与参数,离线爆破代价高;参数随哈希存储,将来可以平滑升级
密码长度 8–72 字节72 是 bcrypt 的上限;更长的密码会被静默截断,等于两个不同密码对应同一个账号
会话令牌只存 SHA-256令牌是 bearer 凭据。状态文件泄露(备份、支持包、权限设错)不能等于泄露一个可用登录
令牌找账号要常数时间比较避免通过响应时间逐字节猜出令牌
登录失败按「用户名 + 来源地址」限速在线猜密码的收益被压到每天几百次;把来源地址计入 key,使攻击者无法通过猜别人的账号把真正的用户锁在门外
未知用户名也走一次 bcrypt 校验否则响应时间会告诉攻击者哪些用户名存在
改密码注销所有会话如果改密码的原因是"别人知道它",留着对方的会话就等于没改
停用账号立刻注销会话停用必须是边界,而不是一个显示状态
不能撤销最后一个管理员没有管理员的自托管部署只能靠手改状态文件救回来
邀请默认一次性误设为"限次"只是麻烦,误设为"无限"是一扇永远开着的门;无限次需要显式传 maxUses: -1
用户名限小写 ASCII用户名会出现在 URL、日志和控制台里,允许任意 Unicode 会带来同形异义冒充
设备码不是 API 凭据它只用来"找到"一个待批请求;批准要账号密码,领取要另一把 256 bit 的轮询密钥,所以猜中一个码得不到任何权限
设备码一次性领取时请求即被删除,第二次轮询只会被告知"已失效";同一份凭据也不会被签发两次
待批请求绑定机器密钥页面上显示的就是将被放行的设备身份,签发的一次性密钥也只对该机器有意义

与 ACL 的关系

账号不直接出现在 ACL 里,它对应的 User 才是:group:dev = ["alice@example.com"] 这样的规则在编译时展开为该用户拥有的所有节点的虚拟地址。因此"停用账号"不会影响 已经写好的策略,而"删除账号"会让策略里引用的那个身份不再匹配任何机器。