Skip to content

控制台 ↔ 控制服务器接口

控制台消费的全部端点,以及那些读代码看不出来的字段口径。

基址 VITE_CONTROL_API ?? https://api.natlan.io。客户端实现是 apps/control/src/lib/api.ts,类型镜像在 apps/control/src/lib/types.ts

BGP 抖动检测(/ui/fleet/*flap*/admin/fleet/mitigations/*)单列一篇: flaps.md

1. 通用约定

约定
鉴权Authorization: Bearer <token>,账号会话令牌与 admin token 走同一校验入口,所有端点无差别接受两种
401令牌失效。前端丢弃令牌,路由守卫弹回登录页
403Admin API 被锁定
错误体FastAPI 风格 {"detail": …}detail 可能是字符串或 {message, errors[]}
时间戳ISO 8601(UTC)
时序 series一律按时间升序(旧 → 新)。唯一例外是配合 before_id 游标的瘦列表(status-events、flap-alerts、audit),它们是新 → 旧
字段命名snake_case
请求超时前端一律 15 秒(上传 120 秒)。最重的两个端点 GET /ui/dashboardGET /ui/nodes/{id}/routing/dashboard 必须在冷缓存下也稳定低于此值

两层前缀的分工

  • /control/v1/ui/* —— BFF 面。服务端预先聚合、预先计算,前端只渲染。
  • /control/v1/admin/* —— 通用管理面。资源 CRUD 与运维动作。

兼容性方针:单操作者、前后端同车发布,因此契约不做旧后端兼容适配。新字段 一律必有(未知取 null),前端直读,不存在 feature flag 门控的回退分支。 /ui/sessionfeatures[] 仍然保留,用于后端上新能力时的灰度。

2. 账号与会话

POST /control/v1/auth/login

Authorization 头。

jsonc
// 请求
{ "username": "…", "password": "…", "captcha_token": "…" }
// 200
{ "token": "<不透明随机令牌>", "expires_at": "2026-08-14T04:00:00Z" }

token 必需;expires_at 可选(前端当前忽略)。

错误语义是契约,前端按状态码分支

状态码含义detail(稳定字符串)
400人机验证失败 / 缺失turnstile verification failed
401凭据错误(统一文案,防用户枚举invalid credentials
403Admin API 被锁定沿用通用语义
429触发登录限速too many attempts

turnstile verification failed历史锁定的措辞。人机验证 2026-07 已从 Cloudflare Turnstile 迁到自托管 Cap,这个字符串保持不变——前端与更早的缓存产物 都按它匹配。

路由不存在时前端把 404/405 解释为「旧后端不支持账号登录」并引导改走 API Token。 因此不要上线一个会 500 的半成品路由。

人机验证是服务端硬校验:前端只渲染控件并透传一次性 token,服务端持 CAP_SECRET_KEYPOST https://challenges.natlan.io/1018704021/siteverify 校验。票一次性,前端不做 预校验(会烧掉 token),每次失败自动 reset 控件重新取票。

POST /control/v1/auth/password

{old_password, new_password},需 Bearer,仅 kind=user 的令牌可调。 改密吊销该用户全部存活令牌。

GET /control/v1/ui/session

令牌探针 + 服务端元信息。极轻量。

jsonc
{
  "authenticated": true,
  "scope": "admin",
  "server_version": "1.4.0",
  "agent_target_version": "0.9.3",
  "heartbeat_interval_seconds": 30,
  "liveness_thresholds": { "online_seconds": 75, "stale_seconds": 300 },
  "features": [],
  "identity": { "username": "…", "kind": "user" | "static-token" }
}

liveness_thresholds 让前端在需要本地补算时(倒计时 UI 之类)与服务端策略对齐, 而不是自带一份硬编码阈值。

3. 服务端归位的两项策略

这两件事曾经在前端做,现在是服务端职责。前端不得再自行推导。

liveness

「心跳 ≤75s = online / ≤300s = stale / 否则 offline」这套判定归服务端。所有携带 agent 活性字段的响应行都直接带:

jsonc
"liveness": "online" | "stale" | "offline"

覆盖范围:dashboard 的 fleet overview 行、/ui/nodes 行、node overview、 agent-releases 的 nodes[]。聚合计数由服务端给 ({online, stale, offline, agents_behind})。

阈值本身经 /ui/session 下发。后端改心跳间隔时,前端不需要跟着改。

geo

site code → 坐标 / 城市 / 国家 / DN42 region 的注册表在后端。所有带节点行的响应 都带:

jsonc
"geo": {
  "lat": 50.11, "lon": 8.68,
  "city": "Frankfurt",   // 英文名,前端负责 i18n 显示
  "country": "DE",       // ISO 3166-1 alpha-2
  "region": 41           // DN42 origin-region community;未知为 null
}
// 整体可为 null(site 无法解析且无 region 兜底)

解析优先级:节点自带的 region 字段覆盖注册表推导值。

新增一个机房因此不需要发一版前端。

4. 仪表盘

GET /control/v1/ui/dashboard

首屏一次取全,取代五个细粒度端点的连发。高频轮询,服务端有约 3 秒 TTL 缓存。

参数说明
range6h | 24h | 7d | 30d。服务端按 range 选桶宽,返回点数上限约 200
compare1 时返回 points_previous:紧邻的上一个等长窗口,同粒度同桶数,前端按索引对齐叠加
origins_top起源 AS 榜深度(≤200,常用 100)
traffic_limit流量点数
as_traffic1 时返回 traffic_by_as(默认不返回,控响应体积)
jsonc
{
  "generated_at": "…",              // 本次聚合的服务器时间(命中缓存则为缓存生成时间)
  "overview": { /* FleetOverview,每行带 liveness + geo;summary 带四项计数 */ },
  "traffic": { "points": [  ], "points_previous": [  ] },
  "traffic_breakdown": { /* 每节点 / 每对端当前速率 */ },
  "traffic_by_as": { "series": [  ], "table": [  ] },
  "traffic_mix": { "internal_bytes": , "external_bytes": , "rx_bytes": , "tx_bytes":  },
  "peering_issues": [ /* 裸数组,不是 {issues:[…]} */ ],
  "routing": { "captured_at": "…", "summary": {  }, "trend": [  ], "origins": [  ] }
}

口径要点

  • agent_summaryFleetOverview顶层字段,未并入 summary——health 的 stale 与 liveness 的 stale 会撞键。形状 {online, stale, offline, agents_behind}
  • peering_issues 是裸数组,其余四块与对应细粒度端点逐字段一致
  • routing.trend[] 每点有 size(= 两者之和,兼容值)、size_v4size_v6summaryas_count(RIB 中不同起源 AS 总数)、as_count_v4/v6rpki_v4/v6announced / withdrawn 字段保留但仪表盘已不展示。
  • routing.origins[] 每项有 name(registry as-name,未配 registry token 则全 null) 与 count_v4 / count_v6
  • traffic_by_as 的「AS」= peering 的 remote_asn;份额 = 窗口累计字节占外部流量 的比例;关联不到 peering 的接口(内部 mesh wg-* / dn42-*)归入 internal,不进榜。 名称一律用注册表 as-name(权威),不是 peering 的本地 remote_label
  • traffic_mix 的 internal / external 按接口名约定区分,与 traffic_by_as 同源。
  • 「流量相对变化」(逐桶百分比)由前端从 points_previous 算出,无对应字段。

时间戳字段(generated_at / captured_at)不是装饰:dashboard 有缓存、routing 采集 有分钟级延迟,「这个数字是几点的」直接决定可信度,各 Widget 底部都在渲染它。

5. 节点

GET /control/v1/ui/nodes

列表页数据源。行 = NodeOut 的标量字段(不含 base_templateinventory 这两坨大 JSON),加服务端 join 的活性与富化字段:

字段类型口径
healthNodeHealthValue
livenessonline|stale|offline见第 3 节
agent_version / agent_up_to_date / last_heartbeat_atagent 活性
drift_countint最新 report 的漂移项数;未上报 → 0
peers_up / peers_totalint | null最新路由快照中 Established 的 BGP 会话数 / 总数(eBGP+iBGP);无快照 → 两者均 null
rx_bytes_per_sec / tx_bytes_per_secfloat | null当前吞吐,与 traffic_breakdown.nodes 同源同口径
geoNodeGeo | null见第 3 节

前端「当前吞吐」渲染 rx+tx 合计;liveness=offline 的节点前端自行置灰显示 , 后端不特判。

GET /control/v1/ui/nodes/metrics

机群指标矩阵,驱动可观测性 tab 的四张图与节点卡片的通栏 sparkline。

参数 range1h | 6h | 24h | 7d(默认 6h)。

jsonc
{
  "generated_at": "…", "range": "6h",
  "timestamps": [ "…", "…" ],          // 全体节点共享的桶网格
  "nodes": [ {
    "node_id": "hkg2-edge",
    "cpu_percent": [12.4, null, 13.1],
    "rss_mb": [128.0, 129.5, null],
    "rx_bytes_per_sec": [  ], "tx_bytes_per_sec": [  ],
    "reconcile_failures": [0, 1, null]
  } ]
}

固定网格约定(趋势类端点通用):

  • timestamps 是共享网格,各序列与其按索引对齐、等长
  • 服务端按 range 选桶宽,点数 ≤ 200。实际桶宽: 1h → 5min/12 点6h → 10min/3624h → 30min/487d → 3h/56
  • 空桶 = null,不是 0。缺数据是缺口,画图要断线。reconcile_failures0 表示「有上报且无失败」,null 表示「该桶无上报」;
  • 全量矩阵返回,不做服务端 top-N——排名与站点过滤都在前端。

1h 档是 5min 桶而非更细的 2min:cpu / rss 与流量归档的原生粒度就是 5 分钟 (快照节奏),2min 桶会让三分之二的桶变成 null 缺口。

GET /control/v1/ui/nodes/{id}/overview

详情页首屏的唯一请求。除 NodeOverview 原有字段外无条件内嵌:

内嵌说明
node完整 NodeOut,含 base_template(编辑弹窗要用)与 wireguard_public_key
dns_group{id, name}null = 未分配。免掉 DNS tab 拉全量组列表解析名字
trendsagent 自观测 / drift / apply 三组序列,见下
liveness / geo见第 3 节

节点不存在才 404;存在但从未上报返回 health: "unknown" 骨架 + 完整 node ——前端靠这个区分「未上报」与「出错」。

peerings 不并入(Peerings tab 懒加载自取,避免 overview 轮询变重)。

内嵌的 trends

jsonc
{
  "self_metrics": { "current": {  } | null, "series": [ {"at":"…","cpu_percent":,"rss_mb":,
                     "last_routing_collect_seconds":,"last_reresolve_seconds":} ] },
  "drift":  { "current": 0, "series": [ {"at":"…","count":0} ] },
  "apply":  { "total": 50, "succeeded": 48, "last_status": "succeeded",
              "last_at": "…", "series": [ {"at":"…","status":"succeeded"} ] }
}

从未上报 → 三段均 current: null / 空 series。apply 的成功率由服务端算好。

GET /control/v1/ui/nodes/{id}/status-events

瘦列表,kind= 过滤,before_id 游标,新 → 旧。行只带 {id, kind, generation, status, created_at, drift_count}——drift_count 让列表行直接 显示而不必下载 payload(snapshot 单条可达几十 KB)。

完整事件(含 payload)按需取 GET /control/v1/ui/status-events/{event_id}

GET /control/v1/ui/nodes/{id}/traffic

节点流量,参数同 dashboard 的 range / compare

GET|PUT /control/v1/ui/nodes/{id}/route-tuning

路由调优的专用读写,存在的意义是消除 base_template 的读-改-写竞态

jsonc
// GET
{ "node_id": "de-fra1",
  "cold_potato_med": 50,
  "route_local_pref": [ { "prefix": "172.20.62.160/27", "local_pref": 200 } ],
  "sessions": [ { "id": 12, "name": "peer_mp", "remote_asn": 4242420000, "link_latency": 3 } ],
  "updated_at": "…" }

// PUT { "cold_potato_med": 50, "route_local_pref": [ … ] }   两字段均可选,缺省不动
// → 200,返回 GET 同款完整视图

后端做字段级合并:只写 base_template.bird.cold_potato_med.route_local_pref 两个 key,base_template 其余部分原样保留。前端因此不需要理解 base_template 的 内部结构,也不会覆盖他人对其他部分的并发修改。

会话级同理:PUT /control/v1/ui/bgp-sessions/{sid}/link-latency (body {link_latency: 3}null = 清除)在 session spec 上做单字段合并。

GET /control/v1/ui/nodes/{id}/peer-defaults

互联向导的默认值。这些推导曾是前端启发式,现在归后端——约定一变前端就猜错。

jsonc
{ "node_id": "de-fra1",
  "wireguard": { "private_key_ref": "…", "link_local": "fe80::ade0",
                 "used_listen_ports": [51820, 51821] } }

推不出的字段为 null

GET /control/v1/ui/nodes/{id}/routing/dashboard/admin/nodes/{id}/routing/prefixes

节点路由三视图的数据源。prefixes 的查询参数:

参数说明
family / scope / q协议族 / 范围 / 自由文本
rpkivalid | invalid | not_found,可与上面自由组合
path_asn匹配 as_path 包含该 AS 的路由(含起源跳),与 q 独立可组合

响应顶层:

  • rpki_counts: {valid, invalid, not_found} —— 当前 family/scope/q 条件下 (不含 rpki 自身)的分布,驱动 chips 上的计数徽标。rpki_observed=false 的快照 返回 null,前端隐藏 chips。
  • as_names: {"<asn>": "<name>"} —— 仅当前页出现的 origin ASN,未知的省略。

RPKI 状态值在数据里用下划线 not_found。历史上前端有一处用连字符 not-found 判断,导致该类别永远落到 muted 而非 warn。命名以数据为准。

GET /control/v1/ui/nodes/{id}/internal-topology

iBGP / OSPF 不是 bgp_sessions 记录,而是从 bird.internal_topology 合成的。 本视图暴露那份配置,外加一个从路由派生的活性提示(rib_routes / in_rib)。

6. 写操作

POST /control/v1/admin/nodes/{id}/peerings/provision

一次事务创建 peering + interface + N 条 BGP 会话。

jsonc
// 请求
{ "peering": {  }, "interface_spec": {  }, "interface_enabled": true,
  "bgp_specs": [ {  }, {  } ] }
// 200
{ "peering": PeeringOut, "interface": InterfaceOut,
  "sessions": [ SessionOut,  ], "bgp_session": SessionOut }
  • 全部对象在一个事务里创建,任一失败整体回滚,不存在「部分成功」的半配置状态。
  • 响应保留旧的 bgp_session(首条)作兼容,新增 sessions[]
  • bgp_specbgp_specs 二选一,同给 422

DNS 写操作返回父级摘要

zone 与 record 的增删改都会影响父级的计数,因此写响应直接带回父级最新状态, 免掉一次双重刷新:

jsonc
// zone 的 POST / PATCH / DELETE
{ "zone": DnsGroupZoneOut | null,   // DELETE 时为 null
  "group": DnsGroupOut }            // zone_count 已更新

// record 的 POST / PATCH / DELETE
{ "record": DnsRecordOut | null,
  "zone": DnsGroupZoneOut }         // record_count 已更新

⚠️ zone / record 的 DELETE 是 200 带 body,不是 204。

GET /control/v1/ui/audit

游标分页 + 服务端搜索:limit / before_id / q(匹配 actor / method / path)。 行结构 {id, actor, method, path, status_code, detail, created_at}

所有管理动作(含抖动处置)自动进审计,前端不需要额外记录。

流式端点

POST /control/v1/ui/probesGET /control/v1/ui/probes/{id}/stream (ping / mtr / traceroute),日志同构:/ui/logs + /ui/logs/{id}/stream

正常结束路径必须发 done 帧再关流。 前端把「流关闭但没收到 done」显示为 红色的「连接已断开」。异常路径(agent 掉线、超时)不发 done 是正确的——那正是这个 提示要捕捉的场景。

7. 错误契约

后端的错误 detail 措辞是稳定接口。 前端 apps/control/src/lib/api.tsDETAIL_PATTERNS 用正则把已知句式翻译成四种语言;改措辞不会报错,但翻译会静默失效、 回退成英文原文。

已在匹配的句式家族:

句式示例
unknown <kind> <id>unknown node de-fra1unknown remote node …unknown bgp session …
<what> already exists …bgp session 'peer_mp' already exists on node X
wireguard listen_port <n> is already used by interface '<name>'端口冲突
wireguard listen_port must be inside node runtime.wireguard_port_range <a-b>端口范围
node <id> has no connected agent / … agent disconnected before dispatch下发失败
no desired state for node <id> / node <id> has no published desired state无期望状态
node <id> has no generation <n> / no previous generation to diff against世代
peering <n> not on node <id>归属错配
node <id> is live; … / node <id> has no live agent connection…生命周期
materialization failed schema validation物化失败
invalid <what>通用校验
turnstile verification failed人机验证(历史锁定措辞)
{"message": "invalid <Spec>", "errors": [...]}pydantic 校验,前端内联首条字段错误

新增错误请复用现有句式unknown <kind> <id> / <what> already exists … / node <id> has no …),前端零成本自动覆盖。未匹配的句式原样透传——未翻译好过翻错。

长期方向:detail 里加机器可读 code ({"code": "node_not_found", "params": {...}, "message": "…"}),前端改为按 code 查表,整张正则表即可退役、措辞从此随意改。前端已有承接结构,后端加字段即可,不破坏兼容。

8. 轮询预算

端点周期
/ui/dashboard35s(全站统一 tick)
/ui/nodes/ui/nodes/{id}/overview35s
抖动类端点60s,见 flaps.md
流式端点不轮询

全站共用一个定时器,标签页隐藏时暂停。详见 internals/interaction-contracts.md