外观
API 参考
本文档面向调用控制面的人:管理员 / 自动化脚本(Admin API)、Web UI(UI BFF API)、节点 Agent(Agent HTTP / WebSocket)与匿名消费方(公开 API)。权威实现是主控制面(TS 面,apps/control-server-ts/src/routes/,Cloudflare Workers);温备控制面(Python 面,apps/control-server/app/api/v1/,FastAPI)与其保持同一 HTTP 契约。每个 HTTP 路由与 WebSocket 端点的方法、路径、请求体、响应体、鉴权与状态码都来自源码,不含未实现的端点。
基础路径与生产入口:除
/healthz外所有业务路由都挂在/api/v1前缀下。主控制面装配层为 Hono app + 逐前缀路由挂载(apps/control-server-ts/src/index.ts:22-61),生产入口https://api.natlan.io;Python 温备面装配在apps/control-server/app/main.py:166,入口https://legacy.api.natlan.io。鉴权模型(TS
apps/control-server-ts/src/auth.ts;Pythonapps/control-server/app/api/deps.py):
- Admin:
Authorization: Bearer <凭据>,两种凭据在require_admin单入口无差别接受——静态 admin token(TS 面 wrangler secretADMIN_TOKEN,Python 面DN42_CONTROL_ADMIN_TOKEN;机器 / 自动化)与账号登录(POST /api/v1/auth/login)签发的会话令牌。整个/api/v1/admin与/api/v1/ui前缀挂require_admin;未配置 admin token 时 fail-closed,整体返回403(含账号登录);Bearer 缺失 / 错误 / 会话过期或被吊销返回401。- Agent:
Authorization: Bearer <agent token>。token 绑定到一个node_id,agent 只能读写自己节点;payload 中node_id与 token 绑定节点不一致返回403。token 解析失败返回401。- 注册端点(
POST /api/v1/agent/register):不带 Bearer,改在请求体里携带 enrollment token(全局 bootstrap token 或表内一次性 token)。
参见:配置 · DesiredState · 安全模型。
概览 / 鉴权
| 分组 | 前缀 | 调用方 | 鉴权 |
|---|---|---|---|
| 账号登录 API | /api/v1/auth/... | Web UI 登录页 | 无 Bearer(登录本身),/auth/password 需账号会话令牌 |
| Admin API | /api/v1/admin/... | 管理员 / 自动化脚本 | Admin Bearer(静态 token 或账号会话令牌),未配置静态 token 时整体 403 |
| UI BFF API | /api/v1/ui/... | Web UI | Admin Bearer(同上,两种凭据均可) |
| Agent HTTP API | /api/v1/agent/... | Node Agent | Agent Bearer(/register 除外) |
| Agent WebSocket API | /api/v1/agent/ws/{node_id}、/api/v1/agent/probe-stream/{probe_id} | Node Agent | Agent Bearer(握手头) |
| 公开 API | /api/v1/public/... | 匿名(natlan.io 首页等) | 无 |
| 健康探针 | /healthz、/api/v1/healthz | 负载均衡 / systemd | 无 |
OpenAPI:主控制面(TS 面)未挂载 OpenAPI 文档——无 /docs 与 /openapi.json(Hono 装配层不自动生成);Python 温备面保留 FastAPI 自动文档(/docs / /openapi.json)。
Token 形态(详见 安全模型):
- Agent token 自动签发为
<token_id>.<secret>(token_id形如agt_xxxxxx);DB 只存 SHA-256 哈希,完整 secret 只在签发 / 轮换响应中出现一次。可设过期(expires_at),过期后解析返回401。 - Enrollment token 同模型:DB 存哈希,明文 secret 仅创建响应可见;列表 / 删除以非机密
token_id(ent_*)为键。 - 全局 bootstrap enrollment token 来自配置(TS 面 wrangler secret
ENROLLMENT_TOKEN,Python 面DN42_CONTROL_ENROLLMENT_TOKEN;不绑定节点、可重复使用;未配置即关闭)。 - 账号会话令牌由
POST /api/v1/auth/login签发:32 字节熵的不透明随机串,DB 只存 SHA-256 哈希;TTL 默认 24h(TS 面 varSESSION_TTL_SECONDS,Python 面DN42_CONTROL_SESSION_TTL_SECONDS),改密时该用户全部存活会话立即吊销。
审计:所有 /api/v1/admin/* 的写请求(POST / PUT / PATCH / DELETE,含鉴权失败的尝试)由审计中间件记入审计日志——TS 面 auditMiddleware 挂在 admin 路由最前、先于 requireAdmin 执行(await next() 包住鉴权,从而捕获失败尝试的状态码,apps/control-server-ts/src/routes/admin/index.ts:27);Python 温备面为 audit_admin_writes(apps/control-server/app/main.py:172)。
通用错误约定:
400引用了不存在的关联资源(如 peering 的remote_node_id)。404路径上的资源不存在。409唯一约束冲突(重名 / 已存在),或语义冲突(活节点直删、WG 端口占用、notify 时无在线 agent)。422schema 校验失败(InterfaceSpec/BgpSessionSpec/DesiredState/ DNS 配置等),响应体形如{"detail": {"message": "...", "errors": [...]}}。
账号登录 API(/api/v1/auth)
源码:apps/control-server-ts/src/routes/auth.ts(Python 温备:apps/control-server/app/api/v1/auth.py)。管理员账号(用户名 + 密码 + Cloudflare Turnstile)登录,替代人手分发共享 admin token;静态 token 保持原样可用。错误 detail 措辞是与前端锁死的稳定契约。
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
POST | /api/v1/auth/login | 无 | 请求体 {username, password, turnstile_token}。成功 200 返回 {token, expires_at}(不透明会话令牌 + ISO 8601 过期时间)。 |
POST | /api/v1/auth/password | 账号会话令牌 | 请求体 {old_password, new_password}(新密码 ≥8 字符)。成功 204 并吊销该用户全部存活会话(含当前,前端 401 全局登出后重登);静态 admin token 调用返回 403。 |
/auth/login 错误语义(前端按状态码 + detail 分支):
| 状态码 | detail(稳定字符串) | 含义 |
|---|---|---|
400 | turnstile verification failed | Turnstile 缺失 / 伪造 / 复用(token 一次性,服务端调 siteverify 硬校验;secret 未配置同样 400,fail-closed) |
401 | invalid credentials | 用户不存在与密码错误统一文案(防枚举;服务端对不存在用户也跑一次 argon2 对照校验,抹平时间侧信道) |
403 | 同 admin API | 未配置静态 admin token(TS 面 ADMIN_TOKEN / Python 面 DN42_CONTROL_ADMIN_TOKEN),admin 面整体锁定 |
429 | too many attempts | 同用户名或同 IP(CF-Connecting-IP → X-Forwarded-For → 直连地址)连续凭据失败 5 次后的 15 分钟封禁窗口;登录成功清零计数 |
登录成功 / 失败 / 限速 / 改密全部写入 admin 审计(path 为 /api/v1/auth/login、/api/v1/auth/password;成功记 actor=用户名,失败记尝试的用户名 + IP;密码明文绝不入日志)。
Admin API
所有 Admin 路由前缀为 /api/v1/admin,下表「路径」列省略该前缀。所有路由需 Admin Bearer。
节点(Node)
源码:apps/control-server-ts/src/routes/admin/nodes.ts(Python 温备:apps/control-server/app/api/v1/admin/nodes.py)
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /nodes | 列出所有节点(按 node_id 排序) |
POST | /nodes | 创建节点,201;node_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(JSON);无则 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 |
NodeIn 字段:node_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(节点 WG 身份公钥,agent 上报的权威身份,对端互联时交给对方;未上报为 null)/ created_at / updated_at。
link_local 是外部 eBGP LLA 的单一真相源(materializer 由它派生外部 WG 接口地址):取值须为 fe80::/10 内、不带 %zone 后缀的 IPv6 link-local 地址,非法值 422(校验 TS admin/nodes.ts:25-33;NodeIn / NodePatch / NodeOut 均含该字段,admin/nodes.ts:43,59,76)。
创建节点示例:
json
// POST /api/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,
"created_at": "2026-06-22T00:00:00Z",
"updated_at": "2026-06-22T00: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-ts/src/routes/admin/peerings.ts(Python 温备:apps/control-server/app/api/v1/admin/peerings.py)。Peering 是对等关系的元信息(不直接进入 DesiredState);普通 CRUD 不触发 materialize,provision / full 才会一并物化子资源。
| 方法 | 路径 | 说明 |
|---|---|---|
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 |
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 仅返回计划,不物化 / 不广播) |
PeeringIn:name(1..64)、remote_asn(≥1),可选 remote_node_id / remote_label / is_internal(默认 false)/ enabled(默认 true)/ notes。GET /nodes/{node_id}/peerings 的行额外带 remote_as_name(registry 副本的 as-name,与路由表起源名称同源;未配 registry token / 未收录为 null)。
provision 一键端点请求 PeeringProvisionIn:peering(PeeringIn)+ interface_spec(InterfaceSpec 的 JSON dump,见 DesiredState)+ interface_enabled / interface_sort_order,可选 bgp_spec(BgpSessionSpec dump)+ bgp_sort_order。三段先各自 schema 校验(失败 422),任一唯一约束冲突 409 整笔回滚。
json
// POST /api/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
}PUT .../peerings/full 请求 PeeringFullIn:{ peering: PeeringIn, interfaces: [{spec, enabled, sort_order}], bgp_sessions: [{spec, sort_order}] };返回 { peering: <full>, generation }。
接口(WgInterface)
源码:apps/control-server-ts/src/routes/admin/interfaces.ts(Python 温备:apps/control-server/app/api/v1/admin/interfaces.py)。写端点接受完整 InterfaceSpec(spec 字段),先 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 |
InterfaceIn:spec(InterfaceSpec dump)、可选 peering_id、enabled(默认 true)、sort_order(默认 0)。InterfacePatch 各字段可选,并有 clear_peering(显式把 peering_id 置 null)。InterfaceOut:{ id, node_id, peering_id, name, kind, enabled, sort_order, spec }。
WireGuard 端口策略:启用的 WG 接口若设 listen_port,须落在节点 base_template.runtime.wireguard_port_range 内(越界 422),且不得与同节点其他启用 WG 接口端口冲突(409)。
BGP 会话(BgpSession)
源码:apps/control-server-ts/src/routes/admin/bgpSessions.ts(Python 温备:apps/control-server/app/api/v1/admin/bgp_sessions.py)。写端点接受完整 BgpSessionSpec(spec 字段),校验后物化广播。
| 方法 | 路径 | 说明 |
|---|---|---|
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 |
SessionIn:spec(BgpSessionSpec dump)、可选 peering_id、sort_order。SessionPatch 各字段可选 + clear_peering。SessionOut:{ id, node_id, peering_id, name, remote_asn, enabled, sort_order, spec }。
注意:iBGP / OSPF 内部互联不是
bgp_sessions记录,由bird.internal_topology自动合成,视图见 UI BFF 的GET /ui/nodes/{node_id}/internal-topology。
DNS 组(DnsGroup / Zone / Record)
源码:apps/control-server-ts/src/routes/admin/dnsGroups.ts(Python 温备:apps/control-server/app/api/v1/admin/dns_groups.py)。三级模型:DnsGroup(name + 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_id 置 null),重物化广播,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 组(dns_group_id 可为 null),重物化广播 |
DnsGroupIn:name、bind_addresses[]、cache_ttl_seconds(默认 300)、forwards[]、enabled(默认 true)。DnsZoneIn:zone(合法域名,放行 RFC 2317 allow_slash)、可选 primary_ns / admin_email / soa_* / default_ttl / enabled。DnsRecordIn:name、type、content、可选 ttl / comment / enabled / sort_order。PUT /nodes/{id}/dns-group 请求 { "dns_group_id": <int|null> }。
Token(Agent Token)
源码:apps/control-server-ts/src/routes/admin/tokens.ts(Python 温备: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 /api/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-06-22T00:00:00Z",
"expires_at": "2026-07-22T00:00:00Z",
"revoked_at": null
}Enrollment Token
源码:apps/control-server-ts/src/routes/admin/enrollmentTokens.ts(Python 温备: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-ts/src/routes/admin/registrations.ts(Python 温备:apps/control-server/app/api/v1/admin/registrations.py)。审批只是「门禁名单」,approve 不自动 provision / 发 token——真正下发仍需 POST /admin/provision 或逐资源 CRUD。
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /registrations?status= | 列出注册请求;status ∈ pending/approved/rejected,缺省全部 |
POST | /registrations/{registration_id}/approve | 标记 approved;未知 404 |
POST | /registrations/{registration_id}/reject | 标记 rejected;未知 404 |
请求体 RegistrationDecisionIn(可省略):{ "note": "..." }。GET 返回 { "registrations": [...] },审批端点返回该注册记录的 dict。
Provision
源码:apps/control-server-ts/src/routes/admin/provision.ts(Python 温备: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 /api/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"
}json
// 201 Created
{ "node_id": "edge1", "generation": 1, "subscribers": 0, "delivered": 0 }(state 的完整字段见 DesiredState。)
健康(Health,只读)
源码:apps/control-server-ts/src/routes/admin/health.ts(Python 温备: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= | 上报历史;kind ∈ snapshot/report/apply/reresolve,limit 1..500(默认 50) |
路由(Routing,只读)
源码:apps/control-server-ts/src/routes/admin/routing.ts(Python 温备:apps/control-server/app/api/v1/admin/routing.py)。数据来自 RoutingStore(agent 周期上报的 RoutingTableSnapshot 聚合)。
| 方法 | 路径 | 说明 |
|---|---|---|
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?family=&scope=&q=&rpki=&path_asn=&limit=&offset= | 分页 / 过滤路由检索 |
GET | /nodes/{node_id}/routing/timeline?limit= | 路由表趋势 + churn(limit 1..500,默认 200) |
prefixes 查询参数:family ∈ 4/6(缺省全部);scope ∈ all/local/external(默认 all);q 文本过滤(≤128,匹配前缀 / 协议 / 起源 ASN);rpki ∈ valid/invalid/not_found(按校验状态过滤);path_asn 匹配 as_path 包含该 AS 的路由(含起源跳,带边界匹配、不做十进制子串伪匹配,与 q 独立可组合);limit 1..1000(默认 100);offset ≥0(默认 0)。
prefixes 响应顶层另有:rpki_counts——除 rpki 自身外当前条件下的三态分布(驱动前端 RPKI chips 计数徽标;快照 rpki_observed=false 时为 null,前端隐藏 chips);as_names——当前页 routes 出现的 origin ASN → registry as-name 映射(键为 ASN 字符串,未收录省略,未配 registry token 恒空)。
流量(Traffic,只读;仅 TS 面)
源码:apps/control-server-ts/src/routes/admin/traffic.ts。TS 面独有端点,Python 温备面没有对应实现(两面都有的吞吐视图是 UI BFF 的 GET /ui/nodes/{node_id}/traffic,那是蒸馏后的时间线;本端点直出存档行)。
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /nodes/{node_id}/traffic | 单节点 WG 流量 5min 存档时间线,返回 { node_id, series }(TS admin/traffic.ts:9) |
审计(Audit,只读)
源码:apps/control-server-ts/src/routes/admin/audit.ts(Python 温备:apps/control-server/app/api/v1/admin/audit.py)。
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /audit-log?limit= | 按时间倒序返回最近 Admin 写操作(limit 1..1000,默认 100);返回 { "entries": [...] } |
Agent 发布(Agent Releases)
源码:apps/control-server-ts/src/routes/admin/agentReleases.ts(Python 温备:apps/control-server/app/api/v1/admin/agent_releases.py)。agent 自更新的管理侧:把 wheel 产物上传到控制面(落发布存储——TS 面为 R2 桶、Python 面为持久卷目录——并生成含 sha256 的 manifest),再设全局目标版本;agent 经 WS 心跳上报版本,低于目标即被推送 agent_update_available(分发端点见 Agent HTTP API 一节的 /agent/update/*)。
| 方法 | 路径 | 说明 |
|---|---|---|
POST | /agent-releases | 上传一个版本的全部 wheel(multipart:version 字段 + 多个 files),201;同版本重复上传覆盖,上传不自动放量 |
POST | /agent-releases/target | 设全局目标版本;版本未上传(无 manifest)422 |
GET | /agent-releases | 已上传版本 + 当前目标 + 各节点 agent 版本一览 |
flap 处置(mitigations)
源码:apps/control-server-ts/src/routes/admin/mitigations.ts(Python 温备:apps/control-server/app/api/v1/admin/mitigations.py)。从 flap 事件一键发起的人工处置(检测层只告警不自动抑制);声明式、可撤销、走审计。⚠️ blocked_prefixes 需 fleet agent ≥1.0.187(旧 agent 的 DesiredState 严格校验不识别新字段)。
| 方法 | 路径 | 说明 |
|---|---|---|
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 | 恢复对端全部会话,幂等 |
版本号限定 x.y.z、文件名限定 *.whl,二者过严格白名单(防路径穿越),非法 422。上传响应 AgentReleaseManifest:{ version, wheels: [{ filename, sha256, size }] }。/target 请求 { "version": "1.0.158" };/target 与 GET 均返回 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 下发)。
UI BFF API(/api/v1/ui)
源码:apps/control-server-ts/src/routes/ui.ts(flap 榜单族拆分在 uiFlaps.ts、节点目录 / 指标矩阵在 uiNodes.ts、registry 副本在 uiRegistry.ts;Python 温备:apps/control-server/app/api/v1/ui/),前缀 /api/v1/ui(下表省略)。Web UI 专用 BFF 端点:为某个界面「一次取全 / 服务端派生」而存在的只读聚合视图与少量写辅助,取代浏览器侧多次拉取 + 扒 last_snapshot + 客户端算差分;通用资源接口仍在 /admin 下。整个 /ui 前缀挂 require_admin,与 Admin API 同一把 admin token(未配置时同样 fail-closed 403)。接口面向前端消费、字段较宽,本节每个端点只给一行语义;错误 detail 文案是稳定接口(前端按字面展示 / 匹配,改动视为破坏性变更)。
趋势类端点共享 range / compare 语义:range ∈ 6h/24h/7d/30d,序列改为定长分桶网格(点数 ≤200,空桶留 null);compare=1 附带紧邻上一窗口的对比序列(同粒度同桶数,按索引对齐)。
观测(observability)
源码:apps/control-server-ts/src/routes/ui.ts(flap 榜单族:apps/control-server-ts/src/routes/uiFlaps.ts;Python 温备:apps/control-server/app/api/v1/ui/observability.py)
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /fleet/overview | fleet 健康 + 每节点能力 / agent 活体 / geo + 物理 WG 邻接网格,总览页单次拉取 |
GET | /nodes/{node_id}/traffic | 单节点 WG 吞吐时间线(字节/秒);支持 range / compare |
GET | /fleet/traffic | 全 fleet 吞吐时间线(各节点按时间桶对齐求和);支持 range / compare |
GET | /fleet/traffic-breakdown | 按节点 / 按 WG 对端的当前吞吐排行(peers_limit 1..200 默认 25,breakdown_top 截断节点榜,缺省全量) |
GET | /fleet/peering-issues | 全机群有问题的 BGP 会话:非 Established 或抖动中(flapping),按严重度排序(抖动最前、down 次之、外部会话优先) |
GET | /fleet/bgp-flaps | 全机群会话 flap 榜(指数衰减打分降序;min_score 默认 0.05,limit 1..500 默认 100) |
GET | /fleet/prefix-flaps | fleet 前缀级 flap 榜:按 (prefix, 归因对端 AS) 跨节点归组,max_score 降序,每行附 peer_asn(AS_PATH 第一跳)+ origin_asn(最后一跳=前缀起源)及各自 registry *_name、rate_per_s(当前变化速率)与 flapping_since / flap_duration_s(抖动持续时长),附各节点 flapfeed 喂送健康度(min_score 默认 1.0,limit 1..500 默认 100)。?group=prefix 改返回前缀优先层级(服务端按前缀折叠:顶层聚合 + 内嵌 peers[] + origins[]/moas 多源判定,limit 作用于前缀数)——可选形状,不影响默认扁平消费 |
GET | /fleet/prefix-flaps/localize | 全部抖动前缀的 V2 根因区定位独立端点:返回 { localizations: [...] },每行 zone ∈ near(入向对端)/origin(源头 AS)/path(中段邻接)/moas(多源冲突)+ confidence(按其降序)+ 归因 AS / 邻接边;与 ?group=prefix 内嵌的 localization 同源同算法(TS uiFlaps.ts:239,Python observability.py:630) |
GET | /fleet/flap-stats | fleet 级 flap 活动时序 + 瞬时速率仪表:series[] 定长网格(变化速率两条线 + 活跃前缀数 + 在开告警数,空桶 null),gauge 是 90 分位截尾均值的当前变化速率(次/秒)。range ∈ 1h(60s 桶)/6h/24h/7d/30d 默认 1h |
GET | /fleet/flap-peers | 「哪家对端最抖」榜:会话级 + 前缀级按对端 AS 收拢,severity(阈值归一,≥1 = 至少一层过阈)降序(limit 1..200 默认 50) |
GET | /fleet/flap-peers/{asn} | 单对端 flap 详情(Peer ASN lookup):榜行聚合 + 该方向前缀行(与 fleet 榜同形状,prefixes_limit 1..200 默认 50)+ 告警事件含已消警历史(alerts_limit 1..100 默认 20);该 AS 完全无 flap 痕迹 404 |
GET | /fleet/flap-alerts | flap 告警事件流(边沿触发:上穿告警线开、下穿消警线关;前缀级告警线 100 与榜单 flapping 线 20 分层),新→旧、before_id 游标分页;active=1 只看在开(limit 1..200 默认 50) |
GET | /nodes/{node_id}/links | 单节点链路状态(服务端按握手新鲜度判 up/stale/down);无上报 404 |
GET | /nodes/{node_id}/bgp-sessions/status | 单节点内 iBGP + 外 eBGP 综合状态(内外按 DesiredState 配置归类),行带 flapping/flap_score;无上报 404 |
GET | /nodes/{node_id}/bgp-flaps | 单节点会话 flap 详情 + 最近状态转移历史(transitions_limit 1..200 默认 50);无上报 404 |
GET | /nodes/{node_id}/overview | 节点页一次取全(健康行 + 能力 + 自观测 + drift + 链路 / BGP + 完整 node 记录 + DNS 组引用 + 无条件内嵌 trends 趋势三件套);404 仅表示节点不存在 |
吞吐时间线三层降级:优先 agent 30s 轻量采样(Redis 热窗口 / 5min 存档),无采样回落快照差分(~5min);带 range / compare 时改从 5min 存档蒸馏定长网格,对比序列在 points_previous。/nodes/{node_id}/overview 对存在但从未上报的节点返回 health=unknown 的可渲染骨架而非 404(内嵌 trends 同理为空 series)。trends 段是自观测 sparkline + drift 数 + apply 结果序列(升序);原独立端点 GET /nodes/{node_id}/trends 已随 N9 退役(详情页轮询与 overview 同页同源,合并省一趟往返)。
会话级 flap 检测(P0,纯控制面推导,零 agent 变更):快照入库时把 bgp_protocols 与上次观测逐会话比对——「离开 Established」与「state 前后均 Established 但 since 变化」(隐性重连,快照间隔内至少断建一轮)各计一次 flap,进指数衰减分(半衰期 30min);非 Established 状态间的重试换挡只记历史不计分。衰减分 ≥ 3 判定 flapping,会话状态行随处带 flapping/flap_score 字段。仅检测告警,不做任何自动抑制。
前缀级 flap 检测(P1,agent 旁路管线):BIRD 经 local_flapfeed.conf 注入的 passive eBGP(假 AS 65001,AddPath tx,import none 零选路影响)把全表全部路径喂给 agent 进程内的采集会话(UPDATE 用 exabgp 编解码库解析,非子进程),按 (prefix, 归因对端 AS) 计路径变化——withdraw 与 AS_PATH/next-hop 实变各计一次,同路径刷新不计,全表首灌不计——进指数衰减分(半衰期 10min),每 ~60s 上报控制面整表替换。归因 = AS_PATH 剥掉本节点 AS 前缀后的第一跳(起源 = 最后一跳)。/ui/fleet/prefix-flaps 跨节点归组即 fleet 关联视图;衰减分 ≥ 20 判 flapping。exabgp 是 agent wheel 的钉版依赖(exabgp==5.0.9),库不可导入的节点自动旁路。V2 根因区定位:agent 附带上报每条抖动前缀的 distinct AS_PATH 证据(churn + 是否在宣告,可选字段向后兼容),控制面跨节点求并跑判别性图元差集,判 near(入向对端,P0 会话门控是否可断)/ origin(源头 AS 传播,只告警)/ path(中段 AS 邻接在翻,点名 [A,B])/ moas;结果内嵌进 ?group=prefix 每行的 localization(同一次查询、同快照,以行 origins 为 moas 权威),独立端点 /ui/fleet/prefix-flaps/localize 保留他用。纯只读、只标注不动手。详见 internals/flap-detection.md。
flap 告警事件(P2):控制面背景循环(60s)对两层衰减分做边沿评估——上穿告警线开一条告警、下穿消警线(告警线一半,迟滞防乒乓)关该条,进入 flap_alerts 表(已消警历史按条数截断保留 500)。前缀级告警线(100)与榜单 flapping 线(20)分层:榜单线只作可观测标记(DN42 低烈度背景抖动是常态),只有告警级强度才开单;会话级两线同参(3.0)。事件回答「谁在什么时候抖过、峰值多少、持续多久」——这是衰减分消退后唯一的历史层。/ui/fleet/flap-peers 把两层按对端 AS 收拢成「哪家对端最抖」榜,severity 为阈值归一的综合烈度。
flap 时序与速率(flap-stats):控制面收报时对累计计数做差分累进 60s 存档桶(喂送级全量 + 榜内条目两条线,保留 60 天),告警循环顺手落 fleet 活跃度点(在跟踪 / 过阈前缀数、过阈会话数、在开告警数)——/ui/fleet/flap-stats 由此出定长时序网格与 90 分位截尾均值 gauge。同一套差分让榜行带上 rate_per_s(当前变化速率,跨节点求和、与 total_changes 同口径;无差分基准为 null)与 flapping_since / flap_duration_s(来自同键在开告警的上穿时刻;未达告警线为 null——中间带 flapping=true 但无 duration)。数据分辨率 60s(受 agent 上报节奏限制);「路由表规模随时间」不在此处——用 /ui/routing/fleet-overview 的 trend(规模中位数 + announced/withdrawn churn)。详见 internals/flap-detection.md。
GET /ui/fleet/flap-stats(feature flap-stats)响应:{ range, window_s, bucket_s, generated_at, series[], gauge }。series[] 是 [窗口起点, 最近一个完整桶] 的定长网格(只出完整桶,无部分桶),每点 { t(桶起点 epoch 秒), changes, listed_changes, rate_per_s, tracked_prefixes, flapping_prefixes, flapping_sessions, open_alerts }——前三者来自速率存档(rate_per_s = changes / bucket_s),后四者来自活跃度落点(粗桶取桶内峰值);空桶各字段为 null(「无上报」≠「0」,前端画断线而非归零)。gauge = { rate_per_s(P90 截尾均值), current_rate_per_s(最近完整 60s 桶), percentile: 90, window_s: 900 },不随 range 变化。range 与桶宽 / 点数:1h→60s(60 点)、6h→5min(72)、24h→10min(144)、7d→1h(168)、30d→4h(180)。榜行新字段(/fleet/prefix-flaps 扁平行、?group=prefix 顶层行与内嵌 peers[]、nodes[] 展开项均带):rate_per_s(节点展开项为单节点视角,行级为跨节点 / 跨方向求和)、flapping_since(ISO8601)/ flap_duration_s(服务端算好的距今秒数)——duration 只对告警级强度(分数 ≥100,告警线与榜单线分层)有值;flapping=true 但未达告警级、或刚过告警线告警循环(60s)尚未开单时为 null,前端回落显示 — 即可。
路由(routing)
源码:apps/control-server-ts/src/routes/ui.ts(Python 温备:apps/control-server/app/api/v1/ui/routing.py)。细粒度路由检索(/routing/{fleet,summary,origins,prefixes,timeline})仍在 /admin 下,供对接其他系统用。
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /nodes/{node_id}/internal-topology | iBGP + OSPF 内部互联视图(拓扑配置 + 路由层 liveness);无已发布 DesiredState 404 |
GET | /routing/fleet-overview | 概览「路由全表」板块一次取全:summary + 每节点 + 规模 / churn 趋势(origins_sort ∈ count/v4/v6,origins_top 1..200 默认 100) |
GET | /nodes/{node_id}/routing/dashboard | RoutingTab 头部一次取全:summary + origins + timeline;支持 range / compare(compare 未带 range 时按 24h);origins[].name 由 registry 副本富化(与 fleet 侧 origins-name 同源,未配 token 全 null);无路由上报 404 |
节点目录 / 事件 / 机群指标(nodes)
源码:apps/control-server-ts/src/routes/uiNodes.ts(Python 温备:apps/control-server/app/api/v1/ui/nodes.py)
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /nodes | 节点列表一次取全:Node 标量字段(不含 base_template / inventory)+ 服务端 join 的 health / agent 活体 / geo + 行富化标量(见下) |
GET | /nodes/metrics | 全 fleet 指标矩阵:共享桶网格上的 cpu / rss / 流量 / 对账失败序列(range ∈ 1h/6h/24h/7d 默认 6h,5s TTL 缓存) |
GET | /nodes/{node_id}/status-events | 瘦身事件列表(新→旧,不带 payload):kind 同 admin 端点,limit 1..500 默认 100,before_id 传上一页最后一条 id 向旧翻页 |
GET | /status-events/{event_id} | 单条完整事件(含 payload),用户展开详情时按需取;未知 404 |
GET /ui/nodes 行富化标量(feature ui-nodes-rich):drift_count(最新 report 的漂移项数,与 fleet overview 行同口径,未上报 → 0)、peers_up / peers_total(最新 runtime snapshot 的 BGP 会话 Established 数 / 总数,up 判定与 peering_issues 同源,无快照 → 均 null)、rx_bytes_per_sec / tx_bytes_per_sec(当前吞吐,与 traffic_breakdown.nodes 同源同口径,无数据 → null)。
GET /ui/nodes/metrics(feature ui-nodes-metrics)响应:顶层 timestamps[] 是全体节点共享的桶起点网格(升序),每节点的 cpu_percent[] / rss_mb[] / rx_bytes_per_sec[] / tx_bytes_per_sec[] / reconcile_failures[] 与其按索引对齐、等长;空桶 = null(缺数据是缺口不是 0),reconcile_failures 的 0 表示「有上报且无失败」。桶宽按 range 取 5min 存档的整数倍:1h→5min(12 点)、6h→10min(36)、24h→30min(48)、7d→3h(56)。cpu / rss / 失败数来自自观测 5min 存档(node_metrics_rollup,保留 60 天;失败数为 total_failures 累计计数的桶内差分),rx / tx 来自流量 5min 存档;归档不足窗口时前导桶自然为 null。全量矩阵返回,不做服务端 top-N(排名 / 站点过滤在前端)。
调优 / 向导(tuning)
源码:apps/control-server-ts/src/routes/ui.ts(Python 温备:apps/control-server/app/api/v1/ui/tuning.py)。写端点遵守与 Admin 写路径相同的事务纪律:业务变更与 materialize 同事务,提交后广播。
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /nodes/{node_id}/route-tuning | 路由调优视图:节点级 cold_potato_med / route_local_pref + 全部会话的 link_latency |
PUT | /nodes/{node_id}/route-tuning | 字段级合并写入 base_template.bird 的两个调优 key(其余部分原样保留) |
PUT | /bgp-sessions/{session_id}/link-latency | 会话 spec 单字段合并:只动 link_latency(1..9,null 清除) |
GET | /nodes/{node_id}/peer-defaults | peer 向导默认值:节点惯用 WG 私钥引用 / link_local 建议值(两面来源当前分叉,见下)/ 已占用监听端口 |
PUT .../route-tuning 请求两字段均可选(缺省不动):cold_potato_med、route_local_pref[](逐条经 RouteLocalPrefSpec 校验,失败 422)。peer-defaults 响应 { node_id, wireguard: { private_key_ref, link_local, used_listen_ports[] } },推不出的字段为 null(操作员手填)。
link_local 的取值来源两面当前不一致:Python 温备面读节点档案列 Node.link_local(外部 eBGP LLA 的单一真相源,app/api/v1/ui/tuning.py:236-246);TS 主控制面仍是接口地址扫描启发式——统计外部 peering 口(名字不匹配 wg-* / dn42-*)地址里出现次数最多的 fe80::(src/routes/ui.ts:768-779)。TS 面这一处是待对齐的已知回归,目标行为以 Node.link_local 单源为准。
Registry(DN42 注册表副本)
源码:apps/control-server-ts/src/routes/uiRegistry.ts(Python 温备:apps/control-server/app/api/v1/ui/registry.py)。控制面维护 DN42 registry 的本地只读副本(registry_asn 表,见 database.md),供 peering 向导填 ASN 时实时带出 as-name。同步需配置只读 token(TS 面 wrangler secret REGISTRY_TOKEN,Python 面 DN42_CONTROL_REGISTRY_TOKEN;见 configuration.md)。
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /registry/status | 同步状态:{ enabled, commit, asn_count, synced_at } |
POST | /registry/sync?force= | 手动触发一轮同步(force=1 跳过 sha 门控强制重拉);未配置 token 409,上游失败 502(既有数据保留),sha 未变返回 synced=false(非错误) |
GET | /registry/names?asns=… | 批量 ASN → as-name:asns 为逗号分隔 ASN 列表(去重保序,单次上限 200,超限截断),返回 { "names": { "<asn>": "<as-name>" } }(未收录的 ASN 省略);缺 asns 参数 / 含非法 token 422。TS 面 KV 缓存优先再落库查(uiRegistry.ts:46,Python ui/registry.py:85) |
GET | /registry/asn/{asn} | 单 ASN 摘要 { asn, as_name, mnt_by, updated_at };未知 404 |
仪表盘 / 会话(dashboard)
源码:apps/control-server-ts/src/routes/ui.ts(Python 温备:apps/control-server/app/api/v1/ui/dashboard.py)
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /dashboard | 仪表盘首屏一次取全:overview + traffic + traffic-breakdown + peering-issues + routing 五块合一,服务端短 TTL(3s)缓存 |
GET | /session | 鉴权探测 + 服务端元信息(版本 / agent 目标版本 / 心跳周期 / liveness 阈值 / features 能力开关 / identity 当前登录者),不查库 |
/dashboard 各分块结构与对应细粒度端点逐字段一致;range / compare 同时作用于 traffic 与 routing 趋势,origins_sort / origins_top / breakdown_top 透传对应榜单参数。?as_traffic=1 追加 AS 级流量扩展(R6/R7):响应恒含 generated_at 与 traffic_by_as / traffic_mix 两个键——未请求时二者为 null;请求时 traffic_by_as 为 Top5 对端 AS 的按时间线序列 + Top50 份额表(名称回落 registry 副本),traffic_mix 为 internal / external 与 rx / tx 的流量构成(TS src/routes/ui.ts:391-477)。/session 走到即 token 有效(401 / 403 语义与其余端点一致);features 当前含 account-login / audit-cursor / trend-range / trend-compare / as-traffic / ui-nodes-rich / ui-nodes-metrics / bgp-flaps / prefix-flaps / flap-peers / flap-alerts / flap-mitigations / flap-localize / flap-stats(另有 origins-name 在配置了 registry 同步 token 时条件下发),前端按 flag 灰度 UI。identity 为 {"username", "kind"}:账号会话令牌为 kind=user(用户名为登录账号),静态 admin token 为 kind=static-token(用户名固定 admin)。
审计(audit)
源码:apps/control-server-ts/src/routes/ui.ts(Python 温备:apps/control-server/app/api/v1/ui/audit.py)
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /audit | 审计日志一页(新→旧):limit 1..500 默认 100,before_id 游标向旧翻页,q 对 actor / method / path 做大小写不敏感子串匹配 |
行结构与 GET /admin/audit-log 一致,返回 { "entries": [...] }。
拨测(probes)
源码:apps/control-server-ts/src/routes/ui.ts(ProbeHub 在 TS 面是单例 Durable Object:apps/control-server-ts/src/probeHub.ts;Python 温备:apps/control-server/app/api/v1/ui/probes.py,ProbeHub 为进程内对象)。实时链路:浏览器 SSE ← 控制面 ProbeHub fan-out ← agent 临时 WS 回传(见 Agent WebSocket API 一节的 probe-stream)← debug-shell 执行。拨测输出不落库:作业与缓冲都在 ProbeHub 内存,完成后 TTL(120s)回收。
| 方法 | 路径 | 说明 |
|---|---|---|
POST | /probes | 发起一次拨测,202 返回 { probe_id };节点无在线 agent 409,并发超上限(全局 256 / 单节点 3)429 |
GET | /probes/{probe_id}/stream | SSE 订阅拨测输出:先补发 ring buffer 已产生消息,再实时 fan-out,收到 done 收尾;作业不存在 / 已过期 404 |
POST /probes 请求 { node_id, spec },spec 为 ProbeSpec:tool ∈ ping/mtr/traceroute、target(IP 字面量,白名单收敛在 DN42 / 私网 / link-local,非法 422)、count(1..30,默认 10)、resolve(逐跳 rDNS,默认 true)。SSE 帧为 event: output / event: done,data: 是对应 JSON 消息体。
公开 API(/api/v1/public)
源码:apps/control-server-ts/src/routes/public.ts(Python 温备:apps/control-server/app/api/v1/public.py)。无鉴权的公开只读端点,natlan.io 首页数据源。
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /api/v1/public/map | 公开世界地图数据:城市点 + 城市间链路 + fleet 三数字(节点 / 会话 / 路由)。城市级隐私裁剪——只出 IATA 城市码与粗化坐标(~11km 粒度)、online 布尔,不出 node_id / IP / endpoint;同城多节点折叠为一个地图点,城内自环边剔除。响应带 Cache-Control: public, max-age=300;TS 面另经 Workers Cache API 边缘缓存 300s(缓存键剥掉查询串,防随机参数穿透打到 DB)。(TS routes/public.ts:101,Python app/api/v1/public.py:32) |
字段结构、隐私裁剪规则与消费示例详见 public-map-api.md。
Agent HTTP API
源码:apps/control-server-ts/src/routes/agent.ts(Python 温备:apps/control-server/app/api/v1/agent_http.py),前缀 /api/v1/agent。除 /register 外均需 Agent Bearer,且 payload node_id 必须等于 token 绑定节点(否则 403)。
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
POST | /register | 体内 enrollment token | 注册换取 agent token |
GET | /desired-state | Agent Bearer | 拉取本节点完整 DesiredState;无则 404 |
GET | /update/manifest | Agent Bearer | 自更新:拉取全局目标版本的发布清单;无目标 / 无清单 204 |
GET | /update/wheels/{version}/{filename} | Agent Bearer | 自更新:下载某版本下的单个 wheel;不存在 404 |
GET | /recovery-public-key | Agent Bearer | 取离线托管恢复公钥(PEM)+ 指纹 |
POST | /wireguard-keys | Agent Bearer | 登记节点 WG 公钥 + 托管密文,一致性校验 + 对端传播 |
POST | /runtime-snapshot | Agent Bearer | 上报运行时快照(容器 / 接口 / WG / BGP 观测) |
POST | /routing-table | Agent Bearer | 上报 BIRD 路由全表观测 |
POST | /prefix-flaps | Agent Bearer | 上报前缀级 flap 打分快照(flapfeed 采集会话旁路,按节点整表替换) |
POST | /wireguard-traffic | Agent Bearer | 上报 30s 轻量 WG 流量采样(全 peer 累计收 / 发字节之和) |
POST | /wireguard-reresolve | Agent Bearer | 上报 WG endpoint 重解析结果(自愈观测) |
POST | /reconciliation-report | Agent Bearer | 上报一次 reconcile 报告 |
POST | /apply-result | Agent Bearer | 上报一次 apply 结果 |
POST /agent/register
请求 AgentRegistrationRequest:{ enrollment_token, requested_node_id, inventory }(inventory 为 HostInventory:hostname / os / arch 等)。enrollment token 无效 / 绑定节点不符 401;节点被 admin reject 403。响应 AgentRegistrationResponse:
ACCEPTED:节点已 provision 且审批放行 →node_id/agent_id/agent_token/desired_state_generation同时非空。PENDING_APPROVAL:节点未 provision / 审批 pending / 已建但尚无第一代 →agent_token为 null,message说明原因。
json
// POST /api/v1/agent/register
{
"enrollment_token": "ent_xxxx.oneTimeSecret",
"requested_node_id": "edge1",
"inventory": {
"hostname": "edge1.example",
"os": "linux",
"arch": "x86_64",
"has_systemd": true,
"capabilities": []
}
}json
// 200 OK (ACCEPTED)
{
"status": "accepted",
"node_id": "edge1",
"agent_id": "agent-edge1",
"agent_token": "agt_ab12cd.s3cr3tVALUEonlyShownOnce",
"desired_state_generation": 3,
"message": null
}POST /agent/wireguard-keys
请求 WireGuardKeyReport:{ node_id, public_key, private_key_escrow? }。公钥与已记录不符 → 409 且事务回滚;未知节点 404。响应 WireGuardKeyReportResult:{ node_id, accepted, status, detail?, propagated_to[] },status ∈ stored/matched/rejected。
POST /agent/runtime-snapshot
请求 RuntimeSnapshot:{ node_id, generation?, captured_at, containers[], interfaces[], wireguard_interfaces[], bgp_protocols[], ... }。响应:
json
// 200 OK
{ "accepted": true, "node_id": "edge1", "generation": 3, "containers": 4, "interfaces": 6 }快照入库时控制面顺手喂两份 5min 存档(对旧 agent 快照均 no-op):per-peer 累计字节按接口差分进 per-interface 流量存档(按对端 AS 划分流量的数据层),self_metrics 的 cpu / rss / total_failures 差分进自观测存档(GET /ui/nodes/metrics 的数据层)。
其余上报端点的响应均为确认型 dict:
/routing-table→{ accepted, node_id, observation, routes }/prefix-flaps→{ accepted, node_id, entries, feed_state }(请求PrefixFlapReport:agent 侧按 (prefix, 归因对端 AS) 算好的指数衰减分 Top-N,每条含peer_asn第一跳 +origin_asn起源 AS,feed_state是 flapfeed BGP 会话状态)/wireguard-traffic→{ accepted, node_id, rx_bytes, tx_bytes, peer_count }/wireguard-reresolve→{ accepted, node_id, checked, reresolved }/reconciliation-report→{ accepted, node_id, status, drift_items }/apply-result→{ accepted, node_id, generation, status }
/recovery-public-key 返回 RecoveryPublicKeyResponse:{ configured, public_key_pem?, fingerprint? }(未配置时 configured=false)。
自更新(GET /agent/update/*)
wheel 发布与全局目标版本由 Admin API 的「Agent 发布」一节管理;agent 侧流程:经 WS 心跳上报 agent_version,版本低于全局目标时被推送一次 agent_update_available 事件(携带目标版本 + wheel 清单),随后经本组端点下载安装。
GET /agent/update/manifest:返回全局目标版本的发布清单AgentReleaseManifest({ version, wheels: [{ filename, sha256, size }] })。无目标版本 / 目标版本无 manifest →204(agent 据此判定「无更新」)。端点只发清单、不做版本判定,agent 自行用本地版本比对,需要才下载。GET /agent/update/wheels/{version}/{filename}:下发某版本下的单个 wheel 文件(application/octet-stream)。version/filename过严格白名单(防路径穿越),不存在404。agent 下载后对 manifest 的 sha256 逐字节核对,再由独立的 systemd 更新器整组安装。
Agent WebSocket API
源码:apps/control-server-ts/src/index.ts(WS 升级与鉴权在 fetch 入口,门铃通道转交每节点 Durable Object src/doorbell.ts,probe-stream 转交 src/probeHub.ts;Python 温备:apps/control-server/app/api/v1/agent_ws.py)。
| 端点 | 说明 |
|---|---|
WS /api/v1/agent/ws/{node_id} | 节点私有事件通道 |
WS /api/v1/agent/probe-stream/{probe_id} | 拨测输出回传临时通道(按作业建连,done 后收尾) |
握手:复用 HTTP 的 Authorization: Bearer <agent token> 头(不在 query 里传 token)。鉴权 / 通道隔离:
- token 解析失败 → 拒绝连接:TS 面在升级前以 HTTP
401拒绝(src/index.ts:74,82);Python 温备面以关闭码4401关闭(未握手 accept)。 - token 绑定的
node_id与 URL 路径里的{node_id}不一致 → TS 面 HTTP403(src/index.ts:84);Python 温备面关闭码4403。
数据契约:连接成功后服务端立即下发一条 hello,之后只下发「门铃事件」,agent 收到后回到 HTTP 拉取真实业务数据。agent 上行只认 heartbeat(报活 + 上报 agent_version / applied_generation / apply_status,写入 liveness 注册表,版本低于全局目标时触发一次 agent_update_available 推送);其余上行消息一律忽略(兼顾感知断连与旧 agent 兼容)。
事件类型(apps/control-server/app/schemas/events.py):
type | 何时下发 | 字段 |
|---|---|---|
hello | 握手成功后立即 | { type, node_id, generation? } |
desired_state_updated | 控制面发布新世代 | { type, generation(≥1), reason? } |
snapshot_request | 要求 agent 主动上报快照 | { type, reason? } |
agent_update_available | 心跳版本低于全局目标(同一目标只推一次) | { type, version, wheels[], reason? } |
probe_request | 管理面发起拨测(POST /ui/probes) | { type, probe_id, spec }——例外地携带业务数据,agent 立即执行并经 probe-stream 回传 |
json
// 连接成功后第一条
{ "type": "hello", "node_id": "edge1", "generation": 3 }json
// 配置变更门铃
{ "type": "desired_state_updated", "generation": 4, "reason": "interface updated" }WS /api/v1/agent/probe-stream/
agent 收到 probe_request 后按 probe_id 建连的临时回传通道:把拨测(ping / mtr / traceroute)输出逐条推给控制面,控制面 ProbeHub 缓存并 fan-out 给订阅该作业的浏览器 SSE(GET /ui/probes/{probe_id}/stream)。
- 鉴权:同样复用
Authorization: Bearer <agent token>头;token 解析失败即拒绝(TS 面升级前 HTTP401,src/index.ts:117,125;Python 温备面关闭码4401)。 - 归属校验:
probe_id对应的作业必须存在(未过期)且归属 token 绑定的节点,否则拒绝(TS 面 HTTP403,src/index.ts:134;Python 温备面关闭码4403)——防止某节点往别人的拨测作业里灌输出。 - 消息:只接受上行
output/done两类 JSON 消息,其余忽略;服务端强制把消息里的probe_id覆盖为路径值(防伪造)。收到done即收尾关闭连接。 - 断连兜底:agent 中途断开时服务端补发一条
done(error: "agent disconnected"),避免订阅的浏览器 SSE 无限等待。
健康探针
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
GET | /healthz | 无 | 存活探针。主控制面(TS 面)仅返回 { "status": "ok", "runtime": "workers-ts" }——无 DB 探测、无 503 分支(apps/control-server-ts/src/index.ts:36)。Python 温备面保留 DB 连通性探针(apps/control-server/app/main.py:190):DB 不可达返回 503 { "status": "unavailable", "database": "down" },正常 { "status": "ok", "database": "up" } |
GET | /api/v1/healthz | 无 | 轻量存活探针,两面一致恒返回 { "status": "ok" }(TS src/index.ts:37,Python app/api/v1/health.py) |