安装与部署
这一篇写给自托管团队里负责部署的那个人。照着做完,你会有:
- 一个自己的入口(一台 Linux 上的一个容器,或 Kubernetes 里的一组副本),网页控制台也在它上面;
- 接在入口上的机器——Mac、Linux 都行,会话在这些机器上跑;
- 每位成员电脑上的客户端;
- 一套升级和回滚的办法:所有部分都跟着同一个版本号
stable走。
只有一台电脑、不需要给同事开号的话,不必架入口,照 自托管 · 快速开始 装本机模式即可。
下文例子里的地址、名字都是占位:入口 https://hub.example.com、机器 mac-a linux-a、管理员 alice。换成你自己的。
0. 准备
| 要什么 | 说明 |
|---|---|
| 一个域名和 HTTPS 证书 | 入口的地址,例如 hub.example.com。机器和客户端都只认 HTTPS。 |
| 跑入口的地方 | 一台装了 Docker 的 Linux(单机容器,HTTPS 由反向代理接,见 §1.5),或一个带 ingress-nginx 的 Kubernetes 集群。入口很小:1 核、1 GB 内存够一个小团队。 |
| 构建镜像的地方 | 一台有 Docker(带 BuildKit / buildx)、git 的电脑,能拉基础镜像和 Go 依赖。Ubuntu 上:sudo apt install docker.io docker-buildx。 |
| 至少一种登录方式 | 开工前就备好:一样都没有,入口起不来,或者起来了管理员也登不进。三选一(可多选):GitHub OAuth 应用(要 Client ID / secret,回调地址填入口地址);能发信的 SMTP(要服务器、端口、账号密码,和一个已在云上开通好的发信地址——云厂商的邮件服务开通发信域名要先过验证);微信开放平台的网站应用(要 AppID / AppSecret,审核要几天)。见 §1.4。 |
| 跑会话的机器 | macOS 或 Linux(Debian 12 / Ubuntu,带 systemd),每台有一个能 sudo 的管理员账号。 |
| 订阅 | Claude 订阅(Pro / Max / Team)或 ChatGPT 订阅。可以每人自带,也可以由管理员接进入口给大家共用。 |
1. 架入口
入口是一个程序:ccquota hub,监听一个端口(8787),数据放在一个 SQLite 文件里(也可以换成 Postgres)。
1.1 构建镜像
入口的镜像从源码构建,**用 stable 那一个提交**——机器的升级包由入口签名,入口只给和它自己同一份源码的版本签名(见 §4)。
git clone https://github.com/verkyyi/claude-fleet.git
cd claude-fleet
git checkout stable
TAG=$(git rev-parse --short HEAD)
bin/fleet-client-pack.sh
docker build --build-arg VERSION=$TAG -t registry.example.com/fleet/ccquota:$TAG tokenledger/
docker push registry.example.com/fleet/ccquota:$TAG
bin/fleet-client-pack.sh把客户端和机器要下载的文件装进镜像;少了这一步docker build会拒绝。VERSION是入口在/version上报的版本名,用提交号最方便对照。- 镜像里带着 macOS 与 Linux(amd64 / arm64)机器要下载的程序,构建要几分钟。只在一台机器上跑入口时,镜像推不推仓库都行。
- 要 BuildKit:Dockerfile 用了
--platform=$BUILDPLATFORM,没有 buildx 的docker build会报"" is an invalid OS component。Ubuntu 的docker.io不带它,另装docker-buildx。 - 构建默认经
goproxy.cn拉 Go 依赖(境内直连官方代理常会卡住)。在境外构建、或不想经过它,加--build-arg GOPROXY=https://proxy.golang.org,direct。
1.2 生成钥匙
在一个只有你能读的目录里生成,这些文件不进任何仓库:
umask 077 && mkdir -p ~/fleet-secrets && cd ~/fleet-secrets # 先 umask,目录本身才是 0700
ssh-keygen -t ed25519 -N '' -C fleet-user-ca -f ca # 连接证书的签发钥匙:ca + ca.pub
openssl rand -base64 32 > cred-key # 订阅凭据保险箱的钥匙
openssl rand -hex 24 > viewer-token # 用量页的只读口令
docker run --rm --user "$(id -u)" -v "$PWD:/out" --entrypoint ccquota \
registry.example.com/fleet/ccquota:$TAG release keygen --out /out/release-key # 升级包的签名钥匙
| 文件 | 用途 | 丢了 / 泄露了 |
|---|---|---|
ca | 给成员和机器签短期连接证书 | 泄露:重新生成,所有人重新登录一次 |
cred-key | 加密入口里存的订阅凭据 | 不要换:换了以后已存的凭据全部读不出来,要重新接一遍订阅 |
viewer-token | 用量页的只读口令 | 重新生成即可 |
release-key | 给机器的升级包签名;机器第一次连上时记住它的公钥 | 泄露:重新生成,每台机器重新信任一次 |
release keygen 会把公钥打印出来,记下它,机器上看到的就是这一个。
设了 release-key,入口每打一个发布包,还要有 release.json 钉住版本的几个程序(Codex、tmux 等);没有就拒绝打包(422)。先照 §1.3 末尾「发布包要的程序」备好。
今天签名钥匙放在入口里。以后会改成「签名钥匙只在发版流程里,另有一把离线保存的根钥匙能作废它」,到时这一节会更新。
1.3 必填配置
入口只读环境变量。下面是一套最小可用的配置:
| 键 | 例子 | 说明 |
|---|---|---|
CCQUOTA_FLEET | 1 | 打开 Tokenman 功能 |
CCQUOTA_FLEET_PUBLIC_URL | https://hub.example.com | 入口的公开地址,机器和客户端都用它 |
CCQUOTA_PUBLIC_URL | https://hub.example.com | 同上,登录回跳用 |
CCQUOTA_GITHUB_ADMINS | alice | 管理员的 GitHub 用户名,逗号分隔 |
CCQUOTA_ADMINS | alice@example.com | 不用 GitHub 的管理员:邮箱地址,逗号分隔。填你真能收信的地址——第一个管理员就靠发到这里的验证码进门;照抄 example.com 的占位,入口会说出来并拒绝启动 |
CCQUOTA_FLEET_ADMIN_USERS | alice | 机器上那个管理员账号的系统登录名:入口只把「开号」「删号」发给以它运行的机器程序 |
CCQUOTA_VIEWER_TOKEN | viewer-token 的内容 | 用量页的只读口令 |
CCQUOTA_FLEET_SSH_CA_KEY | /secrets/ca | 连接证书钥匙的路径 |
CCQUOTA_FLEET_SSH_HOST_CA_KEY | /secrets/host-ca | 主机证书钥匙的路径(可选;独立生成的一把,不是上面那把):给家机器的入口签主机证书,客户端换家、换钥匙都不再问 |
CCQUOTA_FLEET_CRED_KEY_FILE | /secrets/cred-key | 凭据保险箱钥匙的路径 |
CCQUOTA_FLEET_RELEASE_KEY | /secrets/release-key | 升级包签名钥匙的路径;不设则机器不会自动升级 |
CCQUOTA_FLEET_RELEASE_DIR | /data/releases | 升级包放哪;设了签名钥匙就必须设 |
CCQUOTA_FLEET_RELEASE_ARTIFACTS | /data/release-artifacts | 发布包要的程序放在哪个目录;设了签名钥匙就要有,见下面「发布包要的程序」 |
CCQUOTA_FLEET_RELEASE_PLATFORMS | darwin-arm64 linux-amd64 | 你的机器是哪几种系统 / 架构,空格分隔;不设只备 darwin-arm64 |
CCQUOTA_FLEET_OAUTH_REFRESH_VIA | node | 订阅的登录状态由机器去续,入口自己不连订阅商 |
可选:
| 键 | 说明 |
|---|---|
CCQUOTA_DB_URL | 用 Postgres 而不是 SQLite:postgres://用户:密码@主机:5432/库?sslmode=require |
CCQUOTA_FLEET_UPSTREAM_HUB | 跟另一个入口(例如官方入口)发的版本走,不问 GitHub;要和 CCQUOTA_FLEET_RELEASE_DIR、CCQUOTA_FLEET_RELEASE_ROOT 一起设。见 §4.3 |
CCQUOTA_FLEET_STABLE_REPO | 去 GitHub 跟哪个仓库的 stable;只有设了才问 GitHub,不设就不问。见 §4.3 |
CCQUOTA_FLEET_CRED_RELAY_URL | 订阅流量经过的中转地址(订阅商不服务你所在地区时才需要,例如在国内);不设就直连。中转怎么架见 §1.8 |
CCQUOTA_FLEET_PUBLISH_TOKEN | 允许把 stable 推给入口的口令(密钥)。推过一次,入口就不再去 GitHub 问 stable;入口在国内时用得上,见 §4.4 |
CCQUOTA_METER_ORIGINS | 允许读用量的网页来源;不设就没有 |
入口启动时,每个没配的地址类键会打一行「未配置 <键>」,照着补即可。
发布包要的程序
机器升级时装的每一样都来自入口的发布包:fleet 本身、ccquota,以及仓库根目录 release.json 钉住版本的 Claude Code、Codex、tmux。ccquota 就在你的镜像里;其余几样要放进 CCQUOTA_FLEET_RELEASE_ARTIFACTS 指的目录,文件名是 <名字>-<版本>-<系统>-<架构>,例如 codex-0.162.1-darwin-arm64、tmux-3.7c-linux-amd64(Codex 还要一个同版本的 codex-code-mode-host-…)。Claude Code 缺了入口会自己从 npm 拉(源见 CCQUOTA_FLEET_RELEASE_NPM)。
缺一个,入口拒绝打这一版的包,报错写着缺哪几个名字和下面这条命令。来源有两条,挑一条:
- 从别的入口取:你知道一个已经有这一版发布包的入口(同一个团队的另一个入口,或你跟随的
stable的发布方给过你的地址)时用。官方不对外提供公共入口,没有这样一个地址就直接走下面「自己放」。 - 自己放:从 Codex 和 tmux 各自的官方发布页取,见本节最后一段。
从别的入口取
那个入口在 /v1/fleet/release/<提交号>/artifacts/<名字> 公开提供每个文件,并在签了名的清单里写着每个文件的大小和 sha256。下面这条命令先用那个入口的公钥核清单的签名,再逐个核 sha256,对得上才放进目录:
UP=https://upstream.example.com # 换成你知道的那个入口;没有就跳到「自己放」
SHA=$(git rev-parse stable) # §1.1 构建镜像的那一个提交
curl -fsS $UP/v1/fleet/release/key > upstream-release.pub # 它的发布公钥,和发布方核对一次
python3 bin/fleet-release-fetch.py --hub $UP --pubkey upstream-release.pub \
--artifacts --platforms "darwin-arm64 linux-amd64" $SHA /srv/fleet/data/release-artifacts
- 每个文件打一行:
got <名字>刚取到,kept <名字>已经在、核过没变。不对的文件会被换掉;签名或 sha256 对不上就整条拒绝,什么都不写。 - 目录是入口容器里的
/data/release-artifacts(§1.5 的数据盘下面),Kubernetes 上同样放在数据盘上。§1.5 之后数据盘归 uid 10001,再跑这条要sudo,跑完sudo chown -R 10001 /srv/fleet/data/release-artifacts。 --platforms和CCQUOTA_FLEET_RELEASE_PLATFORMS写同一组。- 每次升级前(
stable换了提交)再跑一次,只会取新版本的文件。 - 放好后
curl -fsS https://hub.example.com/v1/fleet/release/artifacts列出入口现在有哪些。
自己放
没有上游入口时用。在 §1.1 构建镜像的那份仓库里(git checkout stable,并且已经跑过 bin/fleet-client-pack.sh)执行,版本号都从 release.json 读:
| 制品 | 从哪来 | 怎么变成目录里的文件 |
|---|---|---|
codex-<版本>-<系统>-<架构> | Codex 官方发布页 github.com/openai/codex/releases/tag/rust-v<版本> 的 codex-<triple>.tar.gz | 解压,里面只有一个文件 codex-<triple>,改名 |
codex-code-mode-host-<版本>-<系统>-<架构> | 同一页的 codex-code-mode-host-<triple>.tar.gz | 同上:解出 codex-code-mode-host-<triple>,改名 |
tmux-<版本>-<系统>-<架构> | §1.1 的 fleet-client-pack.sh 已按 conf/vendor-tmux.lock 下载并核过 sha256,放在 tokenledger/hub/api/fleetclient/pack/vendor/tmux-<平台> | 直接复制改名,不用自己构建 |
claude-<版本>-<系统>-<架构> | 不用放:入口自己从 npm 拉 | — |
系统 / 架构和两边名字的对应(用 Codex 的 musl 版:静态链接,哪个 Linux 都能跑):
<系统>-<架构> | Codex 的 <triple> | tmux 的 <平台> |
|---|---|---|
darwin-arm64 | aarch64-apple-darwin | macos-arm64 |
darwin-amd64 | x86_64-apple-darwin | macos-x86_64 |
linux-amd64 | x86_64-unknown-linux-musl | linux-x86_64 |
linux-arm64 | aarch64-unknown-linux-musl | linux-arm64 |
一段做完(只留你 CCQUOTA_FLEET_RELEASE_PLATFORMS 里有的那几行;国内下 GitHub 常断,--retry 会自己重试):
A=/srv/fleet/data/release-artifacts # 入口容器里的 /data/release-artifacts
v() { python3 -c 'import json,sys;print(json.load(open("release.json"))["components"][sys.argv[1]]["version"])' "$1"; }
CX=$(v codex) TM=$(v tmux) T=$(mktemp -d)
mkdir -p "$A"
while read -r plat triple tplat; do
for n in codex codex-code-mode-host; do
curl -fL --retry 8 --retry-delay 5 --retry-all-errors -o "$T/$n.tar.gz" \
"https://github.com/openai/codex/releases/download/rust-v$CX/$n-$triple.tar.gz" &&
tar xzf "$T/$n.tar.gz" -C "$T" && install -m 0755 "$T/$n-$triple" "$A/$n-$CX-$plat" || echo "失败:$n $plat"
done
install -m 0755 "tokenledger/hub/api/fleetclient/pack/vendor/tmux-$tplat" "$A/tmux-$TM-$plat" || echo "失败:tmux $plat"
done <<'EOF'
darwin-arm64 aarch64-apple-darwin macos-arm64
linux-amd64 x86_64-unknown-linux-musl linux-x86_64
EOF
rm -rf "$T"; ls -l "$A"
- 打出一行「失败:…」就是那一个没放上,重跑这一段即可(已放好的会被同样的文件覆盖)。
pack/vendor/里缺某个平台的 tmux:fleet-client-pack.sh下载那一个时失败了,重跑它(它也走 GitHub)。- 放好后
curl -fsS https://hub.example.com/v1/fleet/release/artifacts应当列出你每个平台的三样(codex·codex-code-mode-host·tmux),入口就能给这一版签名打包了。每次升级(§4.2)前,release.json里的版本变了就再跑一次。
这两张表和 §1.4 的键都登记在仓库的设置总表 conf/fleet.conf.reference 末尾「The hub's own settings」一段(地址类在它上面一段),每键一行:用途、必填与否、是不是密钥。表与它不一致时以它为准。
1.4 登录方式
至少配一种,配了几种登录页就给几种(顺序固定:微信、邮箱、GitHub)。密钥类的值只放在环境变量或 Kubernetes Secret 里。
| 方式 | 键 | 去哪儿拿 |
|---|---|---|
| GitHub | CCQUOTA_GITHUB_CLIENT_ID · CCQUOTA_GITHUB_CLIENT_SECRET | GitHub → Settings → Developer settings → OAuth Apps → New,回调地址填 https://hub.example.com/auth/github/callback |
| 邮箱 | CCQUOTA_SMTP_URL(smtp://用户:密码@主机:587,或 smtps://…:465)· CCQUOTA_SMTP_FROM | 任何能发信的 SMTP,例如云厂商的邮件服务 |
| 微信(电脑扫码) | CCQUOTA_WECHAT_WEB_APPID · CCQUOTA_WECHAT_WEB_SECRET | 微信开放平台的「网站应用」(要过审核) |
| 微信(微信里打开) | CCQUOTA_WECHAT_MP_APPID · CCQUOTA_WECHAT_MP_SECRET | 服务号的网页授权 |
| 不用 GitHub 时 | CCQUOTA_SESSION_SECRET | 自己生成:openssl rand -hex 32 |
每一对都要配齐:只配一半,入口拒绝启动。邮箱配置不能用时只记一行日志,其余照常。
除了上面列为管理员的人,谁都要有邀请才能进来(§3.1)。
1.5 方式一:单机容器
适合一台 Linux 服务器。入口只听本机,HTTPS 由反向代理(Caddy、nginx)接到 127.0.0.1:8787。新机器上还没有的话装一个 Caddy 最省事,证书它自己申请:Ubuntu / Debian 上 sudo apt install caddy,apt 里的 2.6 对入口够用(只有 §1.8 的中转要 2.10 以上)。
sudo mkdir -p /srv/fleet/data/release-artifacts && sudo chown -R 10001 /srv/fleet/data # 镜像以 uid 10001 运行
sudo cp ~/fleet-secrets/{ca,cred-key,release-key} /srv/fleet/ && sudo chown 10001 /srv/fleet/{ca,cred-key,release-key}
cat > /srv/fleet/hub.env <<'EOF'
CCQUOTA_FLEET=1
CCQUOTA_FLEET_PUBLIC_URL=https://hub.example.com
CCQUOTA_PUBLIC_URL=https://hub.example.com
CCQUOTA_GITHUB_ADMINS=alice
CCQUOTA_FLEET_ADMIN_USERS=alice
CCQUOTA_FLEET_SSH_CA_KEY=/secrets/ca
CCQUOTA_FLEET_CRED_KEY_FILE=/secrets/cred-key
CCQUOTA_FLEET_RELEASE_KEY=/secrets/release-key
CCQUOTA_FLEET_RELEASE_DIR=/data/releases
CCQUOTA_FLEET_RELEASE_ARTIFACTS=/data/release-artifacts
CCQUOTA_FLEET_RELEASE_PLATFORMS=darwin-arm64 linux-amd64
CCQUOTA_FLEET_OAUTH_REFRESH_VIA=node
EOF
# 密钥类的值另放一个只有 root 能读的文件
sudo sh -c 'umask 077; cat > /srv/fleet/hub.secret.env' <<EOF
CCQUOTA_VIEWER_TOKEN=$(cat ~/fleet-secrets/viewer-token)
CCQUOTA_GITHUB_CLIENT_ID=<你的 OAuth 应用的 Client ID>
CCQUOTA_GITHUB_CLIENT_SECRET=<它的 Client secret>
EOF
docker run -d --name fleet-hub --restart unless-stopped \
-p 127.0.0.1:8787:8787 \
-v /srv/fleet/data:/data \
-v /srv/fleet/ca:/secrets/ca:ro -v /srv/fleet/cred-key:/secrets/cred-key:ro \
-v /srv/fleet/release-key:/secrets/release-key:ro \
--env-file /srv/fleet/hub.env --env-file /srv/fleet/hub.secret.env \
registry.example.com/fleet/ccquota:$TAG --retention-days=60
反向代理要放开长连接:机器的控制通道和会话的流式输出会一连几十分钟。Caddy 一行就够:
hub.example.com {
reverse_proxy 127.0.0.1:8787 {
flush_interval -1
}
}
Caddy 的话这一段写进 /etc/caddy/Caddyfile,再 sudo systemctl reload caddy。nginx 的话关掉 proxy_buffering,proxy_read_timeout / proxy_send_timeout 设到 3600s,client_max_body_size 设到 64m,并转发 WebSocket 升级头。
数据都在 /srv/fleet/data/ccquota.db。不要在入口运行时直接复制这个文件,备份见 §5.1。
1.6 方式二:Kubernetes
仓库的 deploy/k8s/ 是一套通用清单:base/ 是两副本 + Postgres,零停机滚动发布;components/sqlite-single/ 换成单副本 + 一块数据盘。你自己的值放在你自己的部署仓库里的一个覆盖层,不改 base/:
mkdir -p ~/fleet-deploy && cp -r deploy/k8s/base deploy/k8s/components ~/fleet-deploy/
mkdir -p ~/fleet-deploy/overlays && cp -r deploy/k8s/overlays/example ~/fleet-deploy/overlays/mine
在 overlays/mine/ 里改三个文件、加一个:
ingress-hosts.yaml:你的域名和 TLS Secret 名。deployment-env.yaml:§1.3 表里的地址和管理员;不要中转就删掉CCQUOTA_FLEET_CRED_RELAY_URL那一项,不需要CCQUOTA_METER_ORIGINS也删掉。- 新建
release-key.yaml(签名钥匙):
apiVersion: apps/v1
kind: Deployment
metadata:
name: ccquota-hub
spec:
template:
spec:
containers:
- name: ccquota
env:
- name: CCQUOTA_FLEET_RELEASE_KEY
value: /etc/ccquota-release/key
- name: CCQUOTA_FLEET_RELEASE_DIR
value: /data/releases
- name: CCQUOTA_FLEET_RELEASE_ARTIFACTS
value: /data/release-artifacts
- name: CCQUOTA_FLEET_RELEASE_PLATFORMS
value: darwin-arm64 linux-amd64
volumeMounts:
- name: release-key
mountPath: /etc/ccquota-release
readOnly: true
volumes:
- name: release-key
secret:
secretName: ccquota-release-key
defaultMode: 0440
kustomization.yaml整个换成下面这样(小团队推荐的一块盘 + SQLite 形态):
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: fleet-hub
resources:
- ../../base
components:
- ../../components/sqlite-single # 一块盘、SQLite;要 Postgres 就删掉这两行
images:
- name: ccquota
newName: registry.example.com/fleet/ccquota
newTag: abc1234 # §1.1 的 TAG
- name: busybox
newName: registry.example.com/fleet/busybox
patches:
- path: deployment-env.yaml
- path: ingress-hosts.yaml
- path: release-key.yaml
# 没装「正在更新」页(deploy/k8s/updating/)就去掉指向它的注解
- target: {kind: Ingress, name: ccquota-hub}
patch: |-
- op: remove
path: /metadata/annotations/nginx.ingress.kubernetes.io~1default-backend
- 要两副本 + Postgres:删掉
components:两行;/data/releases那时没有盘,给CCQUOTA_FLEET_RELEASE_DIR和CCQUOTA_FLEET_RELEASE_ARTIFACTS另挂一块小盘。 - 装了「正在更新」页(发布那几十秒里浏览器看到的提示页)就删掉最后一项。
- 数据盘用集群默认的 storageclass(
kubectl get storageclass里标(default)的那个)。要用别的,在patches:里加一项:target: {kind: PersistentVolumeClaim, name: ccquota-data},JSON patchop: add、path: /spec/storageClassName、value: <你的 storageclass>。
密钥由人建,不进任何仓库:
NS=fleet-hub
kubectl create namespace $NS
cd ~/fleet-secrets
kubectl -n $NS create secret generic ccquota-ssh-ca --from-file=ca=./ca
kubectl -n $NS create secret generic ccquota-fleet-cred-key --from-file=key=./cred-key
kubectl -n $NS create secret generic ccquota-release-key --from-file=key=./release-key
kubectl -n $NS create secret generic ccquota \
--from-literal=viewer-token="$(cat viewer-token)" \
--from-literal=github-client-id='<Client ID>' --from-literal=github-client-secret='<Client secret>'
# 只有 Postgres 形态需要:
kubectl -n $NS create secret generic ccquota-db \
--from-literal=db-url='postgres://…?sslmode=require' --from-literal=replica-token="$(openssl rand -hex 32)"
登录方式的键在 deployment-env.yaml 的 env: 里引用 ccquota 这个 Secret,不写值:
- name: CCQUOTA_GITHUB_CLIENT_ID
valueFrom: {secretKeyRef: {name: ccquota, key: github-client-id}}
- name: CCQUOTA_GITHUB_CLIENT_SECRET
valueFrom: {secretKeyRef: {name: ccquota, key: github-client-secret}}
然后:
kubectl kustomize ~/fleet-deploy/overlays/mine > /tmp/hub.yaml # 先看一眼渲染结果
kubectl apply -f /tmp/hub.yaml
kubectl -n $NS rollout status deploy/ccquota-hub
SQLite 形态必须是一个副本、Recreate:两个进程同时写一个文件会把库写坏。
1.7 检查入口
curl -fsS https://hub.example.com/healthz # ok
curl -fsS https://hub.example.com/version # 镜像版本、stable 的提交号
curl -sS https://hub.example.com/install | head -1 # #!/bin/sh —— 客户端安装脚本
/install回的是一句以#开头的话(HTTP 503)而不是脚本:入口还发不了客户端,原因就在那句话里——还没配任何登录方式(§1.4),或者没配CCQUOTA_FLEET_SSH_CA_KEY。补上、重启入口即可。启动日志里也有同一句(fleet: /install is off: …)。/version里没有stable、却有stable_problem:入口问不到 GitHub 上的stable,那句话说了原因和办法,见 §4.4。- 云内网能打开、从外网
curl却是Connection reset,或者 HTTP 回的是云厂商的「Non-compliance ICP Filing」之类页面:入口在境内机房、域名没有备案,被云厂商按 SNI 拦了(换端口也一样),见 §1.8 第一行。
然后用浏览器打开 https://hub.example.com,以 §1.3 里列的管理员身份登录:自托管的入口打开就是控制台(没有官网首页)。未登录时浏览器会被带到 /app/signin;curl 这类非浏览器请求 / 得到的是 HTTP 401 和 {"error":"a viewer token is required"},这是正常的,不是入口坏了。
启动不起来时看容器日志(docker logs fleet-hub / kubectl -n $NS logs deploy/ccquota-hub):缺钥匙、登录方式只配了一半,都会在第一屏说清楚是哪个键。
1.8 国内部署
入口和机器在国内时,有几处要多做一步:
| 什么 | 为什么 | 做法 |
|---|---|---|
| 入口域名 | 境内机房按 SNI 拦没有备案的域名:外网访问 443(换任何端口都一样)只有 reset,HTTP 回云厂商的「未备案」页,云内网却正常 | 入口放在境内机房,域名必须已经 ICP 备案,并接入这家云(阿里云叫「接入备案」,在别家备过的也要再接入);做不到,就把入口放在境外或港澳机房,机器照样放在国内 |
| 构建拉基础镜像 | Docker Hub(golang、alpine)直连常拉不到 | 在构建机的 /etc/docker/daemon.json 配 "registry-mirrors"(你的云厂商的镜像加速地址),sudo systemctl restart docker 后再构建 |
| 构建拉 Go 依赖 | 直连官方代理常卡住 | 默认已经经 goproxy.cn(§1.1) |
stable 是哪个提交 | 入口匿名问 GitHub 会被限流 | 把 stable 推给入口(§4.4) |
| 发布包要的程序 | 自己没有 Codex、tmux 的制品 | 从已有这一版的入口取(§1.3「发布包要的程序」) |
| 发布包里的 Claude Code | 入口默认先从 registry.npmjs.org 拉 | 入口设 CCQUOTA_FLEET_RELEASE_NPM="https://registry.npmmirror.com https://registry.npmjs.org",先走国内镜像(每个包都按 sha512 核过) |
| 订阅流量 | Claude、ChatGPT 不服务国内 | 架一台海外中转,入口和机器都经它出去(本节下文) |
中转是一台海外的小 Linux(Debian / Ubuntu,python3、systemd,Caddy 2.10 以上)。它不存任何凭据,只做两件事:凭入口签发的通行证放行,再把请求原样转给订阅商。放行与否每次都问入口(经本机一个核验进程,入口重启的几分钟里它记得刚放行过的通行证)。
下面用 relay.example.com 代表中转的域名,hub.example.com 代表入口。
1. 中转机:先装核验进程。 文件都在仓库的 extras/cred-relay/:
# 在中转机上,仓库副本里
sudo install -D -m 0755 extras/cred-relay/fleet-relay-check.py /usr/local/lib/fleet-relay/fleet-relay-check.py
sudo install -m 0644 bin/fleet_ed25519.py /usr/local/lib/fleet-relay/fleet_ed25519.py
sudo install -m 0644 extras/cred-relay/fleet-relay-check.service /etc/systemd/system/
sudo systemctl edit fleet-relay-check # 写两行:[Service] 和 Environment=FLEET_HUB_URL=https://hub.example.com
sudo systemctl daemon-reload && sudo systemctl enable --now fleet-relay-check
curl -s http://127.0.0.1:2091/healthz # ok
2. 中转机:再装 Caddy 站点。 顺序不能反:Caddy 先起来会去问一个还不存在的核验进程,所有请求都被拒。
sudo cp extras/cred-relay/Caddyfile /etc/caddy/Caddyfile
sudo systemctl edit caddy # 写两行:[Service] 和 Environment=FLEET_RELAY_SITE=relay.example.com
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
用 caddy 用户校验:以 root 校验会建出 root 的日志文件,caddy 随后起不来。防火墙:80、443 对所有来源开放(门禁靠通行证,80 只为签证书),22 只对你自己。
3. 入口:打开中转。 在 §1.2 的目录里再生成两把钥匙:
cd ~/fleet-secrets && umask 077
openssl rand -base64 32 > session-cred-key # 入口给自己和会话签中转通行证
openssl rand -base64 32 > relay-sign-key # 给机器签 4 小时有效的中转通行证
入口加这几个键(钥匙按 §1.5 / §1.6 的办法以文件挂进去,不写值):
| 键 | 值 | 说明 |
|---|---|---|
CCQUOTA_FLEET_CRED_RELAY_URL | https://relay.example.com | 中转的地址 |
CCQUOTA_FLEET_SESSION_CRED_KEY_FILE | /secrets/session-cred-key | 密钥 |
CCQUOTA_FLEET_RELAY_SIGN_KEY_FILE | /secrets/relay-sign-key | 密钥;不设也能用,通行证就不会过期 |
CCQUOTA_FLEET_OAUTH_REFRESH_VIA | relay | 订阅的登录状态由入口经中转续(替代 §1.3 的 node) |
CCQUOTA_GITHUB_OAUTH_VIA | relay | 只在用 GitHub 登录、入口又连不上 github.com 时设 |
设了 …_VIA=relay 却缺中转地址或 SESSION_CRED_KEY,入口拒绝启动并说出缺哪个。先升级中转,再部署入口。
4. 机器:经中转出去。 机器要被入口标为可信才领得到中转通行证;每个跑会话的登录都要知道中转地址(FLEET_CRED_RELAY_URL=https://relay.example.com)。怎么在加机器时带上它,见 §2。
5. 验收。 不需要任何凭据:
curl -s https://relay.example.com/healthz # ok
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://relay.example.com/anthropic/v1/messages # 403:没有通行证
curl -s -o /dev/null -w '%{http_code}\n' https://relay.example.com/v1/models # 404:不是开放代理
中转机上 sudo grep -rEc 'sk-ant-|eyJhbGci|Bearer ' /var/log/caddy/ /etc/caddy/ 应全为 0:日志只记路径前缀、状态和字节数。
管理入口和中转的人可以照仓库里 plugins/fleet-infra/skills/sg-relay-setup/SKILL.md 一步步做(迁移、撤销、回滚也在里面)。
2. 加机器
会话跑在机器上(控制台里叫「服务节点」)。每台机器在入口上有一张卡片,入口按空位把新会话放过去。
2.1 Mac
- 控制台 → 服务节点与客户端 → 加服务节点,填一个名字(例如
mac-a)。页面给出一行命令,里面带一个一次性的加入码,一小时内有效。 - 在这台 Mac 上,用管理员账号打开终端,粘贴那一行。它长这样:
curl -fsSL https://hub.example.com/install/bin/fleet-node-install.sh | sudo bash -s -- --hub https://hub.example.com --join <加入码>
- 它一步一行地打印:检查 · 加入 · 发布公钥 · 期望状态 · ccquota · 运行时 · 机器钥匙 · 角色用户 · ssh CA · 删号通道 · 核心 · 守护 · 入口。每行是
✓、跳过(已经好了)或✗(失败,下面一行写着怎么重跑)。最后一行是已收敛。 - 「服务节点与客户端」页上出现
mac-a,对话框里写「mac-a 已加入 Tokenman」。
要求:macOS 自带的 /usr/bin/python3(没有的话先 xcode-select --install)、curl、管理员权限。
删号要多一步授权:系统设置 → 通用 → 共享 → 远程登录 → ⓘ → 打开「允许远程用户完全磁盘访问」。安装时「删号通道」一行会提醒你;不开的话开号照常,删号做不了。
2.2 Linux
一台 Linux 机器从加入到开出第一个号,只要一个加入码:
- 装好依赖。要求:Debian 12 或 Ubuntu(带 systemd)、Python 3.9 以上,以及
curlgitzshopenssh-serversudo。Debian / Ubuntu 上一次装齐:sudo apt-get install -y python3 curl git zsh openssh-server sudo
- 拿加入码:控制台 → 服务节点与客户端 → 加服务节点,填一个名字(例如
linux-a)。页面给出一行命令,一次性加入码一小时内有效。 加入:用一个能
sudo的账号在这台 Linux 上粘贴那一行:curl -fsSL https://hub.example.com/install/bin/fleet-node-install.sh | sudo bash -s -- --hub https://hub.example.com --join <加入码>它和 Mac 一样一步一行地打印(Linux 上多「开号」「中转」两行,没有「删号通道」)。这台还没有任何账号,所以最后不会说「已收敛」,而是:
已加入,还没有账号:这台(linux-a)的节点程序在等入口派第一个号。下一步在入口做—— 控制台「使用者」→ 生成邀请命令(设置「新使用者在哪些服务节点上开登录名」写 linux-a),或 fleet hub accounts assign <身份 id> --host linux-a 开号在这台上由节点程序自己做;账号开出来之前,这台不跑任何会话。
- 开第一个号:照那几行做(开号与删号)。入口把开号发给这台,这台的节点程序自己建账号,控制台上「排队开号」→「开号中」→ 完成,一般一两分钟。之后重跑第 3 步的命令,最后一行就是
已收敛。
中转:入口配了 CCQUOTA_FLEET_CRED_RELAY_URL(§1.3)时,加入时入口把这个地址交给机器,之后在这台上开的每个账号都自动走它,不用到机器上手写。入口是加入以后才配的中转,就在这台上重跑第 3 步的命令(不用新加入码),「中转」一行会把它写进已有的账号。入口没配中转,这一行不出现。
安装程序自己把目录设成 0755、文件 0644,不受你 shell 的 umask 影响。运行时装在 /opt/claude-fleet,守护进程是 claude-fleet-node.service:
systemctl status claude-fleet-node.service
2.3 检查与修复
在机器上:
fleet doctor --machine
每个部件一行 PASS / WARN / FAIL;退出码是 FAIL 的个数,0 就是好的。
修复就是重跑同一行命令:已经好的步骤打印 跳过,只补缺的那一步;入口还认这台机器时不消耗新的加入码。加入码过期了就回控制台再拿一个。
2.4 维护和移除
- 维护:机器卡片 → 维护 → 开始维护。新会话不再放到这台,正在跑的照常跑完;修好后 结束维护。关机、断网前先进维护。
- 移除:机器卡片 → 移除。入口作废这台机器的钥匙,不再往它上面放会话;机器上装的东西留着,重新加入时再跑一次 §2.1 / §2.2 即可。
3. 成员和客户端
3.1 给成员开号
- 控制台:使用者 → 生成邀请命令。填了 GitHub 用户名,邀请就只给这个人用;空着就是给第一个用它的人。
- 命令行(在已登录的客户端上):
fleet hub invite # 打印一行安装命令,发给新同事;他装好就自动进来 fleet hub accounts # 每个人和他在哪台机器上有账号
开号(在某台机器上给这个人开一个系统账号)由入口发给机器去做,控制台上会显示「排队开号」→「开号中」→ 完成。
3.2 装客户端
每位成员在自己的电脑(macOS、Linux,Windows 用 WSL)上:
curl -fsSL https://hub.example.com/install | sh
- 装到
~/.local/bin/fleet,自带 tmux。装好后会直接打开fleet。 - 第一次打开要登录:浏览器打开入口的登录页(或终端里显示一个二维码),用入口提供的方式登录、点「确认签发」。连接证书 12 小时有效,到期自动续。
- 想让这台电脑自己也跑会话,加
--host:curl -fsSL https://hub.example.com/install | sh -s -- --host。
检查:
fleet doctor # 「fleet」一行:版本、入口、tmux、证书
fleet update # 这台电脑跑的版本和它跟的 stable
4. 升级
4.1 stable 是什么
Tokenman 的每一部分——入口、机器、客户端——都跟着同一个版本号:仓库里叫 stable 的那个标签。stable 只在一个提交通过了全部检查(Linux 和 macOS 两边的测试、发版前的回放检查)之后才往前挪,所以它是「可以给别人用」的那一版,不是最新的提交。
fleet update stable # 入口告诉你的 stable 提交号
curl -fsS https://hub.example.com/version
4.2 一次升级怎么走
- 入口先走。用新的
stable照 §1.1 构建镜像,再按 §5.1 先给库做一份快照,然后换标签:- 单机容器:
docker pull新镜像,docker rm -f fleet-hub,用新标签重跑 §1.5 的docker run。 - Kubernetes:改
overlays/mine/kustomization.yaml的newTag,kubectl apply。
- 单机容器:
- 机器自己跟。入口换好后,它给新版本签名并通知每台机器;机器每 5 分钟也会自己问一次。一次升级是:下载、验签、整体切换、30 秒后自检,自检多出一个 FAIL 就自动切回上一版。
- 客户端自己跟。每次打开
fleet时(至多一小时一次)问入口,在后台准备好新版,空闲时切换;要重开才生效时,顶上会写「新版已就绪 · 下次打开生效」。
入口没换镜像时,机器停在旧版、不会乱跳:入口只给和自己同一份源码的版本签名。
看谁还落后(在一台机器上):
fleet doctor --installs # 这台机器上每一份安装 vs stable,落后超过 30 分钟记 WARN
4.3 自己决定跟哪一版
入口发给机器和客户端的版本从这几处来,排在前面的先用:
- 推给入口的发布:发布列车(或你自己用
bin/fleet-release-publish.sh,见 §4.4)推来的版本。推过一次,入口只认推来的。 - 上游入口:设了
CCQUOTA_FLEET_UPSTREAM_HUB,入口去那个入口取它签过名的版本,整份(清单、签名、运行时、程序包)存进自己的发布仓库,再发给自己的机器。不经过 GitHub。 - GitHub:只有设了
CCQUOTA_FLEET_STABLE_REPO=<组织>/<仓库>才去 GitHub 问那个仓库的stable。 - 镜像自带:以上都没有,只发镜像里打进去的那一版客户端,机器不会自动升级。
什么都不设,入口不问 GitHub。curl -fsS https://hub.example.com/version 的 stable_source 说这一刻用的是哪一处:release · upstream · github · image。
跟官方入口走(最省事,GitHub 不通也照常):
CCQUOTA_FLEET_UPSTREAM_HUB=https://官方入口
CCQUOTA_FLEET_RELEASE_DIR=/data/releases
CCQUOTA_FLEET_RELEASE_ROOT=ed25519 <官方入口 /v1/fleet/release/key 的那一行>
CCQUOTA_FLEET_RELEASE_ROOT 抄一次就行(curl -fsS https://官方入口/v1/fleet/release/key):入口用它核对上游发来的每一个版本,签名对不上的不收,stable_problem 会说原因。这种入口自己不签名,所以不要设 CCQUOTA_FLEET_RELEASE_KEY。入口在有人访问 /version 时顺便看一眼上游(最多 5 分钟一次),上游往前走了就跟上,不会往回退。
自己把关:fork 这个仓库,在你的 fork 上移动 stable 标签(只挪到你验过的提交),并给入口设
CCQUOTA_FLEET_STABLE_REPO=<你的组织>/claude-fleet
或者不设它,每次自己把验过的提交推给入口(§4.4 的第 2 步)。
4.4 入口问不到 GitHub 时
设了 CCQUOTA_FLEET_STABLE_REPO、又没推过 stable 的入口,每 5 分钟匿名去问一次 api.github.com:「stable 现在是哪个提交」。入口在国内时,出口地址常和很多人共用,GitHub 给匿名请求的额度(每小时 60 次)很快被用完,答 403。这时:
curl -fsS https://hub.example.com/version里没有stable,stable_problem写着「GitHub rate-limited …」;- 入口日志每 5 分钟一行
fleet: stable lookup: … HTTP 403 … — GitHub rate-limited this hub's anonymous stable lookup。
办法是**把 stable 推给入口**,推过一次之后入口就只认推来的,再也不问 GitHub:
- 生成一个口令(
openssl rand -hex 32),作为CCQUOTA_FLEET_PUBLISH_TOKEN放进入口的密钥(单机容器放hub.secret.env,Kubernetes 放 Secret),重启入口。 在一份能访问入口的仓库副本里推:
cd claude-fleet && git fetch origin --tags && git checkout stable read -rs FLEET_PUBLISH_TOKEN && export FLEET_PUBLISH_TOKEN # 粘贴同一个口令,不回显 bin/fleet-release-publish.sh $(git rev-parse HEAD) --hub https://hub.example.com入口自己核对提交和文件,不需要信任这台电脑;同一个提交再推一次答
already。curl -fsS https://hub.example.com/version的stable就是这个提交,stable_problem不见了。
以后每次升级(§4.2)照第 2 步推新的提交即可。口令只从环境读,不要写进命令行或提交进仓库。
5. 回滚
5.1 入口
每次换镜像之前先做快照。数据库是 WAL 模式,运行中直接复制会得到一份坏文件,要用 SQLite 的 VACUUM INTO:
# 单机容器:在宿主机上用 sqlite3(3.27 以上)
sqlite3 /srv/fleet/data/ccquota.db "VACUUM INTO '/srv/fleet/data/backup-$(date -u +%Y%m%dT%H%M%SZ).db'"
Postgres 形态用数据库自己的备份(云数据库的自动备份,或 pg_dump)。
想在切换前先验一下新镜像,用快照的一份副本跑它的自检——跑完就退出,不监听端口,不碰正式库:
cp /srv/fleet/data/backup-<时间>.db /tmp/check.db && chown 10001 /tmp/check.db
docker run --rm -v /tmp/check.db:/data/check.db -v /srv/fleet/ca:/secrets/ca:ro \
-v /srv/fleet/cred-key:/secrets/cred-key:ro -v /srv/fleet/release-key:/secrets/release-key:ro \
--env-file /srv/fleet/hub.env --env-file /srv/fleet/hub.secret.env \
-e CCQUOTA_FLEET_RELEASE_DIR=/tmp/releases \
--entrypoint ccquota registry.example.com/fleet/ccquota:<新标签> hub --check --db /data/check.db
退出码 0 才切。
回滚 = 换回旧标签(单机容器:用旧标签重跑 docker run;Kubernetes:newTag 改回去再 apply)。如果新版本已经改过库结构(升级日志里有 migration),旧镜像读不了新库,还要换回快照:
docker stop fleet-hub
mv /srv/fleet/data/ccquota.db /srv/fleet/data/ccquota.db.bad
cp /srv/fleet/data/backup-<时间>.db /srv/fleet/data/ccquota.db && chown 10001 /srv/fleet/data/ccquota.db
rm -f /srv/fleet/data/ccquota.db-wal /srv/fleet/data/ccquota.db-shm
# 再用旧标签重跑 §1.5 的 docker run
快照之后入口里新发生的事(新登录、新会话记录)会随快照一起回到过去。Kubernetes 的 SQLite 形态同理:先 kubectl -n $NS scale deploy/ccquota-hub --replicas=0,在数据盘上换文件,再扩回 1。
5.2 机器
自动的:每台机器保留上一版,切换后自检出了新的 FAIL 就自己切回去,并跳过那一版。入口退回旧镜像后,机器跟着入口签的版本走,不用逐台处理。
5.3 客户端
一般不用管,它跟着入口。确实要手动退回上一版:
ls ~/.claude/fleet.versions/ # 每一版一个目录,.prev 记着上一版
ln -sfn ~/.claude/fleet.versions/<上一版> ~/.claude/fleet
6. 卸载
- 成员的客户端:
fleet quit,再fleet logout;删掉~/.local/bin/fleet、~/.claude/fleet、~/.claude/fleet.versions、~/.local/share/claude-fleet-vendor(安装时带的 tmux),以及安装时加进 shell 配置文件的那一行 PATH。 - 机器:先在控制台 维护、等会话跑完,再 移除。机器上的运行时目前没有一键卸载命令。
- 入口:停掉容器(
docker rm -f fleet-hub)或删掉命名空间(kubectl delete namespace fleet-hub)。数据在/srv/fleet/data或数据盘 / 数据库里,你决定留不留。