Skip to content

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;Python apps/control-server/app/api/deps.py):

  • AdminAuthorization: Bearer <凭据>,两种凭据在 require_admin 单入口无差别接受——静态 admin token(TS 面 wrangler secret ADMIN_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
  • AgentAuthorization: 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 UIAdmin Bearer(同上,两种凭据均可)
Agent HTTP API/api/v1/agent/...Node AgentAgent Bearer(/register 除外)
Agent WebSocket API/api/v1/agent/ws/{node_id}/api/v1/agent/probe-stream/{probe_id}Node AgentAgent 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_ident_*)为键。
  • 全局 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 面 var SESSION_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_writesapps/control-server/app/main.py:172)。

通用错误约定

  • 400 引用了不存在的关联资源(如 peering 的 remote_node_id)。
  • 404 路径上的资源不存在。
  • 409 唯一约束冲突(重名 / 已存在),或语义冲突(活节点直删、WG 端口占用、notify 时无在线 agent)。
  • 422 schema 校验失败(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(稳定字符串)含义
400turnstile verification failedTurnstile 缺失 / 伪造 / 复用(token 一次性,服务端调 siteverify 硬校验;secret 未配置同样 400,fail-closed)
401invalid credentials用户不存在与密码错误统一文案(防枚举;服务端对不存在用户也跑一次 argon2 对照校验,抹平时间侧信道)
403同 admin API未配置静态 admin token(TS 面 ADMIN_TOKEN / Python 面 DN42_CONTROL_ADMIN_TOKEN),admin 面整体锁定
429too many attempts同用户名同 IP(CF-Connecting-IPX-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创建节点,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(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-33NodeIn / 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 不触发 materializeprovision / 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 仅返回计划,不物化 / 不广播)

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 token / 未收录为 null)。

provision 一键端点请求 PeeringProvisionInpeeringPeeringIn)+ interface_specInterfaceSpec 的 JSON dump,见 DesiredState)+ interface_enabled / interface_sort_order,可选 bgp_specBgpSessionSpec 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)。写端点接受完整 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_idenabled(默认 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)。写端点接受完整 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_idsort_orderSessionPatch 各字段可选 + clear_peeringSessionOut{ 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)。三级模型: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_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),重物化广播

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> }

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=列出注册请求;statuspending/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=上报历史;kindsnapshot/report/apply/reresolvelimit 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 查询参数:family4/6(缺省全部);scopeall/local/external(默认 all);q 文本过滤(≤128,匹配前缀 / 协议 / 起源 ASN);rpkivalid/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.tsTS 面独有端点,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" }/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 下发)。


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 语义:range6h/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/overviewfleet 健康 + 每节点能力 / 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-flapsfleet 前缀级 flap 榜:按 (prefix, 归因对端 AS) 跨节点归组,max_score 降序,每行附 peer_asn(AS_PATH 第一跳)+ origin_asn(最后一跳=前缀起源)及各自 registry *_namerate_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: [...] },每行 zonenear(入向对端)/origin(源头 AS)/path(中段邻接)/moas(多源冲突)+ confidence(按其降序)+ 归因 AS / 邻接边;与 ?group=prefix 内嵌的 localization 同源同算法(TS uiFlaps.ts:239,Python observability.py:630
GET/fleet/flap-statsfleet 级 flap 活动时序 + 瞬时速率仪表:series[] 定长网格(变化速率两条线 + 活跃前缀数 + 在开告警数,空桶 null),gauge 是 90 分位截尾均值的当前变化速率(次/秒)。range1h(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-alertsflap 告警事件流(边沿触发:上穿告警线开、下穿消警线关;前缀级告警线 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-overviewtrend(规模中位数 + 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-topologyiBGP + OSPF 内部互联视图(拓扑配置 + 路由层 liveness);无已发布 DesiredState 404
GET/routing/fleet-overview概览「路由全表」板块一次取全:summary + 每节点 + 规模 / churn 趋势(origins_sortcount/v4/v6origins_top 1..200 默认 100)
GET/nodes/{node_id}/routing/dashboardRoutingTab 头部一次取全:summary + origins + timeline;支持 range / comparecompare 未带 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 / 流量 / 对账失败序列(range1h/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_failures0 表示「有上报且无失败」。桶宽按 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-defaultspeer 向导默认值:节点惯用 WG 私钥引用 / link_local 建议值(两面来源当前分叉,见下)/ 已占用监听端口

PUT .../route-tuning 请求两字段均可选(缺省不动):cold_potato_medroute_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_attraffic_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.tsProbeHub 在 TS 面是单例 Durable Object:apps/control-server-ts/src/probeHub.ts;Python 温备:apps/control-server/app/api/v1/ui/probes.pyProbeHub 为进程内对象)。实时链路:浏览器 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}/streamSSE 订阅拨测输出:先补发 ring buffer 已产生消息,再实时 fan-out,收到 done 收尾;作业不存在 / 已过期 404

POST /probes 请求 { node_id, spec }specProbeSpectoolping/mtr/traceroutetarget(IP 字面量,白名单收敛在 DN42 / 私网 / link-local,非法 422)、count(1..30,默认 10)、resolve(逐跳 rDNS,默认 true)。SSE 帧为 event: output / event: donedata: 是对应 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-stateAgent Bearer拉取本节点完整 DesiredState;无则 404
GET/update/manifestAgent Bearer自更新:拉取全局目标版本的发布清单;无目标 / 无清单 204
GET/update/wheels/{version}/{filename}Agent Bearer自更新:下载某版本下的单个 wheel;不存在 404
GET/recovery-public-keyAgent Bearer取离线托管恢复公钥(PEM)+ 指纹
POST/wireguard-keysAgent Bearer登记节点 WG 公钥 + 托管密文,一致性校验 + 对端传播
POST/runtime-snapshotAgent Bearer上报运行时快照(容器 / 接口 / WG / BGP 观测)
POST/routing-tableAgent Bearer上报 BIRD 路由全表观测
POST/prefix-flapsAgent Bearer上报前缀级 flap 打分快照(flapfeed 采集会话旁路,按节点整表替换)
POST/wireguard-trafficAgent Bearer上报 30s 轻量 WG 流量采样(全 peer 累计收 / 发字节之和)
POST/wireguard-reresolveAgent Bearer上报 WG endpoint 重解析结果(自愈观测)
POST/reconciliation-reportAgent Bearer上报一次 reconcile 报告
POST/apply-resultAgent Bearer上报一次 apply 结果

POST /agent/register

请求 AgentRegistrationRequest{ enrollment_token, requested_node_id, inventory }inventoryHostInventoryhostname / 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[] }statusstored/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 面 HTTP 403src/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 面升级前 HTTP 401src/index.ts:117,125;Python 温备面关闭码 4401)。
  • 归属校验probe_id 对应的作业必须存在(未过期)且归属 token 绑定的节点,否则拒绝(TS 面 HTTP 403src/index.ts:134;Python 温备面关闭码 4403)——防止某节点往别人的拨测作业里灌输出。
  • 消息:只接受上行 output / done 两类 JSON 消息,其余忽略;服务端强制把消息里的 probe_id 覆盖为路径值(防伪造)。收到 done 即收尾关闭连接。
  • 断连兜底:agent 中途断开时服务端补发一条 doneerror: "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