Skip to content

Control Server —— 管理面 API

面向管理员与自动化脚本。全部路由前缀 /control/v1/admin,下文表格的「路径」列省略该前缀,全部需要 Admin Bearer(两种凭据见 鉴权模型)。

管理端登录端点 /control/v1/auth/* 不在 admin 前缀下,但属于同一信任域,一并收录在本文开头。


账号登录

源码:apps/control-server/app/api/v1/auth/admin.py。管理员账号(用户名 + 密码 + Cap 人机验证)登录,替代人手分发共享 admin token;静态 token 保持原样可用。

方法路径鉴权说明
POST/control/v1/auth/login请求体 {username, password, captcha_token}(历史字段名 turnstile_token 等价收取)。成功 200 返回 {token, expires_at}——不透明会话令牌 + ISO 8601 过期时间
POST/control/v1/auth/password账号会话令牌请求体 {old_password, new_password}(新密码 ≥8 字符)。成功 204,并吊销该用户全部存活会话(含当前,前端 401 后全局登出重登);静态 admin token 调用返回 403

/auth/login 的错误语义(前端按状态码 + detail 分支):

状态码detail(稳定字符串)含义
400turnstile verification failed人机验证 token 缺失、伪造或复用。token 一次性,服务端调 Cap 实例 siteverify 硬校验;[auth] cap_* 三项配置不齐同样 400(fail-closed)。措辞为历史锁定契约,不随提供方变化
401invalid credentials用户不存在与密码错误统一文案(防枚举)。服务端对不存在的用户也跑一次 argon2 对照校验,抹平时间侧信道
403同管理面未配置静态 admin token,管理面整体锁定
429too many attempts同用户名同 IP 连续凭据失败 5 次后的 15 分钟封禁窗口;登录成功清零计数。IP 取 CF-Connecting-IPX-Forwarded-For → 直连地址

登录成功、失败、限速与改密全部写入管理审计(path/control/v1/auth/login/control/v1/auth/password;成功记 actor 为用户名,失败记尝试的用户名与 IP)。密码明文绝不入日志。

操作步骤见 认证与账号


节点(Node)

源码:apps/control-server/app/api/v1/admin/nodes.py

方法路径说明
GET/nodes列出所有节点(按 node_id 排序)
POST/nodes创建节点,201node_id 已存在 409
GET/nodes/{node_id}取单节点
PATCH/nodes/{node_id}局部更新;已发布过(current_generation > 0)则同事务重物化并广播
DELETE/nodes/{node_id}删除,204;活节点(已发布且非 decommissioned)409,须先退役
POST/nodes/{node_id}/decommission退役:发布空 DesiredState,拆除隧道、撤 BGP,保留节点与子表
POST/nodes/{node_id}/recommission撤销退役,恢复 active 并重新物化
GET/nodes/{node_id}/desired-state该节点已发布的完整 DesiredState;无则 404
GET/nodes/{node_id}/generations?limit=世代历史元信息(limit 1..500,默认 50)
GET/nodes/{node_id}/generations/{generation}单代详情(元信息 + 完整快照)
GET/nodes/{node_id}/generations/{generation}/diff?against=两代字段级 diff;against 缺省取 generation-1,第 1 代须显式传
POST/nodes/{node_id}/generations/{generation}/rollback把某代快照重新发布为新一代并广播
POST/nodes/{node_id}/notify手动门铃:desired_state_updated(递增世代)或 snapshot_request;无在线 agent 订阅时 409

NodeInnode_id(1..64)、asn(≥1)、router_id;可选 site / loopback_ipv4 / loopback_ipv6 / link_local / ipv4_prefixes[] / ipv6_prefixes[] / inventory{} / labels{} / base_template{}

NodePatch 同字段全可选(exclude_unset 语义,省略即不动)。

NodeOut 额外含 current_generation / lifecycle / dns_group_id / wireguard_public_key / created_at / updated_at。其中 wireguard_public_key 是节点密钥表公钥的只读投影(写路径只有密钥表本身,见 fleet 供给收权),未生成密钥时为 null

link_local 是外部 eBGP link-local 地址的单一真相源:materializer 由它派生外部 WG 接口地址。取值须为 fe80::/10 内、不带 %zone 后缀的 IPv6 link-local 地址,非法值 422

json
// POST /control/v1/admin/nodes
{
  "node_id": "edge1",
  "asn": 4242420000,
  "router_id": "172.20.0.1",
  "site": "hkg",
  "loopback_ipv4": "172.20.0.1",
  "loopback_ipv6": "fd00:1234::1",
  "ipv4_prefixes": ["172.20.0.0/27"],
  "ipv6_prefixes": ["fd00:1234::/48"],
  "labels": {"region": "apac"}
}
json
// 201 Created
{
  "node_id": "edge1",
  "asn": 4242420000,
  "router_id": "172.20.0.1",
  "site": "hkg",
  "loopback_ipv4": "172.20.0.1",
  "loopback_ipv6": "fd00:1234::1",
  "link_local": null,
  "ipv4_prefixes": ["172.20.0.0/27"],
  "ipv6_prefixes": ["fd00:1234::/48"],
  "inventory": {},
  "labels": {"region": "apac"},
  "base_template": {},
  "current_generation": 0,
  "lifecycle": "active",
  "dns_group_id": null,
  "wireguard_public_key": null,
  "created_at": "2026-08-10T00:00:00Z",
  "updated_at": "2026-08-10T00:00:00Z"
}

NotifyRequest{ "event": "desired_state_updated" | "snapshot_request", "reason": "..." }(默认 desired_state_updated)。NotifyResponse{ node_id, event, generation, subscribers, delivered }RollbackResponse{ node_id, target_generation, new_generation, reason, subscribers, delivered }


Peering

源码:apps/control-server/app/api/v1/admin/peerings.py。Peering 是对等关系的元信息,本身不进入 DesiredState;普通 CRUD 不触发 materializeprovisionfull 才会一并物化子资源。

方法路径说明
GET/nodes/{node_id}/peerings列出节点的 peering(按 name
POST/nodes/{node_id}/peerings创建 peering,201;同节点重名 409
GET/peerings/{peering_id}取单条 peering
PATCH/peerings/{peering_id}局部更新 peering 元信息
DELETE/peerings/{peering_id}删除,204。关联的活跃自动对等请求行同事务推进 cancelledreject_reason='removed by operator'),配额位与 (asn, node) 活跃唯一位随之释放
POST/nodes/{node_id}/peerings/provision一键:同事务建 Peering + WgInterface +(可选)BgpSession 并物化广播,201
GET/nodes/{node_id}/peerings/full组合读:节点全部 peering 及其名下接口与 BGP 会话
GET/peerings/{peering_id}/full单 peering 的组合读
PUT/nodes/{node_id}/peerings/full(node_id, peering.name) create-or-replace 完整 peer(子资源整集替换),200
POST/nodes/{node_id}/peerings/backfill把孤儿接口与会话归并成 Peering(dry_run 只返回计划,不物化、不广播)

PeeringInname(1..64)、remote_asn(≥1);可选 remote_node_id / remote_label / is_internal(默认 false)/ enabled(默认 true)/ notesGET /nodes/{node_id}/peerings 的行额外带 remote_as_name(registry 副本的 as-name,与路由表起源名称同源;未配 registry 服务或未收录为 null)。

PeeringProvisionInpeeringPeeringIn)+ interface_specInterfaceSpec 的 JSON dump)+ interface_enabled / interface_sort_order;可选 bgp_specBgpSessionSpec dump)+ bgp_sort_order。三段先各自 schema 校验(失败 422),任一唯一约束冲突 409 整笔回滚。

json
// POST /control/v1/admin/nodes/edge1/peerings/provision
{
  "peering": {
    "name": "as4242421111",
    "remote_asn": 4242421111,
    "remote_label": "example-peer",
    "is_internal": false
  },
  "interface_spec": {
    "name": "wg-peer1",
    "kind": "wireguard",
    "listen_port": 51820,
    "addresses": ["fe80::1/64"]
  },
  "bgp_spec": {
    "name": "as4242421111",
    "remote_asn": 4242421111,
    "neighbor_address": "fe80::2",
    "enabled": true
  }
}
json
// 201 Created(节选)
{
  "peering": { "id": 12, "local_node_id": "edge1", "name": "as4242421111", "remote_asn": 4242421111 },
  "interface": { "id": 34, "node_id": "edge1", "name": "wg-peer1", "kind": "wireguard" },
  "bgp_session": { "id": 56, "node_id": "edge1", "name": "as4242421111", "remote_asn": 4242421111 },
  "generation": 5
}

PeeringFullInPUT .../peerings/full):{ peering: PeeringIn, interfaces: [{spec, enabled, sort_order}], bgp_sessions: [{spec, sort_order}] };返回 { peering: <full>, generation }

聚合根语义见 Control Server 内部,操作步骤见 建立互联


接口(WgInterface)

源码:apps/control-server/app/api/v1/admin/interfaces.py。写端点接受完整 InterfaceSpecspec 字段),先 schema 校验,写完物化并广播。

方法路径说明
GET/nodes/{node_id}/interfaces列出节点接口(按 sort_order, id
POST/nodes/{node_id}/interfaces创建接口并物化广播,201;同节点重名 409
GET/interfaces/{iface_id}取单接口
PATCH/interfaces/{iface_id}局部更新(spec / enabled / sort_order / peering_id),物化广播
DELETE/interfaces/{iface_id}删除并物化广播,204

InterfaceInspecInterfaceSpec dump);可选 peering_id / enabled(默认 true)/ sort_order(默认 0)。InterfacePatch 各字段可选,并有 clear_peering(显式把 peering_idnull)。InterfaceOut{ id, node_id, peering_id, name, kind, enabled, sort_order, spec }

WireGuard 端口策略:启用的 WG 接口若设 listen_port,须落在端口池对应段位内(越界 422),且不得与同节点其他启用 WG 接口端口冲突(409)。段位划分见 fleet 供给收权


BGP 会话(BgpSession)

源码:apps/control-server/app/api/v1/admin/bgp_sessions.py。写端点接受完整 BgpSessionSpecspec 字段),校验后物化广播。

方法路径说明
GET/nodes/{node_id}/bgp-sessions列出节点会话(按 sort_order, id
POST/nodes/{node_id}/bgp-sessions创建会话并物化广播,201;同节点重名 409
GET/bgp-sessions/{session_id}取单会话
PATCH/bgp-sessions/{session_id}局部更新(spec / sort_order / peering_id),物化广播
DELETE/bgp-sessions/{session_id}删除并物化广播,204

SessionInspecBgpSessionSpec dump);可选 peering_id / sort_orderSessionPatch 各字段可选 + clear_peeringSessionOut{ id, node_id, peering_id, name, remote_asn, enabled, sort_order, spec }

iBGP / OSPF 内部互联不是 bgp_sessions 记录,由 bird.internal_topology 合成。视图见控制台 BFF 的 GET /ui/nodes/{node_id}/internal-topology,语义见 内部互联


DNS 组(DnsGroup / Zone / Record)

源码:apps/control-server/app/api/v1/admin/dns_groups.py。三级模型:DnsGroupname + bind_addresses)→ DnsGroupZone(组声明的权威 zone + 可选 SOA)→ DnsRecord(扁平 name / type / content)。节点经 Node.dns_group_id 订阅;多节点订阅同组即构成 anycast。组、zone、记录的写入会重新物化该组全部成员节点并广播。

方法路径说明
GET/dns-groups列出 DNS 组
POST/dns-groups创建组,201;重名 409
GET/dns-groups/{group_id}取单组(含 zone_count / member_count
PATCH/dns-groups/{group_id}更新组,重物化成员并广播
DELETE/dns-groups/{group_id}删除组(成员节点 dns_group_idnull),重物化广播,204
GET/dns-groups/{group_id}/zones列出组的权威 zone
POST/dns-groups/{group_id}/zones创建 zone,201;组内重名 409
PATCH/dns-groups/{group_id}/zones/{zone_id}更新 zone
DELETE/dns-groups/{group_id}/zones/{zone_id}删除 zone,200 返回最新组摘要(zone_count 已更新)
GET/dns-groups/{group_id}/zones/{zone_id}/records列出 zone 记录(按 sort_order, id
POST/dns-groups/{group_id}/zones/{zone_id}/records创建记录,201
PATCH/dns-groups/{group_id}/zones/{zone_id}/records/{record_id}更新记录
DELETE/dns-groups/{group_id}/zones/{zone_id}/records/{record_id}删除记录,200 返回最新 zone 摘要(record_count 已更新)
PUT/nodes/{node_id}/dns-group给节点分配或取消 DNS 组,重物化广播

DnsGroupInnamebind_addresses[]cache_ttl_seconds(默认 300)、forwards[]enabled(默认 true)。 DnsZoneInzone(合法域名,放行 RFC 2317 的 allow_slash);可选 primary_ns / admin_email / soa_* / default_ttl / enabledDnsRecordInnametypecontent;可选 ttl / comment / enabled / sort_orderPUT /nodes/{id}/dns-group 请求 { "dns_group_id": <int|null> }

操作步骤见 DNS 与任播


Agent Token

源码:apps/control-server/app/api/v1/admin/tokens.py

方法路径说明
GET/nodes/{node_id}/agent-tokens列出该节点全部 token 元信息(不含 secret)
POST/nodes/{node_id}/agent-tokens签发新 token,201,响应一次性返回 secret
POST/agent-tokens/{token_id}/rotate轮换:撤旧签新,201,返回新 secret
DELETE/agent-tokens/{token_id}撤销 token,204

AgentTokenIssueIn(请求体可省略):可选 token(自定义字面量,须 base64url 且满足最小熵,否则 400)/ agent_id / ttl_seconds(≥1)。AgentTokenRotateIn:可选 ttl_seconds。响应 AgentTokenOut{ token, secret?, node_id, agent_id, issued_at, expires_at?, revoked_at? }——签发与轮换时 token 字段即完整 secret,仅此一次。

json
// POST /control/v1/admin/nodes/edge1/agent-tokens
{ "ttl_seconds": 2592000 }
json
// 201 Created(token 字段即完整 secret,仅此一次)
{
  "token": "agt_ab12cd.s3cr3tVALUEonlyShownOnce",
  "secret": "agt_ab12cd.s3cr3tVALUEonlyShownOnce",
  "node_id": "edge1",
  "agent_id": "agent-edge1",
  "issued_at": "2026-08-10T00:00:00Z",
  "expires_at": "2026-09-09T00:00:00Z",
  "revoked_at": null
}

Enrollment Token

源码:apps/control-server/app/api/v1/admin/enrollment_tokens.py

方法路径说明
GET/enrollment-tokens列出全部门票元信息(不含 secret)
POST/enrollment-tokens创建门票,201,响应一次性返回 secret
DELETE/enrollment-tokens/{token_id}删除门票,204

EnrollmentTokenIn(可省略):可选 token(字面量,须 base64url 且满足最小熵,已存在 409)/ node_id(绑定到该节点,未知 400)/ description / expires_at。响应 EnrollmentTokenCreated{ token_id, node_id?, description?, expires_at?, used_at?, created_at, secret }secret 仅创建可见)。


注册审批(Registrations)

源码:apps/control-server/app/api/v1/admin/registrations.py。审批只是门禁名单:approve 不自动 provision、不自动发 token,真正下发仍需 POST /admin/provision 或逐资源 CRUD。

方法路径说明
GET/registrations?status=列出注册请求;statuspending / approved / rejected,缺省全部
POST/registrations/{registration_id}/approve标记 approved;未知 404
POST/registrations/{registration_id}/reject标记 rejected;未知 404

请求体 RegistrationDecisionIn(可省略):{ "note": "..." }GET 返回 { "registrations": [...] },审批端点返回该注册记录。


Provision

源码:apps/control-server/app/api/v1/admin/provision.py。接受一份完整 DesiredState,一次性落库并发布为新一代;同 node_id 重复 provision 幂等覆盖,不报 409

方法路径说明
POST/provision整节点 provision,201;DesiredState 校验失败 422

ProvisionIn{ "state": <完整 DesiredState 的 JSON dump>, "agent_token": "<可选固定 token>" }ProvisionOut{ node_id, generation, subscribers, delivered }

json
// POST /control/v1/admin/provision
{
  "state": {
    "node": { "node_id": "edge1", "asn": 4242420000, "router_id": "172.20.0.1" },
    "interfaces": [],
    "bgp_sessions": [],
    "bird": { "internal_topology": { "routers": [], "hosts": [] } }
  },
  "agent_token": "agt_fixed.bootstrapTokenForLabUse"
}

state 的完整字段见 DesiredState 参考


健康(只读)

源码:apps/control-server/app/api/v1/admin/health.py。数据来自 NodeStatusStore(agent 上报)。

方法路径说明
GET/health全 fleet 健康概览({ summary, nodes }
GET/nodes/{node_id}/health单节点健康 + 最近 snapshot / report / apply;无上报 404
GET/nodes/{node_id}/status-events?kind=&limit=上报历史;kindsnapshot / report / apply / reresolvelimit 1..500(默认 50)

健康五态的判定顺序见 Control Server 内部


路由(只读)

源码:apps/control-server/app/api/v1/admin/routing.py。数据来自 RoutingStore(agent 周期上报的路由全表观测聚合)。控制台侧另有面向界面的聚合端点,见 控制台 BFF

方法路径说明
GET/routing/fleet全 fleet 路由概览
GET/nodes/{node_id}/routing/summary全表规模 + RPKI / 前缀长度 / AS path 分布;无上报 404
GET/nodes/{node_id}/routing/origins?limit=起源 AS Top 榜(limit 1..1000,默认 50)
GET/nodes/{node_id}/routing/prefixes?…分页与过滤的路由检索
GET/nodes/{node_id}/routing/timeline?limit=路由表趋势与 churn(limit 1..500,默认 200)

prefixes 查询参数:

参数取值语义
family4 / 6地址族,缺省全部
scopeall / local / external默认 all
q≤128 字符文本过滤,匹配前缀 / 协议 / 起源 ASN
rpkivalid / invalid / not_found按校验状态过滤
path_asnASN匹配 as_path 包含该 AS 的路由(含起源跳)。带边界匹配,不做十进制子串伪匹配;与 q 独立可组合
limit1..1000默认 100
offset≥0默认 0

响应顶层另有两个派生字段:

  • rpki_counts——rpki 自身外当前条件下的三态分布,驱动前端 RPKI 计数徽标。快照 rpki_observed=false 时为 null,前端隐藏该组件。
  • as_names——当前页出现的 origin ASN → registry as-name 映射(键为 ASN 字符串,未收录省略,未接 registry 服务时恒空)。

审计(只读)

源码:apps/control-server/app/api/v1/admin/audit.py

方法路径说明
GET/audit-log?limit=按时间倒序返回最近管理写操作(limit 1..1000,默认 100),返回 { "entries": [...] }

控制台侧有支持游标翻页与文本过滤的同源端点,见 控制台 BFF


Agent 发布

源码:apps/control-server/app/api/v1/admin/agent_releases.py。agent 自更新的管理侧:把 wheel 产物上传到控制面(落发布存储目录并生成含 sha256 的 manifest),再设全局目标版本。agent 经 WS 心跳上报版本,低于目标即被推送一次 agent_update_available;分发端点见 节点面 API

方法路径说明
POST/agent-releases上传一个版本的全部 wheel(multipart:version 字段 + 多个 files),201;同版本重复上传覆盖,上传不自动放量
POST/agent-releases/target设全局目标版本;版本未上传(无 manifest)422
GET/agent-releases已上传版本 + 当前目标 + 各节点 agent 版本一览

版本号限定 x.y.z、文件名限定 *.whl,二者过严格白名单(防路径穿越),非法 422

上传响应 AgentReleaseManifest{ version, wheels: [{ filename, sha256, size }] }/target 请求 { "version": "1.0.312" }/targetGET 均返回 ReleasesStatusOut{ target, versions[], nodes[] }——nodes 来自 WS 心跳注册表({ node_id, agent_version, applied_generation, apply_status, last_seen, up_to_date, liveness })。up_to_date 表示该节点版本是否等于当前目标;liveness 是服务端按心跳新鲜度分出的 online / stale / offline,阈值与 /ui/nodes 行同源,并经 /ui/session 下发给前端。

滚动升级步骤见 Agent 滚动升级


flap 处置(mitigations)

源码:apps/control-server/app/api/v1/admin/mitigations.py。从 flap 事件一键发起的人工处置——检测层只告警,不做任何自动抑制。处置声明式、可撤销、全程审计。

方法路径说明
GET/fleet/mitigations当前生效处置汇总:拒收前缀(按前缀归组列节点)+ 全部 disabled 会话(撤销入口的数据源)
POST/fleet/mitigations/prefix-block拒收前缀:写进目标节点 base_template.bird.blocked_prefixes,eBGP 导入过滤器精确匹配 reject。{prefix, nodes?},缺省 fleet 级(全部未退役节点);幂等;非法前缀 422、未知节点 404、退役节点 409
POST/fleet/mitigations/prefix-unblock撤销前缀拒收(缺省清全部节点),幂等
POST/fleet/mitigations/peer-disable断开对端:{asn} 的全部 eBGP 会话置 disabled(BIRD 摘协议,WG 隧道保留),幂等;无该 AS 会话 404
POST/fleet/mitigations/peer-enable恢复对端全部会话,幂等

blocked_prefixes 需要 fleet agent ≥ 1.0.187:更早的 agent 对 DesiredState 的严格校验不识别该字段。

一线操作与判例见 抖动处置,算法见 flap 检测


自动对等供给策略

源码:apps/control-server/app/api/v1/admin/autopeering.py。自动对等免审批——运维控制点是供给策略;已落成对等的处置(禁用、拆除)走上面的标准 Peering 管理 API。

方法路径说明
GET/autopeering/nodes全部节点策略 + 已落成数
PUT/autopeering/nodes/{node_id}upsert 供给策略(全量替换,未给的字段回默认)
PATCH/autopeering/nodes/{node_id}部分更新,只改给出的字段
GET/autopeering/requests?status=请求清单(审计与排障视图,可按状态过滤)

策略字段:{ enabled, endpoint_host?, max_sessions?, notes?, region?, country_code?, description?, bandwidth_mbps?, monthly_quota_gb?, notice? }

  • enabled=false 后该节点从用户面清单消失、新提交 404已落成的对等不受影响。前端的「该节点是否接受自动对等」开关用 PATCH {"enabled": false} 即可。
  • max_sessions 是可选的额外人工上限;null 表示只受端口池约束,可用连接数由 free_ports 派生。
  • endpoint_hostnull 的节点只能由本网向外拨出(NAT 后或跨境专线形态),不出现在可拨入清单里。
  • 其余字段是用户面列表页的展示元数据。

运维视角见 自动对等运维


fleet 收权运维

源码:apps/control-server/app/api/v1/admin/fleet.py。密钥与端口收权批留下的 fleet 级运维端点。两个迁移端点幂等、支持 dry_run;非 dry-run 时对全部受影响节点在同一事务内 materialize,提交后逐节点广播。

方法路径说明
GET/fleet/port-pools端口池视图:{ "pools": { "<scope>": { "start": …, "end": … } } }。现役段位为 external 51800–51830、internal 52800–52820
POST/fleet/normalize-peer-names存量对等命名规范化 + 空壳 Peering 清理(需 registry 副本可用)
POST/fleet/migrate-keys存量密钥统一:典范钥入库,spec 改由 materializer 无条件注入
POST/fleet/migrate-internal-ports内部链路端口换段 + base_template 内残留段位清除

三个 POST 的请求体为 { "dry_run": true }(默认 true)。响应:{ dry_run, changed_nodes[], generations{node: generation}, details }

执行顺序:先对各节点跑 peering backfill 修 orphan 关联 → migrate-keysmigrate-internal-ports。设计见 fleet 供给收权