背景:排查解析差异(污染、区域 DNS、预发布域名)时需要「临时换解析服务器」或 「把某域名指到指定 IP」再拨测,此前只能改节点全局配置或 hosts。 节点 src - webtest 新增 ReqResolve:dns / dns4 / dns6 + resolve / resolve4 / resolve6 九参数, ParseReqResolve 统一解析;Servers(stack) 按「栈内覆盖 → dns 回落」取服务器, Override(host, wantV6) 取强制解析目标(map 键经 canonicalHost 归一化) - 解析层补 ResolveXRecordWith(domain, servers) 与 ResolveByType,ctx 传参走 WithDNSServers / DNSServersFromContext - 拨号:initHTTPClients 的 DialContext 从 ctx 取参数——命中 Override 时改写 addr, IP 直连、域名当作新 host 再解析一跳;未命中则用请求级服务器解析 - tcping:ResolveHostWith 同样支持 Override 与自定义 DNS;字面 IP 也做私网拦截 - SSRF:解析器改为可注入(ssrf.SetResolveFunc + webtest.InstallSSRFResolver), 预检与实际拨号同源,新增 src/webtest/ssrfresolver.go - 缓存:ReqResolve.CacheKey() 把参数按固定字段顺序拼进键,替代「有参数即跳过共享 缓存」;零值返回空串,旧缓存键逐字节不变,升级不失效 - ws:wsProbe 接收 query,与 HTTP 侧同口径 middleware - middleware-go:query 透传从 tcping/speed 分支提升到所有分支(detail/ssl/dns/whois 也带 dns 系参数),WS 侧以 map 传给 RequestProbe - frontend-ssr 内置中间件路由([...slug].get.ts)同步透传 query frontend-ssr - 新增 AdvancedOptions 组件:dns 通用 + 按栈 dns4/dns6、resolve 三档;单栈页只渲染 本栈一列,dns/whois 页隐藏 resolve - 8 个工具页接线;utils/tools.ts 新增 withQuery(空对象原样返回,不填参数时 URL 不变) - dev 下注入本地调试节点并排在首位,中间件指向 127.0.0.1:8091 docs - node-api 补 resolve 参数表、IP / 域名两种目标的语义、缓存键片段与 TTL 修正 验证:本地链路(前端 3010 → 中间件 8091 → 节点 18081)tcping/ssl/detail 带 dns= / resolve= 实测生效;SSRF 开启时私网目标被拦、关闭时放行。
20 KiB
节点 API 与协议参考
后端节点(src/)对外暴露的全部接口与协议。配置项说明见 配置文件,部署方式见 后端节点部署。
本文描述
src/主节点。边缘函数版本(serverless/edgeone/)是精简形态:只有/v1/*探测端点与GET /(仅返回{"status":"ok"},不回报版本与能力),没有 WS 通道、管理接口与 OTA;但同样支持 HTTP 数据上报,见下文「数据上报协议」。
鉴权总则
| 通道 | 鉴权方式 | 未配置凭据时 |
|---|---|---|
业务探测 /v1/* |
access-token 非空时校验 Authorization: Bearer <access-token>,不符返回 401 |
不启用鉴权,公开可访问 |
健康检查 /、节点信息 /info |
无 | — |
管理接口 /v1/config、/v1/ota(HTTP) |
同业务接口,复用 tokenCheck |
整组关闭,一律返回 403(节点未配 access-token 时业务接口本身也无鉴权,暴露配置读写等于公网可改配置) |
| WS 通道 | 中间件 ws-keys 校验注册报文 |
WS 常开:节点是客户端,指令只来自已通过中间件校验的连接,与节点是否配 access-token 无关 |
结论:HTTP 管理面是"有凭据才开"的;只想用 WS 通道管理节点时,节点无需配置
access-token。
一、健康检查与节点信息
GET / → {"status":"ok"}
GET /info → {"version":"v1.2.3","capabilities":["probe","report","config","ota"]}
两个端点都无鉴权、不计入统计,且都注册在 /v1 组之外——因此既不受 access-token 约束,也不经过业务统计中间件。
- 健康检查
/:只回答"进程是否活着"。给负载均衡、OTA 就绪探测(otaWaitChildReady)与运维curl用。不回报版本:它是免鉴权的对外端点,回版本号等于向公网公开节点版本。 - 节点信息
/info:回报节点身份。version为版本号,capabilities为能力清单(与 WSregister报文同源,见ws.go nodeCapabilities)。收集中心对纯 HTTP 节点(无 WS 连接)探活后单独取回,用于节点状态页展示版本、以及下发管理指令前判断该节点能否理解对应指令。
老版本节点没有
/info(返回404),边缘函数版节点同样不提供;收集中心取不到就留空,不影响判活。
二、业务探测接口
全部挂在 /v1 组下,共用 CORS + 可选 tokenCheck + 统计中间件(nodeReportMiddleware,见下文「数据上报」)。
| 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|
| GET | /v1/detail/*url |
目标 URL(路径传入,自动补全协议) | 网站探测明细:DNS/TCP/HTTP 各阶段耗时、状态码、页面体积、下载速度、可达性;双栈分别返回 |
| GET | /v1/ssl/*url |
目标 URL | SSL 证书检查:剩余有效天数、颁发/主体、TLS 版本、是否过期;双栈分别返回 |
| GET | /v1/speed/:version/*url |
version = v4 / v6,目标 URL |
下载测速 |
| GET | /v1/tcping/:ip |
port(默认 80)、count(默认 4,上限 20)、version(见下文「请求级参数」) |
TCP 连通性与 RTT 统计 |
| GET | /v1/dns/:type/*domain |
type ∈ a/aaaa/cname/mx/ns/ptr/srv/txt/caa |
DNS 记录查询(dns-server 支持主从 failover 与 DoH) |
| GET | /v1/dnssec/:domain |
域名 | DNSSEC 链式验证(dnssec-server,留空沿用 dns-server) |
| GET | /v1/whois/:domain |
域名 | Whois 注册信息 |
| GET | /v1/location/:ip |
IP | 多源 IP 归属地聚合(见 功能总览) |
| GET | /v1/location |
无(取请求方 IP) | 查询访问者自身归属地;配置 trusted-proxies 后只信任这些代理转发的 X-Forwarded-For |
| GET | /v1/asn/:ip |
IP | ASN 查询(三套 ASN 库 + ASN Whois 解析) |
条件注册:/v1/location/*、/v1/asn/:ip 仅在 ipdb 未关闭时注册——IP 库未加载时这些接口无意义。
单栈模式:single-stack 配为 ipv4 / ipv6 时只跑对应栈,另一栈直接返回 Skipped due to SINGLE_STACK=...,不发起无意义的探测与超时。
SSRF 防护:block-private-ips 开启时,detail / ssl 对解析到内网/私有地址的目标直接返回伪造结果,出站客户端同时拒绝连接私有 IP、禁止跨跳重定向。tcping 是裸 TCP、不经过出站客户端,故在 webtest.ResolveHostWith 内单独把关:字面 IP、DNS 解析结果、resolve* 改写后的目标三者只要落在私网/回环/链路本地/组播网段一律拒绝,该栈返回 Error: connection to private/internal address <IP> is not allowed(sent 仍为请求次数、success=0、loss_rate=100);关闭开关时三者一并放行。
请求级参数
除路径参数与常规 query 外,/v1/tcping/:ip 与所有走 DNS 的探测接口还接受一组请求级参数,用于按次覆盖解析行为。参数统一由 webtest.ParseReqResolve 解析,HTTP 拨测经 context、tcping 经形参透传到解析层;HTTP 与 WS 两条通道语义一致。
| 参数 | 取值 | 作用接口 | 说明 |
|---|---|---|---|
dns |
逗号分隔的 ip:port 或 http(s):// DoH URL |
detail / ssl / speed / tcping / whois / dns |
本次探测改用指定 DNS 服务器,取代配置的 dns-server |
dns4 |
同上 | 同上 | 只覆盖 IPv4 族解析,优先于 dns |
dns6 |
同上 | 同上 | 只覆盖 IPv6 族解析,优先于 dns |
resolve |
逗号分隔的 域名:目标,目标为 IP 或域名 |
detail / ssl / speed / tcping |
强制改写某域名的解析结果(两栈通用) |
resolve4 |
同上 | 同上 | 只影响 IPv4 侧;非空时 IPv4 侧只用这张表(与 dns4 同口径) |
resolve6 |
同上 | 同上 | 只影响 IPv6 侧;非空时 IPv6 侧只用这张表 |
version |
ipv4 / ipv6(兼容 v4 / v6,大小写与首尾空格不敏感) |
tcping |
请求级指定单栈 |
dns* 与 resolve* 是两类正交的覆盖:前者「换服务器去问」,后者「直接改答案」,可同时使用。
resolve* 的目标可以是 IP,也可以是域名:
- 目标是 IP(
resolve4=example.com:1.2.3.4,IPv6 可写[::1]或裸地址)→ 拨号阶段不再查 DNS,直接对该 IP 做私网校验后连接; - 目标是 域名(
resolve=www.example.com:origin.internal)→ 把它当作新的 host 继续走解析,解析服务器仍取dns*/配置,且不再查映射表(因此不会绕成环)。
行为要点:
- 取值与配置侧同一套校验(
NormalizeServers):合法项为ip:port(UDP)或 DoH URL,非法项被丢弃;参数存在但所有项都非法时返回400,不静默忽略——避免「看起来指定了、实际按默认跑」。resolve*同理:每组必须形如域名:目标,目标为 IP 或形如域名(不含端口、空格),非法项丢弃、全非法返回400。 version不越权单栈配置:节点single-stack非空时一律以配置为准(单栈机指定另一栈也跑不起来);仅当配置为空(双栈机)时version才生效。跳过文案借此区分来源:配置造成记SINGLE_STACK,参数造成记version。- 作用止于「域名 → IP」这一步:
dns*只更换解析服务器、resolve*只改写解析结果,都不改变探测目标与端口;dnssec/location/asn三类接口不接受上述参数,whois/dns只取dns*、忽略resolve*(dns拨测问的就是真实解析,改写会自相矛盾)。 - SSRF 预检同源:开启
block-private-ips时,预检校验的是拨号真正会连的地址(即resolve*改写后的目标),不会拿原域名的解析结果去判定。tcping没有独立的预检步骤,同一判断落在ResolveHostWith进入拨号之前,口径与上述一致。 - 带覆盖时按独立缓存键缓存:规范化后的参数会拼进缓存键(见「七、缓存与并发」),不同服务器/不同映射的结果各占一条,既不串味、也不牺牲命中率。
三、管理接口
3.1 运行时配置 /v1/config
读写节点当前生效的配置(内存值),与收集中心的「托管配置」是两回事——后者是节点启动时拉的远端配置,见 配置文件 的「运行时配置管理」一节。
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /v1/config |
返回当前生效配置快照 + secretKeys + restartRequiredKeys + remoteProtectedKeys |
| PATCH | /v1/config |
按下发 map 逐键应用;?persist=1 时同时写回本地 setting.json |
| POST | /v1/config/refresh |
重新拉取 remote-config-url 并应用(远端是持久来源,不写回本地文件) |
行为要点:
- 凭据遮蔽:
access-token/node-key/report-token在 GET 快照里回显***,不泄露明文;下发时原样提交会把真值覆盖成三个星号,因此调用方必须剔除这些键(收集中心控制台已自动剔除)。 access-token/report-token不随远端配置覆盖:这两项硬编码在保护名单configRemoteProtectedKeys里,remote-config-url(含收集中心托管配置)下发时会被跳过,并在refresh应答的protectedIgnored与节点日志里列出被跳过的键。只约束远端下发,本地PATCH照常可改。理由与清单见 配置文件 的「远端配置」一节。- 未持久化的改动会被重启顶掉:节点侧优先级是 远端 > 环境变量 >
setting.json,只 PATCH 内存时重启即回到旧值。需要长期生效应refresh(改远端配置)或把改动合并进托管配置——受保护凭据例外,只能落到本地 ENV /setting.json。 restartRequired只如实回报:port/cors/ipdb/report-interval-seconds/trusted-proxies/node-id/node-key/access-token这些启动期固定的键,改动后节点不会自行重启,只在应答里列出键名,重启时机交给运维。ws-url例外,热生效:WS 客户端控制器按新地址"多退少补",无需重启。node-ota同样即时生效:该开关在每次下发 OTA 指令时才读取,不属需重启类键。
3.2 OTA 升级 /v1/ota
收集中心 HTTP 回退通道(WS 在线时优先走 WS ota 消息)。
POST /v1/ota
{
"requestId": "可选,回执关联用",
"version": "v1.4.0", // 二选一:按版本号下发
"url": "https://.../lemonipw-linux-amd64", // 二选一:直发下载地址
"assetBase": "https://.../releases/download", // 可选,配合 version 使用
"sha256": "可选,hex64(兼容 sha256: 前缀),提供则强校验"
}
→ 202 {"ok":true,"started":true}
响应仅为受理回执(下载可能数分钟,HTTP 无进度通道)。执行阶段见 后端节点部署 的「OTA 升级」一节。
四、WebSocket 协议
节点作为 WS 客户端连接收集中心(或独立中间件)的 /ws 端点:ws(s)://<中间件>:<ws-port>/ws。ws-url 支持逗号分隔多个地址,同时连接全部(多活),由 per-URL 连接控制器统一收敛。
4.1 消息信封
{ "type": "...", "nodeId": "...", "ts": 1710000000, "data": { } }
4.2 消息类型
节点 → 中间件
| type | data 字段 | 作用 |
|---|---|---|
register |
nodeId / key(配了 ws-keys 时必填)/ version / capabilities[] |
注册接入,建立身份 |
ping |
— | 心跳,每 10s |
pong |
— | 应答中间件 ping |
probe_result |
requestId / status / body |
拨测结果回传 |
report |
统计 + 拨测明细(同 /report body) |
周期数据上报 |
config_result |
requestId / action / ok / error / 配置快照 / applied / unknown / restartRequired / persisted |
配置指令应答 |
ota_result |
requestId / ok / stage / error |
OTA 进度与失败原因 |
中间件 → 节点
| type | data 字段 | 作用 |
|---|---|---|
register_ok |
heartbeatSeconds |
注册通过,告知心跳周期 |
register_error |
code / command |
注册被拒(如 key 不匹配),30s 后重试 |
probe |
requestId / apiType / raw / query / scheduler |
下发拨测 |
config |
requestId / action / config / persist |
下发配置指令 |
ota |
requestId / url 或 version+assetBase / sha256 |
下发升级任务 |
ping |
— | 中间件心跳(节点回 pong) |
status |
统计字段 | 中间件状态,节点仅记录日志 |
4.3 心跳与重连
- 节点每 10s 发
ping;发送连续失败 3 次判定通道不可用,主动断开并在 3s 后重连。 - 注册被拒(key 错误等)时 30s 后重试。
- 中间件侧每 20s 发
ping+status,空闲 75s 未收到任何报文即剔除该节点并记离线事件。
4.4 OTA 进度阶段
节点经 ota_result 依次回报:accepted → downloading → verifying → installing → restarting;任一阶段失败即终止并带 error。重启后连接必然断开,"最终结果"由收集中心以重连注册上报的新版本号判定。
五、数据上报协议
统计与拨测明细由节点自己上报,收集中心不在转发路径上采集——节点是唯一记录者,不存在双算。
| 通道 | 条件 | 形式 |
|---|---|---|
WS report 消息 |
已连接中间件 | 向所有在线中间件广播 |
HTTP POST <report-url>/report |
WS 未启用或全部掉线 | 携带 Authorization: Bearer <report-token> |
{
"instance": "cn-jiangsu",
"stats": [
{ "nodeId": "cn-jiangsu", "apiType": "tcping",
"total": 1, "errors": 0, "latencySumMs": 12, "latencyMaxMs": 12, "minute": 29821676 }
],
"probes": [
{ "nodeId": "cn-jiangsu", "apiType": "tcping", "raw": "qq.com",
"status": 200, "latencyMs": 33, "body": { } }
]
}
- 上报周期由
report-interval-seconds控制(缺省 15s)。 stats是增量,收集器按(分钟 × 节点 × apiType)累加;上报为 at-most-once(失败丢弃并记日志,不重试——重试会双算)。- 单次报文上限 500 条 stats / 500 条 probes / 4MB;超出或队列(缓冲 2048)打满时丢弃并计数。
- 只上报第一方观测:节点自己处理的请求才记账,不转播从别处收到的数据。
minute是 unix 分钟桶(按节点时钟);上报方传0表示"由收集器按自身时钟入桶",收集端另有"不接受未来 1 小时以外"的漂移容忍。- 部署形态差异:
serverless/edgeone/版本不维持长连接(无 WS 客户端),只走 HTTPPOST /report一条通道,配置方式见 后端节点部署 的 EdgeOne 一节。
六、能力清单(capabilities)
节点在 register 报文与主节点的 GET /info 中上报自身支持的管理能力,取值为 probe / report / config / ota 的数组。边缘函数版本不提供 /info,也不回报能力清单。
收集中心据此做三态判定,绝不能把"未知"当作"不支持":
| 上报情况 | 收集中心行为 |
|---|---|
| 清单含该能力 | 正常下发对应指令 |
| 清单明确不含 | 该能力确实没有:立刻拒绝,不发起指令、不等超时 |
| 清单为空(未上报) | 老版本节点,能力未知:仍尝试下发;超时后主动探活,区分"节点活着但静默忽略(程序过旧)"与"连接已僵死" |
新增一类管理指令时应在此追加能力标识,并保持取值稳定(收集中心按字符串比对)。
七、缓存与并发
业务探测结果在节点侧缓存,避免同 key 洪打把上游打崩:
| 缓存 | 键(末尾拼请求级解析片段) | TTL(成功 / 失败) |
|---|---|---|
detail |
归一化 URL | 30s / 5s |
ssl |
URL | 30s / 5s |
speed |
url:version |
30s / 5s |
tcping |
host:port:count:stack |
30s / 5s |
whois |
域名 | 30s / 5s |
asn whois |
AS<asn> |
30s / 5s |
| Bilibili 归属地(在线 API) | IP | 24 小时(不分成败) |
- TTL 按结果分档(
cacheTTL):成功 30s、失败 5s。失败结果不缓存太久,避免故障恢复后长时间返回旧结论;TTL 记在条目里、读时判断,无需后台协程唤醒。 - 缓存键含生效栈:
tcping的键为host:port:count:stack,version=ipv6的结果与默认双栈不会互相污染。 - singleflight 合并:
detail/ssl/tcping/speed的并发同 key 请求合并为一次真实探测(合并键同缓存键)。 - 请求级解析参数进键:
dns*与resolve*一并拼入,详见下一节。 - 定时清扫:每 1 分钟清理过期缓存条目,防止只靠「同 key 重访才淘汰」时内存无限增长。
7.1 请求级解析参数如何进入缓存键
缓存键的基础部分只含目标维度(URL / host),但探测结果依赖怎么解析——同一域名经不同服务器、或经 resolve* 改写后可能指向完全不同的 IP(企业内网、CDN 调度、分线路权威、上线前的强制指向)。若把两者混在一条键里,会双向串味且无从察觉:
- 带
dns=1.1.1.1:53的结果写进example.com这条 → 随后的普通请求(用配置的dns-server)读出来,用户以为是自己默认 DNS 的结果; - 反过来,普通请求的结果也会被带
dns=的请求读走——用户明明指定了服务器,却拿到别人用另一台服务器查的结果; resolve*更直接:强制指向 A 与强制指向 B 是两次不同探测,共用一条键等于互相覆盖。
因此把规范化后的参数片段拼在键末尾(ReqResolve.CacheKey()),两件事同时成立:不同解析配置各占一条,相同配置仍能命中。
https://example.com ← 无请求级参数
https://example.com;dns=1.1.1.1:53 ← dns=
https://example.com:v4;dns=8.8.8.8:53;dns6=[2400::1]:53 ← speed,按栈覆盖
qq.com:80:4:ipv4;dns=1.1.1.1:53 ← tcping
https://example.com;resolve4=example.com:127.0.0.1 ← resolve4,强制解析
片段规则:
- 字段顺序固定为
dns→dns4→dns6→resolve→resolve4→resolve6。所以参数在 URL 里谁先谁后不影响键(?dns6=…&dns=…与?dns=…&dns6=…是同一个键)。 dns*内部按输入顺序,resolve*内部按域名排序:映射表本质是 map,迭代顺序随机;不排序的话同一请求每次算出的键都不同,缓存永远命不中。- 无参数时片段为空串 ⇒ 键与未引入该特性时逐字节一致,升级后旧缓存条目照常命中。
- 按规范化结果拼:非法项在校验阶段已被丢弃,故
dns=1.1.1.1:53,garbage与dns=1.1.1.1:53落在同一个键上;resolve4=Example.COM.:1.2.3.4与resolve4=example.com:1.2.3.4同理(域名统一小写、去尾点)。 - 键空间随组合增长:每条仍受 30s / 5s 的 TTL 约束,由每分钟的清扫兜住,不会无限堆积。
asn/location/dnssec不接这些参数,键与以前相同。
已知残留(与本次改动无关):
detail与ssl的 singleflight 合并键都是「URL + 片段」,两种探测并发打同一 URL 时会撞在一起,后到者按另一类型断言会 panic(被recover接住 → 500)。修法是给合并键加探测类型前缀,暂未处理。