Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a4e417b28 | ||
|
|
65001d1eff | ||
|
|
4d2273c885 | ||
|
|
a837f38c8f | ||
|
|
5f202a3a2b | ||
|
|
df9f7857ed | ||
|
|
61788fa7ae | ||
|
|
c9d1f21fa7 | ||
|
|
fbf5c9e3b0 | ||
|
|
af0b5e1ea0 | ||
|
|
4d608a6697 | ||
|
|
afd9df87c1 | ||
|
|
4987cea8e0 | ||
|
|
0dce0fef14 | ||
|
|
fac8be622c | ||
|
|
8ae615d0e3 | ||
|
|
f8482414e4 | ||
|
|
05093190b9 | ||
|
|
7debff02fe | ||
|
|
25a46b8e13 | ||
|
|
0bbaf443e1 | ||
|
|
f82731eb77 | ||
|
|
8f57649832 | ||
|
|
b72136a461 | ||
|
|
2a65abb8c3 | ||
|
|
4c0f29918c | ||
|
|
5de97082aa | ||
|
|
8b094bcb36 | ||
|
|
62b80d06dc | ||
|
|
81a61c0836 | ||
|
|
cee22230ea | ||
|
|
2947ad2125 | ||
|
|
1235ccffbd | ||
|
|
d688e9bb8e | ||
|
|
4abe6e4d1a | ||
|
|
165caa9127 |
No files matched your search
@@ -35,4 +35,13 @@ jobs:
|
||||
run: |
|
||||
cd frontend-ssr
|
||||
pnpm install
|
||||
pnpm run deploy --keep-vars
|
||||
pnpm run deploy --keep-vars
|
||||
|
||||
# 部署成功后把 sitemap 全量提交给 IndexNow(Bing/Yandex/Seznam 等共用端点),
|
||||
# 新页面/新文档以天级收录代替等爬虫回访。
|
||||
# key 是公开所有权凭证(对应 frontend-ssr/public/<key>.txt,IndexNow 靠抓取它验证)。
|
||||
- name: Submit sitemap to IndexNow
|
||||
if: success()
|
||||
run: |
|
||||
cd frontend-ssr
|
||||
node scripts/indexnow-submit.mjs --site https://nipw.cn --key d49c90fb2fb160cdd2cd4b09532bc380
|
||||
@@ -28,3 +28,8 @@ setting.json
|
||||
|
||||
# 本地调试脚本与实测产物(不入库,与 ipw-boce 的约定一致)
|
||||
tmp-smoke/
|
||||
|
||||
# Agent 工具产物(本机技能安装/会话状态,不入库)
|
||||
.agents/
|
||||
.zcode/
|
||||
skills-lock.json
|
||||
@@ -0,0 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
本文件记录面向用户/运维的版本变更。格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本(SemVer)。
|
||||
|
||||
## [v4.2.0] - 2026-10-25
|
||||
|
||||
> DNSSEC 从不可用变为可用、请求级解析能力同步 EO 特供、全站文档图灯箱、SEO 与部署链路整体升级。
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
- **DNSSEC 验证重写**:默认解析器内置境外 DO 友好列表(1.1.1.1 / 8.8.8.8 等,IPv4/IPv6 双栈 14 地址);新增 DNSKEY 自验签(TLD 与无 A 记录域名可验);DS 与 A 并行查询;解析器不支持 DNSSEC 时给出配置提示
|
||||
- **SSL 检查新增 SAN 字段**:返回证书全部主体别名(DNS + IP)
|
||||
- **IndexNow 接入**:key 静态文件 + 提交端点(GET 全量 / POST 增量)+ 部署后自动向 Bing/Yandex 提交 sitemap
|
||||
- **文档图灯箱**:全站文档图片点击放大(el-image-viewer),支持多图切换、滚轮缩放、旋转、Esc 关闭、路由切换自动关闭
|
||||
- **高级选项输入规整**:DNS 服务器填纯域名自动补全为 `https://域名/dns-query`,缺 scheme/路径/端口的写法自动补齐
|
||||
- **EO 特供获得请求级解析**:`dns=` / `dns4=` / `dns6=` / `resolve=` 全套参数与主线对齐,DialContext DNS 接管 + SSRF 预检同源
|
||||
- **ipdb 后台初始化**:首次启动不再被 450MB 下载阻塞,服务降级运行、库就绪一个上线一个
|
||||
- **DNSSEC 原理文档**:/dnssec 页新增原理介绍、验证链图(OpenAI 风)与字段说明
|
||||
|
||||
### 🛠 修复
|
||||
|
||||
- **DNSSEC「一直用不了」**:国内公共解析器带 DO 位也不返回 RRSIG(根因,实测 6 家);TLD/无 A 记录域没有验签路径;DS 只在失败路径查询——三项全修
|
||||
- **构建 EBUSY 静默失败**:运行中的服务锁住 `.output` 导致产物不更新,`.gitignore` 同步补齐 agent 工具目录
|
||||
- **内置中间件**:h3 不解码路径 `%2F`,带协议头的拨测目标经兜底转发必然拼坏——已与 fiber 语义对齐
|
||||
- **/ipv6 空壳页**:改为服务端 301(消除无 h1/无 title 的低质量页)
|
||||
- **sitemap**:全站显式枚举 + 权重标注
|
||||
- whois 摘除自定义解析后回归系统解析器;EO 同步
|
||||
|
||||
### ⚡ 优化(ipdb)
|
||||
|
||||
- 条件下载(ETag/Last-Modified):日更不再全量重拉 450MB
|
||||
- 单文件下载超时 60s → 10 分钟(大文件慢链路不再必然失败)
|
||||
- bilibili 并行查询 + 负缓存 + singleflight(/v1/location 首查 292ms → 复查 1.7ms)
|
||||
- 数据库加载器原子换指针:日更重载零服务真空窗
|
||||
|
||||
### ⚠️ 行为变化
|
||||
|
||||
- `/v1/whois` 不再接受 `dns=` 参数(静默忽略)
|
||||
- `/ipv6` 改为服务端 301 到 `/location`
|
||||
- DNSSEC 默认走境外解析器:网络屏蔽/劫持这些 UDP 53 时需配置 `dnssec-server`
|
||||
- 部署/构建前需停掉 3000/4000 端口的服务进程(EBUSY 会静默失败,本次已验证此坑)
|
||||
+74
-14
@@ -42,7 +42,7 @@ GET /info → {"version":"v1.2.3","capabilities":["probe","report","config","ot
|
||||
| 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) | TCP 连通性与 RTT 统计 |
|
||||
| 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 注册信息 |
|
||||
@@ -54,7 +54,36 @@ GET /info → {"version":"v1.2.3","capabilities":["probe","report","config","ot
|
||||
|
||||
**单栈模式**:`single-stack` 配为 `ipv4` / `ipv6` 时只跑对应栈,另一栈直接返回 `Skipped due to SINGLE_STACK=...`,不发起无意义的探测与超时。
|
||||
|
||||
**SSRF 防护**:`block-private-ips` 开启时,`detail` / `ssl` 对解析到内网/私有地址的目标直接返回伪造结果,出站客户端同时拒绝连接私有 IP、禁止跨跳重定向。
|
||||
**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` 进入拨号之前,口径与上述一致。
|
||||
- **带覆盖时按独立缓存键缓存**:规范化后的参数会拼进缓存键(见「七、缓存与并发」),不同服务器/不同映射的结果各占一条,既不串味、也不牺牲命中率。
|
||||
|
||||
---
|
||||
|
||||
@@ -198,16 +227,47 @@ POST /v1/ota
|
||||
|
||||
业务探测结果在节点侧缓存,避免同 key 洪打把上游打崩:
|
||||
|
||||
| 缓存 | 键 | TTL |
|
||||
|------|-----|-----|
|
||||
| `detail` | 归一化 URL | 5 分钟 |
|
||||
| `ssl` | URL | 5 分钟 |
|
||||
| `speed` | `url:version` | 5 分钟 |
|
||||
| `tcping` | `host:port:count` | 5 分钟 |
|
||||
| `whois` | 域名 | 5 分钟 |
|
||||
| `asn whois` | `AS<asn>` | 5 分钟 |
|
||||
| Bilibili 归属地(在线 API) | IP | 24 小时 |
|
||||
| 缓存 | 键(末尾拼请求级解析片段) | 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 小时(不分成败) |
|
||||
|
||||
- **失败结果 30s 后删除**:探测失败(如目标不可达)不缓存整 5 分钟,避免故障恢复后长时间返回旧结论。
|
||||
- **singleflight 合并**:`detail` / `ssl` / `tcping` / `speed` 的并发同 key 请求合并为一次真实探测。
|
||||
- **定时清扫**:每 10 分钟清理过期缓存条目与 Bilibili 缓存,防止只靠"同 key 重访才淘汰"时内存无限增长。
|
||||
- **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)。修法是给合并键加探测类型前缀,暂未处理。
|
||||
Generated
+6
-6
@@ -783,8 +783,8 @@ packages:
|
||||
shiki@2.1.0:
|
||||
resolution: {integrity: sha512-yvKPdNGLXZv7WC4bl7JBbU3CEcUxnBanvMez8MG3gZXKpClGL4bHqFyLhTx+2zUvbjClUANs/S22HXb7aeOgmA==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
source-map-js@1.2.2:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
space-separated-tokens@2.0.2:
|
||||
@@ -1313,7 +1313,7 @@ snapshots:
|
||||
'@vue/shared': 3.5.41
|
||||
entities: 7.0.1
|
||||
estree-walker: 2.0.2
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
'@vue/compiler-dom@3.5.41':
|
||||
dependencies:
|
||||
@@ -1330,7 +1330,7 @@ snapshots:
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.21
|
||||
postcss: 8.5.26
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
'@vue/compiler-ssr@3.5.41':
|
||||
dependencies:
|
||||
@@ -1603,7 +1603,7 @@ snapshots:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
preact@10.29.8: {}
|
||||
|
||||
@@ -1672,7 +1672,7 @@ snapshots:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
source-map-js@1.2.2: {}
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
|
||||
@@ -124,15 +124,70 @@ function updateViewport() {
|
||||
nextTick(fixDividerTabindex)
|
||||
}
|
||||
let umamiScript: HTMLScriptElement | null = null
|
||||
|
||||
// ==================== 全站级 SEO 兜底 ====================
|
||||
// 以下标签对每一页生效,页面级 useHead 不必再重复声明(index/whois 原有的
|
||||
// canonical / og:url / twitter:card 已上收到这里,避免 head 出现两条 canonical)。
|
||||
//
|
||||
// canonical 按纯路径(不含 query):工具页普遍接受 ?ip= / ?site= 等参数,
|
||||
// 同一内容会以无限多组 query 变体出现,SEO 审计由此判定「sitemap 缺少重要页面」;
|
||||
// 统一收敛到裸路径,爬虫就不会把 ?ip=x 当独立页面收录。
|
||||
const siteOrigin = config.siteUrl.endsWith('/') ? config.siteUrl : config.siteUrl + '/'
|
||||
const canonicalUrl = computed(() => new URL(route.path, siteOrigin).href)
|
||||
// 站点级结构化数据(WebSite + Organization):给搜索引擎的实体锚点,
|
||||
// 各工具页的 WebApplication 与文档页的 BreadcrumbList 通过 url 与之关联。
|
||||
const siteJsonLd = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': siteOrigin + '#website',
|
||||
name: config.siteName,
|
||||
alternateName: 'Lemon IPW',
|
||||
url: siteOrigin,
|
||||
inLanguage: 'zh-CN',
|
||||
description: '专业IP查询与网络测试工具平台:IPv4/IPv6地址查询、归属地定位、网站测速、DNS解析、SSL证书检测、TCPing、Whois查询。'
|
||||
},
|
||||
{
|
||||
'@type': 'Organization',
|
||||
'@id': siteOrigin + '#organization',
|
||||
name: config.siteName,
|
||||
url: siteOrigin,
|
||||
logo: siteOrigin + 'favicon.svg',
|
||||
sameAs: ['https://github.com/nomdn/ipw-cn']
|
||||
}
|
||||
]
|
||||
})
|
||||
useHead({
|
||||
// 页面没写 title 时的兜底(正常页面各自已带完整标题)
|
||||
titleTemplate: (title?: string) => title || config.siteName,
|
||||
link: [
|
||||
{ rel: 'canonical', href: canonicalUrl },
|
||||
],
|
||||
meta: config.noindex
|
||||
? [
|
||||
{ name: 'robots', content: 'noindex, nofollow' },
|
||||
{ name: 'googlebot', content: 'noindex, nofollow' },
|
||||
{ name: 'bingbot', content: 'noindex, nofollow' },
|
||||
{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'}
|
||||
{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'},
|
||||
{ property: 'og:url', content: canonicalUrl },
|
||||
{ property: 'og:site_name', content: config.siteName },
|
||||
{ property: 'og:locale', content: 'zh_CN' },
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
]
|
||||
: [{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'}],
|
||||
: [
|
||||
{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'},
|
||||
{ property: 'og:url', content: canonicalUrl },
|
||||
{ property: 'og:site_name', content: config.siteName },
|
||||
{ property: 'og:locale', content: 'zh_CN' },
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: siteJsonLd,
|
||||
}
|
||||
]
|
||||
});
|
||||
onMounted(() => {
|
||||
mediaQueryList = window.matchMedia('(max-width: 768px)');
|
||||
@@ -318,6 +373,8 @@ onMounted(() => {
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- 全站文档图片灯箱:委托拦截 .markdown / .markdown-body 内的图片点击 -->
|
||||
<ImageLightbox />
|
||||
</template>
|
||||
<style scoped>
|
||||
@import "~/style.css";
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { isIPv6 } from 'is-ip'
|
||||
|
||||
/**
|
||||
* 高级选项:请求级解析参数(与 ipw-ak 的 AdvancedOptions 同源)
|
||||
*
|
||||
* - 展开后按地址族分列:IPv4 → dns4 / resolve4,IPv6 → dns6 / resolve6
|
||||
* - stack 非空时(单栈页)只渲染对应栈那一列,且不显示 dns / resolve 通用行:
|
||||
* 单栈页里两者与 dns4=/dns6= 等价,双份输入框只会让人纠结该填哪个;
|
||||
* tcping / speed 的前端两个栈是分开的页面,另一列的参数永远吃不到。
|
||||
* - dns / resolve 是双栈页的两栈通用兜底,dns4/dns6、resolve4/resolve6 非空时覆盖它们。
|
||||
*
|
||||
* 用法:
|
||||
* <AdvancedOptions ref="adv" /> 双栈页(dns4/dns6 + resolve4/resolve6)
|
||||
* <AdvancedOptions ref="adv" stack="ipv4" /> 单栈页,只给 IPv4 一列
|
||||
* <AdvancedOptions ref="adv" :show-resolve="false" /> 仅 dns4/dns6(dns、whois 页不支持强制解析)
|
||||
*
|
||||
* 页面里取参数:
|
||||
* withQuery(baseUrl, adv.value?.query() ?? {})
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 是否显示 resolve / resolve4 / resolve6(dns、whois 页不支持强制解析) */
|
||||
showResolve?: boolean
|
||||
/** 只显示某一栈的配置项;单栈页面(tcping / speed 的 v4、v6 页)传,双栈页面不传 */
|
||||
stack?: 'ipv4' | 'ipv6' | ''
|
||||
}>(),
|
||||
{ showResolve: true, stack: '' },
|
||||
)
|
||||
|
||||
const expanded = ref(false)
|
||||
/** 两栈通用的 DNS 服务器:dns4/dns6 非空时覆盖它 */
|
||||
const dns = ref('')
|
||||
const dns4 = ref('')
|
||||
const dns6 = ref('')
|
||||
/** 两栈通用的强制解析:resolve4/resolve6 非空时覆盖它 */
|
||||
const resolve = ref('')
|
||||
const resolve4 = ref('')
|
||||
const resolve6 = ref('')
|
||||
|
||||
/** 域名 HOST 判定:含点、标签为字母数字/连字符/下划线(宽松匹配,畸形值交给后端报错) */
|
||||
const DOMAIN_RE = /^[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?(?:\.[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?)+$/i
|
||||
const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/
|
||||
|
||||
/**
|
||||
* 把用户输入的单个 DNS 服务器条目规整成后端认可的形式:
|
||||
* - 已带协议的 URL:路径为空或仅 "/" 时补上 DoH 标准路径 /dns-query
|
||||
* - 无协议但含 "/"(缺了 scheme 的 DoH 地址):补 https:// 前缀,路径按上条处理
|
||||
* - 纯域名 HOST(无端口无路径):包裹为 https://域名/dns-query
|
||||
* - 无端口的裸 IP(v4 / 裸 v6):补默认端口 53(v6 顺手加方括号)
|
||||
* - 其余(host:port、[v6]:53 等)原样返回,交给后端校验
|
||||
*/
|
||||
function normalizeDnsEntry(raw: string): string {
|
||||
const v = raw.trim()
|
||||
if (!v) return ''
|
||||
|
||||
// 1) 已带协议的 URL
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return completeDohPath(v)
|
||||
// 2) 无协议但含 "/":缺 scheme 的 DoH 地址
|
||||
if (v.includes('/')) return completeDohPath('https://' + v)
|
||||
// 3) 带方括号的 v6(含/含端口均原样,端口要求交后端校验)
|
||||
if (v.startsWith('[')) return v
|
||||
// 4) host:port:IP 或域名都按 UDP 语义原样(写了端口就尊重端口)
|
||||
if (/^[^:]+:\d+$/.test(v)) return v
|
||||
// 5) 无方括号的裸 v6:补 [..]:53
|
||||
if (isIPv6(v)) return `[${v}]:53`
|
||||
// 6) 裸 IPv4:补 :53
|
||||
if (IPV4_RE.test(v)) return `${v}:53`
|
||||
// 7) 纯域名 HOST:包裹为标准 DoH URL
|
||||
if (DOMAIN_RE.test(v)) return `https://${v}/dns-query`
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
/** 补齐 DoH URL 的标准路径:空路径/根路径 → /dns-query;自带路径(如 /custom)原样保留 */
|
||||
function completeDohPath(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
if (!u.hostname) return url
|
||||
if (u.pathname === '/' || u.pathname === '') u.pathname = '/dns-query'
|
||||
return u.toString()
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/** 逗号分隔列表逐项规整(空项丢弃) */
|
||||
function normalizeDnsList(value: string): string {
|
||||
return value
|
||||
.split(',')
|
||||
.map(p => normalizeDnsEntry(p))
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
/** 组装请求级解析参数(空值不下发;DNS 服务器逐项规整) */
|
||||
function query(): Record<string, string> {
|
||||
const q: Record<string, string> = {}
|
||||
if (dns.value.trim()) q.dns = normalizeDnsList(dns.value)
|
||||
if (dns4.value.trim()) q.dns4 = normalizeDnsList(dns4.value)
|
||||
if (dns6.value.trim()) q.dns6 = normalizeDnsList(dns6.value)
|
||||
if (props.showResolve) {
|
||||
if (resolve.value.trim()) q.resolve = resolve.value.trim()
|
||||
if (resolve4.value.trim()) q.resolve4 = resolve4.value.trim()
|
||||
if (resolve6.value.trim()) q.resolve6 = resolve6.value.trim()
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
defineExpose({ query })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="adv">
|
||||
<!-- 用原生 button:页面的全局 .el-button{width:165px} 会把 el-button 撑成实心大按钮 -->
|
||||
<button type="button" class="adv__toggle" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||
<span class="adv__caret" :class="{ 'is-open': expanded }">▸</span>
|
||||
<span>高级选项</span>
|
||||
</button>
|
||||
|
||||
<div v-if="expanded" class="adv__body">
|
||||
<!-- 两栈通用的 DNS 服务器(dns4/dns6 非空时覆盖它)。仅双栈页显示:
|
||||
单栈页里 dns= 与 dns4=/dns6= 对该栈请求效果等价(后端按栈取值),
|
||||
再给一个"两栈通用"行只会让人纠结该填哪个 —— 单栈只留对应栈那一列。 -->
|
||||
<div v-if="!stack" class="adv__field adv__field--wide">
|
||||
<span class="adv__label">DNS 服务器(dns,两栈通用)</span>
|
||||
<el-input v-model="dns" clearable placeholder="如 1.1.1.1:53 / doh.pub / dns.google,逗号分隔" />
|
||||
</div>
|
||||
|
||||
<!-- 两栈通用的强制解析(resolve4/6 非空时覆盖它),同上仅双栈页显示 -->
|
||||
<div v-if="!stack && showResolve" class="adv__field adv__field--wide">
|
||||
<span class="adv__label">强制解析(resolve,两栈通用)</span>
|
||||
<el-input v-model="resolve" clearable placeholder="如 example.com:1.2.3.4,逗号分隔" />
|
||||
</div>
|
||||
|
||||
<div class="adv__grid" :class="{ 'is-single': !!stack }">
|
||||
<section v-if="!stack || stack === 'ipv4'" class="adv__col">
|
||||
<h4 class="adv__col-title">IPv4</h4>
|
||||
<div class="adv__field">
|
||||
<span class="adv__label">DNS 服务器(dns4)</span>
|
||||
<el-input v-model="dns4" clearable placeholder="如 1.1.1.1:53 / doh.pub,逗号分隔" />
|
||||
</div>
|
||||
<div v-if="showResolve" class="adv__field">
|
||||
<span class="adv__label">强制解析(resolve4)</span>
|
||||
<el-input v-model="resolve4" clearable placeholder="如 example.com:1.2.3.4" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="!stack || stack === 'ipv6'" class="adv__col">
|
||||
<h4 class="adv__col-title">IPv6</h4>
|
||||
<div class="adv__field">
|
||||
<span class="adv__label">DNS 服务器(dns6)</span>
|
||||
<el-input v-model="dns6" clearable placeholder="如 [2400:3200::1]:53,逗号分隔" />
|
||||
</div>
|
||||
<div v-if="showResolve" class="adv__field">
|
||||
<span class="adv__label">强制解析(resolve6)</span>
|
||||
<el-input v-model="resolve6" clearable placeholder="如 example.com:[::1]" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p class="adv__hint">
|
||||
DNS 服务器可填 <code>ip:port</code>、DoH 地址或纯域名(自动补全为
|
||||
<code>https://域名/dns-query</code>),逗号分隔多个;强制解析格式
|
||||
<code>域名:IP</code> 或 <code>域名:CNAME</code>,IPv6 目标写 <code>[::1]</code>。{{
|
||||
stack ? '参数直接作用于本栈解析;双栈页的 dns / resolve 通用项在此页不展示。'
|
||||
: 'dns4 / dns6、resolve4 / resolve6 非空时覆盖 dns / resolve,分别只作用于对应栈。'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.adv {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.adv__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--el-color-primary, #299764);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.adv__caret {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.adv__caret.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.adv__body {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid var(--el-border-color-lighter, #ebeef5);
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-lighter, #fafafa);
|
||||
}
|
||||
|
||||
.adv__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 1rem;
|
||||
}
|
||||
|
||||
/* 单栈页只渲染一列:撑满,别再留半幅空白 */
|
||||
.adv__grid.is-single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.adv__col-title {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--el-text-color-secondary, #909399);
|
||||
}
|
||||
|
||||
.adv__field {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.adv__field--wide {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.adv__label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--el-text-color-secondary, #909399);
|
||||
}
|
||||
|
||||
.adv__hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-secondary, #909399);
|
||||
}
|
||||
|
||||
.adv__hint code {
|
||||
padding: 0 0.2rem;
|
||||
border-radius: 2px;
|
||||
background: var(--el-fill-color, #f0f2f5);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.adv__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
/**
|
||||
* 全站文档图片灯箱:全局点击委托 + Element Plus 自带查看器(el-image-viewer)。
|
||||
*
|
||||
* 在 app.vue 挂载一次即覆盖所有 v-html 文档容器(.markdown / .markdown-body),
|
||||
* 页面无需任何接线。命中规则:容器内的 <img>,且不在 <a> 链接里(放行导航)。
|
||||
* 查看器自带 Esc 关闭、滚轮缩放、旋转工具栏;hide-on-click-modal 允许点遮罩关闭。
|
||||
*/
|
||||
const visible = ref(false)
|
||||
const urlList = ref<string[]>([])
|
||||
const initialIndex = ref(0)
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
const img = target.closest?.<HTMLImageElement>('.markdown img, .markdown-body img')
|
||||
if (!img) return
|
||||
if (img.closest('a')) return // 图片套在链接里 → 放行导航,不开灯箱
|
||||
if (img.complete && img.naturalWidth === 0) return // 加载失败的裂图不进灯箱
|
||||
const src = img.currentSrc || img.src
|
||||
if (!src) return
|
||||
e.preventDefault()
|
||||
|
||||
// 收集同一容器内的全部图片:EP 查看器由此获得左右箭头/键盘多图切换
|
||||
// (doc 页一篇文章带几十张截图,单图模式等于白瞎了查看器的切换能力)
|
||||
const container = img.closest('.markdown, .markdown-body')
|
||||
const imgs = container ? Array.from(container.querySelectorAll('img')) : [img]
|
||||
const list = imgs
|
||||
.filter(i => !(i.complete && i.naturalWidth === 0))
|
||||
.map(i => i.currentSrc || i.src)
|
||||
.filter(Boolean)
|
||||
if (!list.length) return
|
||||
|
||||
urlList.value = list
|
||||
initialIndex.value = Math.max(0, list.indexOf(src))
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
// 打开期间锁 body 滚动(EP 查看器自带 Esc 关闭与缩放/旋转工具栏)
|
||||
watch(visible, (v) => {
|
||||
document.body.style.overflow = v ? 'hidden' : ''
|
||||
})
|
||||
|
||||
// 路由切换时强制关闭:灯箱开着按浏览器后退,查看器不能跨页面残留
|
||||
const route = useRoute()
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
visible.value = false
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => document.addEventListener('click', onClick))
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onClick)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-image-viewer
|
||||
v-if="visible"
|
||||
:url-list="urlList"
|
||||
:initial-index="initialIndex"
|
||||
:z-index="3000"
|
||||
hide-on-click-modal
|
||||
@close="visible = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 文档图片可点的视觉暗示(非 scoped:v-html 内容拿不到 scope id) */
|
||||
.markdown img,
|
||||
.markdown-body img {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
</style>
|
||||
@@ -44,6 +44,7 @@ useHead({
|
||||
|
||||
interface ASNResult {
|
||||
ip: string
|
||||
lemondb_asn?: { asn: string; org: string }
|
||||
geolite2_asn?: { asn: string; org: string }
|
||||
dbip_asn?: { asn: string; org: string }
|
||||
ip2location_asn?: { asn: string; as: string }
|
||||
@@ -56,7 +57,12 @@ const result = ref<ASNResult | null>(null)
|
||||
const error = ref('')
|
||||
const userIP = ref('')
|
||||
|
||||
const apiList = config.IPLocationAPI
|
||||
// ASN 页节点池:DualStack ∩ IPLocationAPI 的交集(按 id 取两个池都有的节点)。
|
||||
// 交集保证:节点在 IPLocationAPI 里(中间件 asn 分支按它解析转发),
|
||||
// 同时也是双栈池在册的全功能节点;任一池单独加节点都不会进本页。
|
||||
const apiList = config.IPLocationAPI.filter((loc) =>
|
||||
config.APIBaseURL.DualStack.some((d) => d.id === loc.id)
|
||||
)
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
|
||||
@@ -145,6 +151,7 @@ function getStatusClass(status: string): string {
|
||||
<el-input
|
||||
v-model="tmpIP"
|
||||
placeholder="请输入IP地址(如:1.1.1.1)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="queryASN()"
|
||||
@@ -172,10 +179,17 @@ function getStatusClass(status: string): string {
|
||||
<td class="table-label">IP 地址</td>
|
||||
<td class="table-value">{{ result.ip }}</td>
|
||||
</tr>
|
||||
<tr v-if="result.lemondb_asn?.asn">
|
||||
<td class="table-label">LemonDB ASN</td>
|
||||
<td class="table-value">
|
||||
{{ result.lemondb_asn.asn }}
|
||||
<span v-if="result.lemondb_asn.org" style="color: #999; font-size: 0.9em;">{{ result.lemondb_asn.org }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.geolite2_asn?.asn">
|
||||
<td class="table-label">Maxmind ASN</td>
|
||||
<td class="table-value">
|
||||
AS{{ result.geolite2_asn.asn }}
|
||||
{{ result.geolite2_asn.asn }}
|
||||
<span v-if="result.geolite2_asn.org" style="color: #999; font-size: 0.9em;">{{ result.geolite2_asn.org }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -189,7 +203,7 @@ function getStatusClass(status: string): string {
|
||||
<tr v-if="result.ip2location_asn?.asn">
|
||||
<td class="table-label">IP2Location ASN</td>
|
||||
<td class="table-value">
|
||||
{{ result.ip2location_asn.asn }}
|
||||
AS{{ result.ip2location_asn.asn }}
|
||||
<span v-if="result.ip2location_asn.as" style="color: #999; font-size: 0.9em;">{{ result.ip2location_asn.as }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { isIPv6 } from 'is-ip';
|
||||
import { formatTime } from '../../utils/tools';
|
||||
import { formatTime, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
const route = useRoute()
|
||||
|
||||
useHead({
|
||||
@@ -74,12 +75,15 @@ const recordTypes = [
|
||||
{ value: 'ptr', label: 'PTR 记录' }
|
||||
]
|
||||
|
||||
// 高级选项:DNS 拨测问的就是真实解析结果,只取 DNS 服务器(dns4/dns6),不支持强制解析
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
|
||||
const dnsServerFetches = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
...config.APIBaseURL.IPv4,
|
||||
...config.APIBaseURL.IPv6
|
||||
].map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + encodeURIComponent(domain.value));
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/dns/" + recordType.value + "/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
|
||||
const { data, error, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -199,7 +203,8 @@ const doc = page.value;
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
clearable
|
||||
/>
|
||||
<!-- aria-label 落到 EP select 内部的原生 input 上,否则 Lighthouse 的 label 审计必挂 -->
|
||||
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select" aria-label="DNS 记录类型">
|
||||
@@ -219,6 +224,9 @@ const doc = page.value;
|
||||
查询
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" :show-resolve="false" />
|
||||
|
||||
<div class="result-section">
|
||||
<table class="result-table" v-if="results.length > 0">
|
||||
<thead>
|
||||
@@ -293,6 +301,24 @@ const doc = page.value;
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
|
||||
/* 本页行内容比通用行宽(input 420 + select 150 + button 165 + 间距 ≈ 765px),
|
||||
换行点更高:容器低于 780px(≈自然换行点)时换行元素铺满整行,
|
||||
消除 615~780px 之间「换行但仍固定宽」的夹缝(tool-common 的通用阈值只覆盖到 615px)。
|
||||
select 的 width 是行内 style,需 !important 才能覆盖。 */
|
||||
@container (max-width: 780px) {
|
||||
.el-input,
|
||||
.el-select,
|
||||
.el-button {
|
||||
width: 100% !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.el-input,
|
||||
.el-select {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@@ -168,6 +168,11 @@ async function getUserIP() {
|
||||
}
|
||||
return userIP.value
|
||||
}
|
||||
|
||||
const { data: page, error } = await useAsyncData('dnssec-page', () =>
|
||||
$fetch('/api/markdown/dnssec')
|
||||
)
|
||||
const doc = page.value;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -182,6 +187,7 @@ async function getUserIP() {
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="checkDNSSEC()"
|
||||
@@ -255,10 +261,12 @@ async function getUserIP() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="markdown">
|
||||
<div v-html="doc"></div>
|
||||
</div>
|
||||
|
||||
<blockquote>
|
||||
DNSSEC 原理介绍即将上线<br/>
|
||||
DNSSEC 通过 DNSKEY、RRSIG、DS 记录为 DNS 数据提供完整性验证,防止 DNS 欺骗攻击。<br/>
|
||||
如果显示 "验证失败",可能原因:域名未配置 DNSSEC、DNSKEY 密钥不匹配、DS 记录缺失等。<br/>
|
||||
如果显示 "验证失败",可能原因:域名未配置 DNSSEC、DNSKEY 密钥不匹配、DS 记录缺失等(详见上方原理介绍)。<br/>
|
||||
<a href="/dns" target="_blank">DNS 解析查询</a> | <a href="/whois" target="_blank">Whois 查询</a><br/>
|
||||
访客IP: {{ userIP || '获取中...' }},您的网络{{ isIPv6(userIP) ? 'IPv6' : 'IPv4' }}优先<br/>
|
||||
</blockquote>
|
||||
@@ -269,6 +277,26 @@ async function getUserIP() {
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
|
||||
.markdown :deep(a) {
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
/* 1240px 宽的验证链图必须缩进 60% 的内容列,桌面端也要约束(仅移动端限制不够) */
|
||||
.markdown :deep(img) {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.markdown :deep(a) {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.markdown :deep(img) {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { getDocMeta } from '../../../config/doc'
|
||||
import { config } from '../../../config/index'
|
||||
|
||||
// 窄屏下这条侧栏被 CSS 隐藏(导航在顶栏抽屉里),但照样要花钱水合 ——
|
||||
// 详见 app/pages/doc/index.vue 的同名注释。
|
||||
@@ -11,10 +12,34 @@ const path = computed(() => route.path)
|
||||
const { data: page } = await useFetch(() => `/api/markdown${path.value}`)
|
||||
|
||||
const meta = computed(() => getDocMeta(route.path))
|
||||
// <title>/meta 优先取 seoXxx 加长版(Bing 审计判文档页标题与描述过短);
|
||||
// 侧栏菜单与卡片仍读原字段展示,不受影响。
|
||||
const seoTitle = computed(() => meta.value.seoTitle ?? meta.value.title)
|
||||
const seoDescription = computed(() => meta.value.seoDescription ?? meta.value.description)
|
||||
const docUrl = computed(() => new URL(route.path, config.siteUrl).href)
|
||||
useHead({
|
||||
title: computed(() => meta.value.title),
|
||||
title: seoTitle,
|
||||
titleTemplate: computed(() => `%s | ${config.siteName}`),
|
||||
meta: [
|
||||
{ name: 'description', content: computed(() => meta.value.description) }
|
||||
{ name: 'description', content: seoDescription },
|
||||
{ property: 'og:title', content: computed(() => `${seoTitle.value} | ${config.siteName}`) },
|
||||
{ property: 'og:description', content: seoDescription },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'article' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: computed(() => JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: '首页', item: config.siteUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: '文档', item: new URL('/doc', config.siteUrl).href },
|
||||
{ '@type': 'ListItem', position: 3, name: meta.value.title, item: docUrl.value }
|
||||
]
|
||||
}))
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { getDocMeta } from '../../../config/doc'
|
||||
import { config } from '../../../config/index'
|
||||
|
||||
// 窄屏(≤768px)下这条侧栏被 CSS 整个隐藏,导航改由顶栏抽屉承载。
|
||||
// 但「只是藏起来」不等于不花钱:Vue 依旧会把整棵 40 项的 el-menu 水合一遍
|
||||
@@ -14,10 +15,32 @@ const { data: page } = await useAsyncData('doc-index', () =>
|
||||
const doc = page.value;
|
||||
|
||||
const meta = getDocMeta('/doc')
|
||||
// <title>/meta 优先取 seoXxx 加长版(Bing 审计判文档页标题与描述过短);
|
||||
// 侧栏菜单与卡片仍读原字段展示,不受影响。
|
||||
const seoTitle = meta.seoTitle ?? meta.title
|
||||
const seoDescription = meta.seoDescription ?? meta.description
|
||||
useHead({
|
||||
title: meta.title,
|
||||
title: seoTitle,
|
||||
titleTemplate: `%s | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: meta.description }
|
||||
{ name: 'description', content: seoDescription },
|
||||
{ property: 'og:title', content: `${seoTitle} | ${config.siteName}` },
|
||||
{ property: 'og:description', content: seoDescription },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: '首页', item: config.siteUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: meta.title, item: new URL('/doc', config.siteUrl).href }
|
||||
]
|
||||
})
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,14 +5,13 @@ import { config } from '../../config/index';
|
||||
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
|
||||
import { isIPv4 } from '../../utils/tools';
|
||||
const route = useRoute();
|
||||
const canonicalUrl = computed(() => new URL(route.path, config.siteUrl).toString());
|
||||
// canonical 由 app.vue 全局下发(见下方 useHead 注释)。
|
||||
|
||||
useHead({
|
||||
title: `${config.siteName} | IP查询工具 | IPv4/IPv6地址查询与网络测试平台`,
|
||||
titleTemplate: '%s',
|
||||
link: [
|
||||
{ rel: 'canonical', href: canonicalUrl.value }
|
||||
],
|
||||
// canonical / og:url / twitter:card 由 app.vue 全局下发(按纯路径去 query),
|
||||
// 这里不再重复声明,避免 head 里出现两条 canonical。
|
||||
meta: [
|
||||
{ name: 'description', content: `${config.siteName}提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台` },
|
||||
{ name: 'keywords', content: 'ipv6,ipv4,ip查询,ipv6查询,ipv4查询,ipv6地址查询,ipv4地址查询,网络测速,DNS查询,SSL检测,TCPing,IP归属地,IPv6优先' },
|
||||
@@ -20,8 +19,6 @@ useHead({
|
||||
{ property: 'og:description', content: '提供IPv4/IPv6地址查询、网络测速、DNS解析、SSL检测等全方位网络诊断工具,助力IPv6普及与部署' },
|
||||
{ property: 'og:image', content: `${config.siteUrl}favicon.svg` },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:url', content: canonicalUrl.value },
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
@@ -57,7 +54,7 @@ useHead({
|
||||
'@type': 'WebApplication',
|
||||
name: config.siteName,
|
||||
description: `${config.siteName}提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台`,
|
||||
url: canonicalUrl.value,
|
||||
url: new URL(route.path, config.siteUrl).toString(),
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Any',
|
||||
offers: {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(async () => {
|
||||
const urlParam = route.query.ip;
|
||||
// ip 参数缺失时不要拼出 ?ip=undefined
|
||||
await navigateTo(urlParam ? { path: '/location', query: { ip: urlParam } } : { path: '/location' });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="redirecting">正在跳转到 IP 归属地查询…</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.redirecting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 50vh;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,8 @@ import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass } from '../../utils/tools';
|
||||
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
|
||||
|
||||
const route = useRoute()
|
||||
@@ -48,6 +49,8 @@ const loading = ref(false)
|
||||
const result = ref<any[]>([])
|
||||
const error = ref('')
|
||||
const domain = ref('')
|
||||
// 高级选项(dns6 / resolve / resolve6):用户填了才拼进请求
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
|
||||
/** 服务器结果接口 */
|
||||
interface ServerResult {
|
||||
@@ -72,7 +75,7 @@ const serverResults = ref<ServerResult[]>([])
|
||||
|
||||
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
|
||||
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -82,7 +85,7 @@ const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
|
||||
/** IPv6 专用服务器:仅支持 IPv6 的节点 */
|
||||
const ipv6Fetches = config.APIBaseURL.IPv6.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -180,7 +183,8 @@ onMounted(() => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="SpeedTest()"
|
||||
@@ -191,6 +195,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" stack="ipv6" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { isIPv6 } from 'is-ip';
|
||||
import { extractHost } from '../../utils/tools';
|
||||
import { extractHost, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
const route = useRoute()
|
||||
|
||||
useHead({
|
||||
@@ -77,6 +78,8 @@ const port = ref('80')
|
||||
const userIP = ref('')
|
||||
const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
// 高级选项(dns6 / resolve / resolve6):用户填了才拼进请求
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
@@ -86,7 +89,7 @@ const allServers = [
|
||||
];
|
||||
|
||||
const tcpingFetches = allServers.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value));
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value) + "&version=ipv6", adv.value?.query() ?? {}));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -195,11 +198,13 @@ onMounted(() => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-input
|
||||
v-model="port"
|
||||
placeholder="端口号(默认 80)"
|
||||
placeholder="端口号(默认 80)"
|
||||
clearable
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<el-button
|
||||
@@ -211,6 +216,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" stack="ipv6" />
|
||||
|
||||
<div class="result-section">
|
||||
<table class="result-table">
|
||||
<thead>
|
||||
@@ -280,6 +287,20 @@ onMounted(() => {
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
|
||||
/* 本页行是双输入框(420×2 + 按钮 165 + 间距 ≈ 1035px),换行点远高于通用阈值的
|
||||
615px:容器低于 1050px(≈自然换行点)时换行元素铺满整行,消除中间的夹缝区。 */
|
||||
@container (max-width: 1050px) {
|
||||
.el-input,
|
||||
.el-button {
|
||||
width: 100% !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { CircleCheckFilled, CircleCloseFilled,InfoFilled,Position } from '@element-plus/icons-vue';
|
||||
import { extractHost, getStatusCodeClass, formatTime, formatSize, formatSpeed } from '../../utils/tools';
|
||||
import { extractHost, getStatusCodeClass, formatTime, formatSize, formatSpeed, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -63,7 +64,9 @@ interface PerformanceCheckItem {
|
||||
download_speed: number
|
||||
is_reachable: boolean
|
||||
}
|
||||
const apiList = config.APIBaseURL.DualStack
|
||||
const apiList = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
]
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const tmpDomain = ref('https://www.zakoflare.com')
|
||||
@@ -71,7 +74,9 @@ const testDomain = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref<PerformanceCheckResponse | null>(null)
|
||||
const checkUrl = computed(() => "/middleware/" + backendID.value + "/detail/" + testDomain.value);
|
||||
// 高级选项(dns4/dns6 + resolve/resolve4/resolve6):本页一次探测两栈
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
const checkUrl = computed(() => withQuery("/middleware/" + backendID.value + "/detail/" + testDomain.value, adv.value?.query() ?? {}));
|
||||
// testDomain 是完整 URL(含 https:// 与路径斜杠),服务端 [...slug].get.ts 依赖
|
||||
// 分段重组协议部分,这里刻意不做 encodeURIComponent
|
||||
|
||||
@@ -136,7 +141,8 @@ onMounted(() => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="checkWeb()"
|
||||
@@ -147,6 +153,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
|
||||
import { useRoute } from 'vue-router'
|
||||
import { isIPv4 } from '../../utils/tools';
|
||||
import { wgs84ToGcj02 } from '../../utils/gcj02';
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
|
||||
@@ -47,6 +48,8 @@ interface IPLocationType {
|
||||
bilibili?: any;
|
||||
ip?: string;
|
||||
geocn?: any;
|
||||
lemondb?: any;
|
||||
lemondb_asn?: any;
|
||||
ip2region?: any;
|
||||
ip2location?: any;
|
||||
ip2location_asn?: any;
|
||||
@@ -90,6 +93,88 @@ const ipAddress = ref('');
|
||||
const IPLocation = ref<IPLocationType>({});
|
||||
const UserIP = ref('');
|
||||
const error = ref('')
|
||||
|
||||
// —— 归属地地图:高德瓦片(GCJ-02)+ Leaflet,免 key ——
|
||||
// 每个有坐标的来源各画一个点(bilibili / geocn 坐标已是 GCJ-02 直接用;
|
||||
// maxmind / ip2location / dbip / lemondb 为 WGS84 → 转 GCJ-02 与底图对齐),
|
||||
// 多点同时展示可直观看出各库定位的差异与离散度。
|
||||
const mapContainer = ref<HTMLElement | null>(null)
|
||||
let mapInstance: any = null
|
||||
let markerGroup: any = null
|
||||
|
||||
const SOURCE_DEFS = [
|
||||
{ key: 'lemondb', lat: 'latitude', lng: 'longitude', gcj: false, label: 'LemonDB', color: '#ffab00' },
|
||||
{ key: 'bilibili', lat: 'latitude', lng: 'longitude', gcj: true, label: 'bilibili Live', color: '#299764' },
|
||||
{ key: 'maxmind_city', lat: 'latitude', lng: 'longitude', gcj: false, label: 'MaxMind', color: '#1d4ed8' },
|
||||
{ key: 'ip2location', lat: 'latitude', lng: 'longitude', gcj: false, label: 'IP2Location', color: '#f97316' },
|
||||
{ key: 'dbip_city', lat: 'latitude', lng: 'longitude', gcj: false, label: 'DB-IP', color: '#8b5cf6' },
|
||||
{ key: 'geocn', lat: 'latitude', lng: 'longitude', gcj: true, label: 'GeoCN', color: '#0ea5e9' },
|
||||
]
|
||||
|
||||
const mapPoints = computed(() => {
|
||||
const r = IPLocation.value
|
||||
if (!r) return []
|
||||
const pts: Array<{ lat: number; lng: number; label: string; color: string }> = []
|
||||
for (const s of SOURCE_DEFS) {
|
||||
const lat = parseFloat(r[s.key]?.[s.lat])
|
||||
const lng = parseFloat(r[s.key]?.[s.lng])
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
|
||||
// LemonDB 全库统一 WGS-84(coord_system 恒为 wgs84,这里留个动态判断作前向兼容,
|
||||
// 若哪天源库换坐标系标注会自动跟随);其余源维持静态标记
|
||||
const gcj = s.gcj || r[s.key]?.coord_system === 'gcj02'
|
||||
const p = gcj ? { lat, lng } : wgs84ToGcj02(lat, lng)
|
||||
pts.push({ ...p, label: s.label, color: s.color })
|
||||
}
|
||||
}
|
||||
return pts
|
||||
})
|
||||
|
||||
let leafletModule: any = null
|
||||
async function renderLocationMap() {
|
||||
if (!import.meta.client || !mapPoints.value.length || !mapContainer.value) return
|
||||
const pts = mapPoints.value
|
||||
leafletModule = leafletModule || (await import('leaflet'))
|
||||
const L = leafletModule.default ?? leafletModule
|
||||
if (!mapInstance) {
|
||||
mapInstance = L.map(mapContainer.value, { attributionControl: true })
|
||||
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
maxZoom: 18,
|
||||
// 高德栅格瓦片原生层级上限约 17:部分区域(尤其县乡)z=18 会返回纯米色
|
||||
// "无数据"空瓦片。声明 maxNativeZoom 后,Leaflet 对更高缩放自动放大
|
||||
// 17 级瓦片(轻度模糊但绝不变空白)。
|
||||
maxNativeZoom: 17,
|
||||
attribution: '© <a href="https://www.amap.com/" target="_blank" rel="noreferrer">高德地图</a>',
|
||||
}).addTo(mapInstance)
|
||||
}
|
||||
if (markerGroup) markerGroup.remove()
|
||||
markerGroup = L.featureGroup()
|
||||
for (const p of pts) {
|
||||
L.circleMarker([p.lat, p.lng], {
|
||||
radius: 8,
|
||||
color: p.color,
|
||||
fillColor: p.color,
|
||||
fillOpacity: 0.85,
|
||||
weight: 2,
|
||||
})
|
||||
.bindPopup(`<b>${p.label}</b><br>${ipAddress.value || UserIP.value}<br>${p.lat.toFixed(5)}, ${p.lng.toFixed(5)}`)
|
||||
.addTo(markerGroup)
|
||||
}
|
||||
markerGroup.addTo(mapInstance)
|
||||
// 多点:视野自适应包住全部点;单点:定位到 10 级
|
||||
if (pts.length > 1) {
|
||||
mapInstance.fitBounds(markerGroup.getBounds().pad(0.3))
|
||||
} else {
|
||||
mapInstance.setView([pts[0].lat, pts[0].lng], 10)
|
||||
}
|
||||
}
|
||||
|
||||
watch(IPLocation, async () => {
|
||||
if (mapPoints.value.length) {
|
||||
await nextTick()
|
||||
await renderLocationMap()
|
||||
}
|
||||
})
|
||||
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + encodeURIComponent(ipAddress.value));
|
||||
|
||||
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
|
||||
@@ -165,7 +250,8 @@ onMounted(async () => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="ipAddress"
|
||||
placeholder="请输入IP地址"
|
||||
placeholder="请输入IP地址"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="locateIP(ipAddress)"
|
||||
@@ -186,6 +272,17 @@ onMounted(async () => {
|
||||
<div v-if="IPLocation" class="result-section">
|
||||
<table class="result-table">
|
||||
<tbody>
|
||||
<tr v-if="IPLocation.lemondb && (IPLocation.lemondb.country || IPLocation.lemondb.administrative_area || IPLocation.lemondb.city)">
|
||||
<td class="table-label">LemonDB</td>
|
||||
<td class="table-value">
|
||||
{{ IPLocation.lemondb?.country }} {{ IPLocation.lemondb?.administrative_area }} {{ IPLocation.lemondb?.city }} {{ IPLocation.lemondb?.district }}
|
||||
</td>
|
||||
<td class="table-value">
|
||||
<span>
|
||||
{{ IPLocation.lemondb?.isp }} {{ IPLocation.lemondb?.type }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="IPLocation.bilibili && (IPLocation.bilibili.administrative_area || IPLocation.bilibili.city)">
|
||||
<td class="table-label">bilibili Live接口</td>
|
||||
<td class="table-value">
|
||||
@@ -232,6 +329,8 @@ onMounted(async () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 归属地地图:高德瓦片(GCJ-02)+ Leaflet,每个有坐标的来源各画一点 -->
|
||||
<div v-if="mapPoints.length" ref="mapContainer" class="location-map"></div>
|
||||
</div>
|
||||
<div style="font-size: 1.5em;">
|
||||
<h3 v-if="isIPv6(UserIP)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
|
||||
@@ -257,6 +356,17 @@ onMounted(async () => {
|
||||
<style scoped>
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
/* leaflet 基础样式必须非 scoped 引入(动态创建的窗格拿不到 data-v 属性) */
|
||||
</style>
|
||||
<style>
|
||||
@import "leaflet/dist/leaflet.css";
|
||||
.location-map {
|
||||
height: 320px;
|
||||
margin-top: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter, #ebeef5);
|
||||
z-index: 0; /* 压住 leaflet 内部窗格的层级,避免盖过下拉/弹窗 */
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
@import "../style.css";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
@@ -10,14 +10,40 @@ const route = useRoute()
|
||||
const url = ref('')
|
||||
|
||||
useHead({
|
||||
title: '网站截图工具 - '+(route.query.site || config.siteName),
|
||||
// 原标题「网站截图工具 - 站名」默认态过短;描述不足 50 字被审计判过短,一并加长。
|
||||
title: route.query.site
|
||||
? `网站截图工具 - ${route.query.site} 的网页快照 | ${config.siteName}`
|
||||
: `网站截图工具 - 在线网页快照生成 | ${config.siteName}`,
|
||||
meta: [
|
||||
{ name: 'description', content: '在线网站截图工具,输入网址即可获取网页快照,支持所有公开网站的实时截图,方便查看网站布局和设计效果' },
|
||||
{ name: 'keywords', content: '网站截图,网页快照,在线截图,网页预览,网站布局查看,截图工具' },
|
||||
{ property: 'og:title', content: '网站截图工具 - 在线网页快照' },
|
||||
{ property: 'og:description', content: '输入网址即可获取网页快照,查看网站布局和设计效果' },
|
||||
{ name: 'description', content: '在线网站截图工具:输入网址即可获取网页实时快照,支持任意公开网站的整页截图与预览,查看网页布局、设计效果与渲染状态,可用于页面监控、设计走查与内容存档比对,免费无需注册' },
|
||||
{ name: 'keywords', content: '网站截图,网页快照,在线截图,网页预览,网站布局查看,截图工具,整页截图,网页快照生成' },
|
||||
{ property: 'og:title', content: '网站截图工具 - 在线网页快照生成' },
|
||||
{ property: 'og:description', content: '输入网址即可获取网页实时快照,查看网页布局与设计效果,支持任意公开网站,免费无需注册' },
|
||||
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebApplication',
|
||||
name: '网站截图工具',
|
||||
description: '在线网站截图工具,输入网址即可获取网页实时快照,支持任意公开网站的整页截图与预览。',
|
||||
url: new URL('/screenshot', config.siteUrl).href,
|
||||
applicationCategory: 'UtilitiesApplication',
|
||||
operatingSystem: 'Web',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'CNY'
|
||||
},
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: config.siteName
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -26,6 +52,8 @@ const loading = ref(false)
|
||||
const error = ref('')
|
||||
const screenshotUrl = ref('')
|
||||
const imgLoaded = ref(false)
|
||||
// 主通道(thum.io)经 fetch 下载后以 blob URL 喂给 <img>;换新截图前要 revoke 旧的
|
||||
const blobUrl = ref('')
|
||||
|
||||
|
||||
let detectionImg: HTMLImageElement | null = null
|
||||
@@ -38,6 +66,45 @@ const DEFAULT_MSHOTS_WIDTH = 1200
|
||||
const DEFAULT_MSHOTS_HEIGHT = 900
|
||||
const LOADING_TIMEOUT = 15000 // 15 seconds
|
||||
|
||||
// —— 主通道:thum.io(同步出图、CORS 全开)——
|
||||
// content-type 即结果信号:image/png|jpeg = 成品;image/gif = thum.io 的
|
||||
// 「生成中/目标不可达」动画(对没缓存且生成慢的站首访可能出现)→ 等片刻重试一次,
|
||||
// 仍 GIF 就放弃。网络层失败(被墙/CSP/超时)→ 立即回退 mshots。
|
||||
const THUM_TIMEOUT = 10_000
|
||||
const THUM_RETRY_DELAY = 4_000
|
||||
|
||||
async function takeThumio(target: string, requestId: number): Promise<string | null> {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
if (requestId !== currentRequestId) return null
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), THUM_TIMEOUT)
|
||||
const res = await fetch(`${config.screenshotAPI}/get/width/1280/${target}`, { signal: ctrl.signal })
|
||||
clearTimeout(timer)
|
||||
if (requestId !== currentRequestId) return null
|
||||
const type = res.headers.get('content-type') || ''
|
||||
if (res.ok && /^image\/(png|jpe?g)\b/.test(type)) {
|
||||
return URL.createObjectURL(await res.blob())
|
||||
}
|
||||
if (attempt === 0) {
|
||||
await new Promise(r => setTimeout(r, THUM_RETRY_DELAY))
|
||||
continue
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function releaseBlob() {
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserIP(){
|
||||
try {
|
||||
userIP.value = await $fetch<string>(config.DualStackAPI)
|
||||
@@ -82,14 +149,31 @@ function takeScreenshot() {
|
||||
imgLoaded.value = false
|
||||
loading.value = true
|
||||
screenshotUrl.value = ''
|
||||
releaseBlob()
|
||||
|
||||
const requestId = ++currentRequestId
|
||||
|
||||
// Set the screenshot URL on next tick so Vue can update the displayed <img>
|
||||
nextTick(() => {
|
||||
nextTick(async () => {
|
||||
// Check if this request was superseded
|
||||
if (requestId !== currentRequestId) return
|
||||
|
||||
// —— 主通道 thum.io:fetch 下载成 blob 直接展示(同步出图,无「请稍后」体验)——
|
||||
const blob = await takeThumio(url.value, requestId)
|
||||
if (requestId !== currentRequestId) {
|
||||
if (blob) URL.revokeObjectURL(blob)
|
||||
return
|
||||
}
|
||||
if (blob) {
|
||||
blobUrl.value = blob
|
||||
screenshotUrl.value = blob
|
||||
// blob 已完整在手,无需等 <img> 的 load 事件再揭开
|
||||
imgLoaded.value = true
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// —— 回退 mshots:异步生成,先重定向到占位图,需要隐藏 <img> 探测真伪 ——
|
||||
screenshotUrl.value = `https://s0.wp.com/mshots/v1/${encodeURIComponent(url.value)}`
|
||||
|
||||
// Create a hidden detection image to check for default WordPress error image
|
||||
@@ -153,6 +237,11 @@ function onImageError() {
|
||||
showError('截图加载失败,请检查网址是否正确')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDetection()
|
||||
releaseBlob()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const urlParam = route.query.site as string
|
||||
if (urlParam) {
|
||||
@@ -180,6 +269,7 @@ const doc = page.value
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入网址(如:https://example.com)"
|
||||
clearable
|
||||
@keyup.enter="takeScreenshot"
|
||||
/>
|
||||
<el-button
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass } from '../../utils/tools';
|
||||
import { extractHost, formatTime, formatSpeed, formatSize, getStatusCodeClass, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
|
||||
|
||||
const route = useRoute()
|
||||
@@ -48,6 +49,8 @@ const loading = ref(false)
|
||||
const result = ref<any[]>([])
|
||||
const error = ref('')
|
||||
const domain = ref('')
|
||||
// 高级选项(dns4 / resolve / resolve4):用户填了才拼进请求
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
|
||||
/** 服务器结果接口 */
|
||||
interface ServerResult {
|
||||
@@ -72,7 +75,7 @@ const serverResults = ref<ServerResult[]>([])
|
||||
|
||||
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
|
||||
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -82,7 +85,7 @@ const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
|
||||
|
||||
/** IPv4 专用服务器:仅支持 IPv4 的节点 */
|
||||
const ipv4Fetches = config.APIBaseURL.IPv4.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value), adv.value?.query() ?? {}));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -180,7 +183,8 @@ onMounted(() => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="SpeedTest()"
|
||||
@@ -191,6 +195,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" stack="ipv4" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useRoute } from 'vue-router'
|
||||
import { CircleCheckFilled, CircleCloseFilled,InfoFilled } from '@element-plus/icons-vue';
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { extractHost, getStatusCodeClass, formatTime, formatSpeed } from '../../utils/tools';
|
||||
import { extractHost, getStatusCodeClass, formatTime, formatSpeed, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
const route = useRoute()
|
||||
|
||||
const siteUrlWithSlash = computed(() => config.siteUrl.replace(/\/$/, '') + '/')
|
||||
@@ -62,10 +63,13 @@ interface SSLCheckItem {
|
||||
issuer_organization: string[] | null
|
||||
issuer_common_name: string
|
||||
subject_common_name: string
|
||||
san: string[] | null
|
||||
is_expired: boolean
|
||||
is_reachable: boolean
|
||||
}
|
||||
const apiList = config.APIBaseURL.DualStack
|
||||
const apiList = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
]
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const tmpDomain = ref('https://www.zakoflare.com')
|
||||
@@ -73,7 +77,16 @@ const testDomain = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref<SSLCheckResponse | null>(null)
|
||||
const sslUrl = computed(() => "/middleware/" + backendID.value + "/ssl/" + encodeURIComponent(testDomain.value));
|
||||
// SAN 行只在「至少一个栈拿到非空 SAN」时渲染:两栈都空(节点未升级返回无 san 字段、
|
||||
// 或证书本就没有 SAN)时整行隐藏,而不是渲染一行「- -」
|
||||
const showSanRow = computed(() => {
|
||||
const r = result.value
|
||||
if (!r) return false
|
||||
return !!((r.ipv4?.is_reachable && r.ipv4.san?.length) || (r.ipv6?.is_reachable && r.ipv6.san?.length))
|
||||
})
|
||||
// 高级选项(dns4/dns6 + resolve/resolve4/resolve6):本页一次探测两栈
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
const sslUrl = computed(() => withQuery("/middleware/" + backendID.value + "/ssl/" + encodeURIComponent(testDomain.value), adv.value?.query() ?? {}));
|
||||
|
||||
const { data: sslData, error: sslError, execute: executeSSL } = useMiddlewareFetch<SSLCheckResponse>(sslUrl, {
|
||||
immediate: false,
|
||||
@@ -146,7 +159,8 @@ onMounted(() => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
placeholder="请输入域名(如:https://zakoflare.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="checkSSL()"
|
||||
@@ -157,6 +171,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
@@ -201,6 +217,17 @@ onMounted(() => {
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="showSanRow">
|
||||
<td class="table-label">SAN(备用域名)</td>
|
||||
<td class="table-value">
|
||||
<template v-if="result.ipv4!.is_reachable && result.ipv4!.san?.length">{{ result.ipv4!.san.join(', ') }}</template>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="table-value">
|
||||
<template v-if="result.ipv6 && result.ipv6.is_reachable && result.ipv6.san?.length">{{ result.ipv6.san.join(', ') }}</template>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="table-label">签发者</td>
|
||||
<td class="table-value">
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { extractHost } from '../../utils/tools';
|
||||
import { extractHost, withQuery } from '../../utils/tools';
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue';
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -76,6 +77,8 @@ const tmpDomain = ref('www.zakoflare.com')
|
||||
const port = ref('80')
|
||||
const loading = ref(false)
|
||||
const serverResults = ref<ServerResult[]>([])
|
||||
// 高级选项(dns4 / resolve / resolve4):用户填了才拼进请求
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
@@ -85,7 +88,7 @@ const allServers = [
|
||||
];
|
||||
|
||||
const tcpingFetches = allServers.map((server) => {
|
||||
const url = computed(() => "/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value));
|
||||
const url = computed(() => withQuery("/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value) + "&version=ipv4", adv.value?.query() ?? {}));
|
||||
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
|
||||
immediate: false,
|
||||
watch: false,
|
||||
@@ -95,7 +98,8 @@ const tcpingFetches = allServers.map((server) => {
|
||||
|
||||
function initServerResults() {
|
||||
const results: ServerResult[] = []
|
||||
|
||||
|
||||
// 与 allServers 顺序保持一致:DualStack → IPv4
|
||||
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
@@ -186,11 +190,13 @@ onMounted(() => {
|
||||
<div class="one-line">
|
||||
<el-input
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-input
|
||||
v-model="port"
|
||||
placeholder="端口号(默认 80)"
|
||||
placeholder="端口号(默认 80)"
|
||||
clearable
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<el-button
|
||||
@@ -202,6 +208,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" stack="ipv4" />
|
||||
|
||||
<div class="result-section">
|
||||
<table class="result-table">
|
||||
<thead>
|
||||
@@ -256,6 +264,20 @@ onMounted(() => {
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
|
||||
/* 本页行是双输入框(420×2 + 按钮 165 + 间距 ≈ 1035px),换行点远高于通用阈值的
|
||||
615px:容器低于 1050px(≈自然换行点)时换行元素铺满整行,消除中间的夹缝区。 */
|
||||
@container (max-width: 1050px) {
|
||||
.el-input,
|
||||
.el-button {
|
||||
width: 100% !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu--horizontal > .el-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@@ -3,25 +3,26 @@ import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { ref, onMounted, computed, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index'
|
||||
import { withQuery } from '../../utils/tools'
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue'
|
||||
import { isIPv6 } from 'is-ip'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
useHead({
|
||||
title: 'Whois查询 | ' + (route.query.site || config.siteName),
|
||||
// 原标题「Whois查询 | 站名」只有十几个字符,被 SEO 审计判为过短;
|
||||
// 带查询参数时展示的是具体域名,单独给一个含关键词的标题。
|
||||
title: route.query.site
|
||||
? `Whois查询结果 - ${route.query.site} 的域名注册信息 | ${config.siteName}`
|
||||
: `Whois域名查询工具 | 域名注册信息与到期时间查询 | ${config.siteName}`,
|
||||
titleTemplate: '%s',
|
||||
link: [
|
||||
{ rel: 'canonical', href: computed(() => new URL(route.path, config.siteUrl).toString()).value }
|
||||
],
|
||||
meta: [
|
||||
{ name: 'description', content: '专业的Whois域名查询工具,支持.com、.net等国内外域名WHOIS信息查询,提供域名注册商、注册时间、到期时间、DNS服务器等详细信息,助力域名管理和交易决策' },
|
||||
{ name: 'description', content: '专业的Whois域名查询工具,支持.com、.net、.cn等国内外域名WHOIS信息在线查询,提供域名注册商、注册时间、到期时间、DNS服务器、域名状态等详细信息,支持IPv4/IPv6多节点检测,助力域名管理与交易决策' },
|
||||
{ name: 'keywords', content: 'whois查询,域名whois,whois信息查询,域名注册信息,域名到期时间,域名注册商,dns服务器查询,域名whois工具' },
|
||||
{ property: 'og:title', content: 'Whois域名查询工具 - 域名注册信息在线查询' },
|
||||
{ property: 'og:description', content: '专业的Whois域名查询工具,支持国内外域名WHOIS信息查询,提供域名注册商、注册时间、到期时间等详细信息' },
|
||||
{ property: 'og:image', content: `${config.siteUrl}favicon.svg` },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:url', content: computed(() => new URL(route.path, config.siteUrl).toString()).value },
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
@@ -64,10 +65,14 @@ const result = ref<any>(null)
|
||||
const error = ref('')
|
||||
const userIP = ref('')
|
||||
|
||||
const apiList = config.APIBaseURL.DualStack
|
||||
const apiList = [
|
||||
...config.APIBaseURL.DualStack,
|
||||
]
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const whoisUrl = computed(() => '/middleware/' + backendID.value + '/whois/' + encodeURIComponent(domain.value))
|
||||
// 高级选项:whois 只取 DNS 服务器(dns4/dns6),不支持强制解析
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
const whoisUrl = computed(() => withQuery('/middleware/' + backendID.value + '/whois/' + encodeURIComponent(domain.value), adv.value?.query() ?? {}))
|
||||
|
||||
const { data: whoisData, error: whoisError, execute: executeWhois } = useMiddlewareFetch<any>(whoisUrl, {
|
||||
immediate: false,
|
||||
@@ -168,6 +173,7 @@ function getStatusClass(status: string): string {
|
||||
<el-input
|
||||
v-model="tmpdomain"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
@click="queryWhois()"
|
||||
@@ -178,6 +184,8 @@ function getStatusClass(status: string): string {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" :show-resolve="false" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
@@ -285,6 +293,11 @@ function getStatusClass(status: string): string {
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
/* 1240px 宽的生命周期图需缩进内容列,桌面端也要约束 */
|
||||
.markdown :deep(img) {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.markdown :deep(a) {
|
||||
font-size: 1.1em;
|
||||
|
||||
@@ -236,6 +236,30 @@ html.dark pre code {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* —— 行内换行自适应(容器查询)——
|
||||
.one-line 里的输入框/按钮是固定宽度,容器放不下时 flex 只换行不改宽,
|
||||
出现「换行了却不铺满」的夹缝态:换行点 ~605px(input 420 + 按钮 165 + 间距)、
|
||||
铺满点 768px(下方媒体查询断点),手机视口直接落在断点内看不出来,
|
||||
桌面缩窗在 768~换行点之间全是夹缝。容器查询补齐:容器低于行内容的
|
||||
自然换行点 ⇒ 换行的元素铺满整行。阈值略高于换行点保证无缝衔接;
|
||||
行内容更宽的页面(tcping 双输入框 1035px、dns 带 select 765px)
|
||||
在各自 scoped 样式里用同名容器再写一条更高阈值的规则。 */
|
||||
.one-line {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@container (max-width: 615px) {
|
||||
.el-input,
|
||||
.el-button {
|
||||
width: 100% !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 窄屏响应式:所有组件缩小 */
|
||||
@media (max-width: 768px) {
|
||||
.el-input,
|
||||
|
||||
+102
-32
@@ -1,136 +1,206 @@
|
||||
export interface DocMeta {
|
||||
title: string
|
||||
description: string
|
||||
// —— SEO 专用字段(可选)——
|
||||
// 审计(Bing 站长工具)判定 10 个页面标题过短、13 个页面 meta description 过短,
|
||||
// 全是文档页:上面的 title/description 同时用于侧栏菜单与卡片展示,不适合拉长,
|
||||
// 因此文档页的 <title> 与 meta 优先取这里的加长版(见 doc 两个页面的 useHead)。
|
||||
seoTitle?: string
|
||||
seoDescription?: string
|
||||
}
|
||||
|
||||
export const docConfig: Record<string, DocMeta> = {
|
||||
'/doc': {
|
||||
title: 'IPv6 工具箱使用文档',
|
||||
description: '致力于普及 IPv6,推进 IPv6 规模部署和应用,以全面推进 IPv6 技术创新与融合应用为主线,以提升应用广度深度为主攻方向。支持 IPv6 本地网络(宽带)开启验证、IPv6 网站开启检测、IPv6 Ping 延迟测试、IPv6 网站测速、IPv6 地址查询、IPv6 DNS 解析。'
|
||||
description: '致力于普及 IPv6,推进 IPv6 规模部署和应用,以全面推进 IPv6 技术创新与融合应用为主线,以提升应用广度深度为主攻方向。支持 IPv6 本地网络(宽带)开启验证、IPv6 网站开启检测、IPv6 Ping 延迟测试、IPv6 网站测速、IPv6 地址查询、IPv6 DNS 解析。',
|
||||
seoTitle: 'IPv6 工具箱使用文档:宽带开启、网站检测、测速与 DNS 解析教程',
|
||||
seoDescription: 'IPv6 工具箱完整使用文档:涵盖家庭宽带开启 IPv6、网站 IPv6 支持检测、IPv6 Ping 延迟测试、IPv6 网站测速、IP 归属地查询与 IPv6 DNS 解析等教程,致力于普及 IPv6、推进 IPv6 规模部署和应用。'
|
||||
},
|
||||
'/doc/user/enable_ipv6': {
|
||||
title: '个人宽带如何开启IPv6网络访问',
|
||||
description: 'IPv6 是大势所趋,就在前段时间湖南联通发布公告,对家庭宽带提供 IPv6 地址,不再提供 IPv4 地址,那本文就介绍个人宽带如何开启 IPv6 网络访问。'
|
||||
description: 'IPv6 是大势所趋,就在前段时间湖南联通发布公告,对家庭宽带提供 IPv6 地址,不再提供 IPv4 地址,那本文就介绍个人宽带如何开启 IPv6 网络访问。',
|
||||
seoTitle: '个人宽带如何开启 IPv6 网络访问:光猫与路由器设置教程',
|
||||
seoDescription: '手把手教你为家庭宽带开启 IPv6 网络访问:从光猫与路由器的设置,到向运营商申请(湖南联通等已对家庭宽带提供 IPv6 地址、不再提供 IPv4),附开启后的 IPv6 地址验证方法与常见问题排查。'
|
||||
},
|
||||
'/doc/user/cmd_bash_disable_ipv6': {
|
||||
title: '命令行禁用/启用IPv6本地网络',
|
||||
description: '图形化禁用 IPv6 网络比较繁琐,以下是 macOS 和 Window 10 命令行下禁用/启用 IPv6 本地网络的方法。'
|
||||
description: '图形化禁用 IPv6 网络比较繁琐,以下是 macOS 和 Window 10 命令行下禁用/启用 IPv6 本地网络的方法。',
|
||||
seoTitle: 'macOS / Windows 命令行禁用或启用 IPv6 本地网络',
|
||||
seoDescription: '图形化开关 IPv6 网络比较繁琐,本文整理 macOS(networksetup)与 Windows 10/11(netsh、PowerShell)命令行下禁用/启用 IPv6 本地网络的完整命令,可直接复制执行,便于脚本化批量管理多台机器。'
|
||||
},
|
||||
'/doc/user/cmd_getip': {
|
||||
title: '命令行(curl)获取 IPv4 和 IPv6 地址',
|
||||
description: '通过 curl 命令获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。'
|
||||
description: '通过 curl 命令获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。',
|
||||
seoTitle: 'curl 命令获取公网 IPv4 / IPv6 地址及访问优先级',
|
||||
seoDescription: '通过 curl 命令查询本机公网 IPv4 和 IPv6 地址:分别指定 -4 / -6 强制协议栈,还可一键返回当前网络是 IPv4 还是 IPv6 访问优先。适用于 Linux、macOS、Windows 终端以及监控脚本与 CI 环境调用。'
|
||||
},
|
||||
'/doc/user/view_ipv6_adress_url': {
|
||||
title: '浏览器访问 IPv6 地址',
|
||||
description: '在 IPv6 地址两边加上中括号,就可以通过浏览器访问了,比如 http://[2402:4e00:1013:e500:0:9671:f018:4947]/'
|
||||
description: '在 IPv6 地址两边加上中括号,就可以通过浏览器访问了,比如 http://[2402:4e00:1013:e500:0:9671:f018:4947]/',
|
||||
seoTitle: '浏览器访问 IPv6 地址:中括号写法与端口注意事项',
|
||||
seoDescription: '在 IPv6 地址两边加上中括号即可用浏览器访问,例如 http://[2402:4e00:1013:e500:0:9671:f018:4947]/。本文介绍浏览器访问 IPv6 地址的正确写法、端口号的书写位置,以及无法访问时的排查思路。'
|
||||
},
|
||||
'/doc/user/ipv4_ipv6_prefix_precedence': {
|
||||
title: 'Windows 10/11 设置 IPv4/IPv6 访问优先级',
|
||||
description: 'Windows 10/11 开启 IPv6 后默认 IPv6 访问优先(以访问 IPv4/IPv6 双栈站点为例,操作系统会优先访问 IPv6),如果期望 IPv4 访问优先,可以通过 netsh 命令调整。'
|
||||
description: 'Windows 10/11 开启 IPv6 后默认 IPv6 访问优先(以访问 IPv4/IPv6 双栈站点为例,操作系统会优先访问 IPv6),如果期望 IPv4 访问优先,可以通过 netsh 命令调整。',
|
||||
seoTitle: 'Windows 10/11 设置 IPv4/IPv6 访问优先级(netsh 前缀策略)',
|
||||
seoDescription: 'Windows 10/11 开启 IPv6 后默认 IPv6 访问优先(访问 IPv4/IPv6 双栈站点时操作系统会优先走 IPv6)。如果期望 IPv4 访问优先,可通过 netsh interface ipv6 修改前缀策略调整,本文提供可直接复制的命令与恢复默认的方法。'
|
||||
},
|
||||
'/doc/user/ipv6_daohang': {
|
||||
title: '国内 IPv6 资源导航',
|
||||
description: 'IPv6 网络开启成功后,可以访问一些 IPv6 网站资源,来体验下 IPv6 的魅力。不过目前纯 IPv6 网站比较少,目前网站支持 IPv6 的主流方式为 IPv4/IPv6 双栈访问,毕竟要兼顾 IPv4 网络用户的访问需求。'
|
||||
description: 'IPv6 网络开启成功后,可以访问一些 IPv6 网站资源,来体验下 IPv6 的魅力。不过目前纯 IPv6 网站比较少,目前网站支持 IPv6 的主流方式为 IPv4/IPv6 双栈访问,毕竟要兼顾 IPv4 网络用户的访问需求。',
|
||||
seoTitle: '国内 IPv6 资源导航:支持 IPv6 访问的网站大全',
|
||||
seoDescription: 'IPv6 网络开启成功后,可以访问这些国内 IPv6 网站资源体验下一代互联网。目前纯 IPv6 网站还比较少,主流网站支持 IPv6 的方式为 IPv4/IPv6 双栈访问,以兼顾 IPv4 网络用户的访问需求。'
|
||||
},
|
||||
'/doc/user/pure_ipv6_website': {
|
||||
title: '国内纯 IPv6 网站导航',
|
||||
description: 'IPv6 网络开启成功后,可以访问一些纯 IPv6 网站资源,来体验下 IPv6 的魅力。'
|
||||
description: 'IPv6 网络开启成功后,可以访问一些纯 IPv6 网站资源,来体验下 IPv6 的魅力。',
|
||||
seoTitle: '国内纯 IPv6 网站导航:仅支持 IPv6 访问的站点合集',
|
||||
seoDescription: '收录一批仅提供 IPv6 访问的国内纯 IPv6 网站:家庭宽带开启 IPv6 网络后即可直接访问,用来验证本机 IPv6 连通性,或体验真正的 IPv6-only 互联网服务,列表持续更新。'
|
||||
},
|
||||
'/doc/user/dns': {
|
||||
title: '全国各省 DNS 服务器列表',
|
||||
description: '全国各省电信、联通、移动的首选 IPv4 DNS 服务器和备用 DNS 服务器,以及公共 DNS 服务器列表,最近更新为 2021-07-25。'
|
||||
description: '全国各省电信、联通、移动的首选 IPv4 DNS 服务器和备用 DNS 服务器,以及公共 DNS 服务器列表,最近更新为 2021-07-25。',
|
||||
seoTitle: '全国各省电信/联通/移动 DNS 服务器地址列表(IPv4)',
|
||||
seoDescription: '全国各省电信、联通、移动的首选与备用 IPv4 DNS 服务器地址汇总,外加 114DNS、阿里 DNS、DNSPod 等公共 DNS 列表,按省份与运营商分类整理,方便网络排障时就近选用延迟最低的 DNS 服务器。'
|
||||
},
|
||||
'/doc/user/ipv6_dns': {
|
||||
title: 'IPv6 DNS 地址列表',
|
||||
description: 'IPv6 DNS 地址列表,请求 IPv6 网络需要设置 IPv6 DNS。'
|
||||
description: 'IPv6 DNS 地址列表,请求 IPv6 网络需要设置 IPv6 DNS。',
|
||||
seoTitle: 'IPv6 DNS 服务器地址列表:公共 IPv6 DNS 推荐',
|
||||
seoDescription: 'IPv6 网络需要设置 IPv6 DNS 才能正常解析域名。本文汇总国内外常用 IPv6 DNS 服务器地址:包括阿里、DNSPod、Google、Cloudflare 等公共 IPv6 DNS,可直接复制到光猫、路由器或网卡设置中。'
|
||||
},
|
||||
'/doc/user/AliyunAuthorizeSecurityGroup': {
|
||||
title: '阿里云自动化添加安全组',
|
||||
description: 'Golang 代码实操阿里云自动化添加安全组'
|
||||
description: 'Golang 代码实操阿里云自动化添加安全组',
|
||||
seoTitle: 'Golang 实现阿里云 ECS 安全组规则自动化添加',
|
||||
seoDescription: '使用 Golang 调用阿里云 ECS OpenAPI 实现安全组放行规则的自动化添加:包含 SDK 初始化、AccessKey 鉴权、AuthorizeSecurityGroup 接口的参数说明与完整示例代码,适合批量管理多台 ECS 实例的场景。'
|
||||
},
|
||||
'/doc/user/TencentCloudAddSecurityGroup': {
|
||||
title: '腾讯云自动化添加安全组',
|
||||
description: 'Golang 代码实操腾讯云自动化添加安全组'
|
||||
description: 'Golang 代码实操腾讯云自动化添加安全组',
|
||||
seoTitle: 'Golang 实现腾讯云 CVM 安全组规则自动化添加',
|
||||
seoDescription: '使用 Golang 调用腾讯云 CVM OpenAPI 实现安全组规则的自动化添加:涵盖 SDK 安装、密钥配置、安全组策略接口的调用示例与完整源码,便于在脚本中批量放行端口、管理大量云主机实例。'
|
||||
},
|
||||
'/doc/server/website_enable_ipv6': {
|
||||
title: '网站开启 IPv6 的三种方式',
|
||||
description: '从传统二进制部署的 Nginx,到云原生部署的 K8S、Istio,分别介绍网站开启 IPv6 的三种方式。'
|
||||
description: '从传统二进制部署的 Nginx,到云原生部署的 K8S、Istio,分别介绍网站开启 IPv6 的三种方式。',
|
||||
seoTitle: '网站开启 IPv6 的三种方式:Nginx、K8s 与 Istio',
|
||||
seoDescription: '从传统二进制部署的 Nginx 监听 IPv6 地址,到云原生部署的 K8s Service 双栈与 Istio 网关,一文讲清网站开启 IPv6 的三种方式及各自适用场景,附关键配置片段与开启后的验证方法。'
|
||||
},
|
||||
'/doc/server/tencent_cloud_cvm_ipv6': {
|
||||
title: '腾讯云 cvm 开启 IPv6',
|
||||
description: '一文看懂在腾讯云 CVM 上开启 IPv6,只需 7 步。'
|
||||
description: '一文看懂在腾讯云 CVM 上开启 IPv6,只需 7 步。',
|
||||
seoTitle: '腾讯云 CVM 开启 IPv6 完整教程(只需 7 步)',
|
||||
seoDescription: '一文看懂在腾讯云 CVM 上开启 IPv6:从 VPC 与子网启用 IPv6、给实例分配 IPv6 地址、安全组放行,到系统内网卡配置与连通性验证,共 7 步完成,附每步操作说明与常见报错的处理办法。'
|
||||
},
|
||||
'/doc/server/nginx_ipv6': {
|
||||
title: 'Nginx 开启 IPv6',
|
||||
description: '一文看懂 Nginx 中开启 IPv6,包含设置 IPv6 SSL 证书。'
|
||||
description: '一文看懂 Nginx 中开启 IPv6,包含设置 IPv6 SSL 证书。',
|
||||
seoTitle: 'Nginx 开启 IPv6:listen 监听与 IPv6 SSL 证书配置',
|
||||
seoDescription: '一文看懂 Nginx 中开启 IPv6:listen [::] 监听写法、与 IPv4 共存的配置示例、IPv6 SSL 证书的设置,以及防火墙放行 IPv6 流量,配置完成后可用 IPv6 网站检测工具一键验证。'
|
||||
},
|
||||
'/doc/server/ipv6webcheck': {
|
||||
title: '如何确认一个网站是否开启 IPv6',
|
||||
description: '通过 IPv6 网站检测工具,一键检查网站是否开启 IPv6 访问。'
|
||||
description: '通过 IPv6 网站检测工具,一键检查网站是否开启 IPv6 访问。',
|
||||
seoTitle: '如何确认一个网站是否开启 IPv6:一键检测方法',
|
||||
seoDescription: '使用本站的 IPv6 网站检测工具,输入域名即可一键检查网站是否开启 IPv6 访问:返回 IPv4 与 IPv6 双栈的 HTTP/HTTPS 状态码、DNS 解析与连接耗时对比数据,并可申请 IPv6 徽标认证。'
|
||||
},
|
||||
'/doc/server/ipv6_sign': {
|
||||
title: '网站增加支持IPv6访问标识',
|
||||
description: 'HTML+CSS 代码,可以提醒访客本网站支持 IPv6 访问,同时可以跳转到 IPv6 网站检测页面。'
|
||||
description: 'HTML+CSS 代码,可以提醒访客本网站支持 IPv6 访问,同时可以跳转到 IPv6 网站检测页面。',
|
||||
seoTitle: '网站添加「支持 IPv6 访问」标识(HTML+CSS 代码)',
|
||||
seoDescription: '提供一段现成的 HTML+CSS 代码,在网站页脚展示「本网站支持 IPv6 访问」标识,点击即可跳转到 IPv6 网站检测页面实时验证,让访客直观感知网站的 IPv6 支持状态。'
|
||||
},
|
||||
'/doc/server/ipv6_domain_record': {
|
||||
title: '如何为域名添加 IPv6 解析记录',
|
||||
description: 'IPv6 网站准备好了后,接下来可以为网站域名添加 IPv6 解析记录(AAAA)。'
|
||||
description: 'IPv6 网站准备好了后,接下来可以为网站域名添加 IPv6 解析记录(AAAA)。',
|
||||
seoTitle: '如何为域名添加 IPv6 解析记录(AAAA 记录)',
|
||||
seoDescription: '网站支持 IPv6 后,还需为域名添加 IPv6 解析记录(AAAA)。本文演示在常见 DNS 服务商控制台添加 AAAA 记录的操作步骤、TTL 建议值,以及添加后用 dig / nslookup 验证解析是否生效。'
|
||||
},
|
||||
'/doc/server/http2': {
|
||||
title: '网站如何开启 HTTP/2',
|
||||
description: 'HTTP/2 通过引入字段压缩和允许在同一连接上进行多个并发交换,使网络资源得到更有效的利用,并减少了延迟。'
|
||||
description: 'HTTP/2 通过引入字段压缩和允许在同一连接上进行多个并发交换,使网络资源得到更有效的利用,并减少了延迟。',
|
||||
seoTitle: '网站如何开启 HTTP/2:Nginx 配置与抓包验证',
|
||||
seoDescription: 'HTTP/2 通过引入字段压缩和允许在同一连接上进行多个并发交换,使网络资源得到更有效的利用并减少延迟。本文介绍网站在 Nginx 中开启 HTTP/2 的配置方法,并用浏览器与 Wireshark 抓包验证协商结果。'
|
||||
},
|
||||
'/doc/rfc/rfc8200': {
|
||||
title: 'IPv6 RFC8200 解读',
|
||||
description: 'RFC8200(Internet Protocol, Version 6 (IPv6) Specification)是 IPv6 的权威定义,接下来在解读 RFC8200 文档中的过程中,加深对 IPv6 的理解。'
|
||||
description: 'RFC8200(Internet Protocol, Version 6 (IPv6) Specification)是 IPv6 的权威定义,接下来在解读 RFC8200 文档中的过程中,加深对 IPv6 的理解。',
|
||||
seoTitle: 'IPv6 RFC 8200 解读:IPv6 协议规范逐节精读',
|
||||
seoDescription: 'RFC 8200(Internet Protocol, Version 6 (IPv6) Specification)是 IPv6 协议的权威定义。本文逐节解读其报文格式、扩展报头、地址语义等核心内容,在精读文档的过程中建立对 IPv6 的完整理解。'
|
||||
},
|
||||
'/doc/rfc/ipv6_address_format': {
|
||||
title: 'IPv6 地址标识方法',
|
||||
description: '首选格式、压缩格式、内嵌 IPv4 地址的 IPv6 地址格式。'
|
||||
description: '首选格式、压缩格式、内嵌 IPv4 地址的 IPv6 地址格式。',
|
||||
seoTitle: 'IPv6 地址表示方法:首选格式、压缩格式与内嵌 IPv4',
|
||||
seoDescription: 'IPv6 地址共 128 位,本文讲解三种标准表示方法:首选格式(8 组 4 位十六进制数)、零压缩格式(:: 缩写规则)以及内嵌 IPv4 地址的混合表示法,并给出每组示例与常见的书写误区。'
|
||||
},
|
||||
'/doc/user/tcpdump_ipv6': {
|
||||
title: 'tcpdump 分析 IPv6 包',
|
||||
description: 'tcpdump 分析 IPv6 包'
|
||||
description: 'tcpdump 分析 IPv6 包',
|
||||
seoTitle: 'tcpdump 抓包分析 IPv6 数据包与常用过滤命令',
|
||||
seoDescription: '使用 tcpdump 抓取并分析 IPv6 数据包:整理 ip6、icmp6、TCP/UDP 端口等常用过滤表达式,配合 -nn -vv 等参数看清 IPv6 报文结构,适用于排查 IPv6 连通性与邻居发现(NDP)协议问题。'
|
||||
},
|
||||
'/doc/user/wireshark_ipv6': {
|
||||
title: 'WireShark 分析IPv6包头',
|
||||
description: '使用 WireShark 分析 IPv6 包头'
|
||||
description: '使用 WireShark 分析 IPv6 包头',
|
||||
seoTitle: 'WireShark 分析 IPv6 包头:各字段逐项讲解',
|
||||
seoDescription: '使用 WireShark 逐字段分析 IPv6 包头:版本号、流量类别(Traffic Class)、流标签(Flow Label)、有效载荷长度、下一报头、跳数限制与源/目的地址,配合显示过滤器快速定位 IPv6 通信问题。'
|
||||
},
|
||||
'/doc/user/ipv6_ping': {
|
||||
title: 'IPv6 Ping 检测原理',
|
||||
description: '协议标准:Internet Control Message Protocol (ICMPv6) for the Internet Protocol Version 6 (IPv6) Specification'
|
||||
description: '协议标准:Internet Control Message Protocol (ICMPv6) for the Internet Protocol Version 6 (IPv6) Specification',
|
||||
seoTitle: 'IPv6 Ping 检测原理:ICMPv6 回显请求与应答',
|
||||
seoDescription: 'IPv6 Ping 基于 ICMPv6 协议(Internet Control Message Protocol for IPv6)的回显请求(Echo Request)与应答(Echo Reply)实现。本文讲解 ICMPv6 报文结构与类型字段,以及 IPv6 为何用多播取代广播探测。'
|
||||
},
|
||||
'/doc/api/ip/locate': {
|
||||
title: '获取客户端公网 IP 及位置',
|
||||
description: '调用本项目的 /v1/location 接口,一次取回访问者自己的公网 IP 与 10 套 IP 库的归属地结果。'
|
||||
description: '调用本项目的 /v1/location 接口,一次取回访问者自己的公网 IP 与 10 套 IP 库的归属地结果。',
|
||||
seoTitle: 'API 文档:获取客户端公网 IP 与多库归属地结果',
|
||||
seoDescription: '调用本项目的 /v1/location 接口,一次取回访问者自己的公网 IPv4/IPv6 地址与 10 套 IP 库(MaxMind、IP2Region、GeoCN 等)的归属地结果,返回 JSON、支持跨域,附请求示例与响应字段说明。'
|
||||
},
|
||||
'/doc/api/ip/myip': {
|
||||
title: '获取客户端公网 IP 地址',
|
||||
description: '用自带边缘函数获取客户端公网 IP,纯文本返回、支持跨域,可按 IPv4 / IPv6 / 双栈分别部署。'
|
||||
description: '用自带边缘函数获取客户端公网 IP,纯文本返回、支持跨域,可按 IPv4 / IPv6 / 双栈分别部署。',
|
||||
seoTitle: 'API 文档:边缘函数获取客户端公网 IP(纯文本跨域)',
|
||||
seoDescription: '使用本项目自带的边缘函数获取客户端公网 IP:纯文本直接返回、支持 CORS 跨域,可按 IPv4-only / IPv6-only / 双栈三种方式分别部署,适合脚本、服务器监控与 CI 环境快速取公网 IP。'
|
||||
},
|
||||
'/doc/api/ip/query': {
|
||||
title: '查询指定 IP 地址的位置信息',
|
||||
description: '调用本项目的 /v1/location/<ip> 接口查询任意 IPv4 / IPv6 地址的归属地,返回多源聚合结果。'
|
||||
description: '调用本项目的 /v1/location/<ip> 接口查询任意 IPv4 / IPv6 地址的归属地,返回多源聚合结果。',
|
||||
seoTitle: 'API 文档:查询指定 IPv4/IPv6 地址的归属地信息',
|
||||
seoDescription: '调用本项目的 /v1/location/<ip> 接口查询任意 IPv4 / IPv6 地址的归属地:返回多套 IP 数据库聚合的地理位置、运营商、ASN 等结果,支持节点直连与中间件转发两种调用方式,附完整响应示例。'
|
||||
},
|
||||
'/doc/api/whois/query': {
|
||||
title: '查询域名的 WHOIS 信息',
|
||||
description: '调用本项目的 /v1/whois/<domain> 接口查询域名注册信息:IANA → 注册局 → 注册商转介三跳,返回状态、注册商、关键日期与 NS。'
|
||||
description: '调用本项目的 /v1/whois/<domain> 接口查询域名注册信息:IANA → 注册局 → 注册商转介三跳,返回状态、注册商、关键日期与 NS。',
|
||||
seoTitle: 'API 文档:查询域名 WHOIS 注册信息(三跳转介)',
|
||||
seoDescription: '调用本项目的 /v1/whois/<domain> 接口查询域名注册信息:按 IANA → 注册局 → 注册商三跳转介获取完整 WHOIS 数据,返回查询状态、注册商、注册/到期等关键日期与 NS 服务器字段。'
|
||||
},
|
||||
'/doc/server/check_userip_ipv4_ipv6': {
|
||||
title: 'JS 检查网络是 IPv4 还是 IPv6',
|
||||
description: '通过 JS 代码检查客户端是 IPv4 还是 IPv6 访问优先。'
|
||||
description: '通过 JS 代码检查客户端是 IPv4 还是 IPv6 访问优先。',
|
||||
seoTitle: 'JS 检查客户端网络是 IPv4 还是 IPv6 访问优先',
|
||||
seoDescription: '通过一段前端 JS 代码检查客户端当前是 IPv4 还是 IPv6 访问、以及双栈网络下的访问优先级:分别向 IPv4/IPv6 探测端点发起请求并按可达性判定,可嵌入任意网页实时展示访客的网络栈状态。'
|
||||
},
|
||||
'/doc/server/tls': {
|
||||
title: '网站如何开启 TLS',
|
||||
description: '用 Wireshark 抓包分析 TLS 1.3 的 TCP 三次握手与 TLS 协商全过程。'
|
||||
description: '用 Wireshark 抓包分析 TLS 1.3 的 TCP 三次握手与 TLS 协商全过程。',
|
||||
seoTitle: '网站如何开启 TLS:TLS 1.3 握手过程抓包分析',
|
||||
seoDescription: '用 Wireshark 抓包分析 TLS 1.3 的 TCP 三次握手与 TLS 协商全过程:ClientHello、密钥共享(Key Share)、证书验证与 Finished 报文逐个拆解,理解网站开启 TLS 加密后的完整建连链路。'
|
||||
},
|
||||
'/doc/user/code_getip': {
|
||||
title: 'Python/Go 获取 IPv4 和 IPv6 地址',
|
||||
description: '通过 Python/Golang 获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。'
|
||||
description: '通过 Python/Golang 获取公网 IPv4 和 IPv6 地址,还可以返回是 IPv4 还是 IPv6 访问优先。',
|
||||
seoTitle: 'Python / Golang 代码获取公网 IPv4 与 IPv6 地址',
|
||||
seoDescription: '用 Python 和 Golang 获取本机公网 IPv4 和 IPv6 地址:分别请求 IPv4、IPv6 探测端点并判定当前网络的访问优先栈,附可直接运行的完整示例代码,适合嵌入监控脚本或后端服务使用。'
|
||||
},
|
||||
'/doc/user/dns_dig': {
|
||||
title: 'DNS 解析流程',
|
||||
description: '剖析 DNS 解析流程'
|
||||
description: '剖析 DNS 解析流程',
|
||||
seoTitle: 'DNS 解析流程详解:递归查询与迭代查询全过程',
|
||||
seoDescription: '剖析一次完整的 DNS 解析流程:浏览器缓存 → 操作系统缓存 → 本地 DNS 服务器的递归查询 → 根域名 / 顶级域 / 权威服务器的迭代过程,配合 dig 命令逐步演示,理解域名解析为何时快时慢。'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/*
|
||||
前端的一系列配置
|
||||
*/
|
||||
|
||||
const config = {
|
||||
siteUrl: "https://nipw.cn/",
|
||||
// 站点名称:用于页面标题 / 描述 / 页脚品牌展示
|
||||
@@ -18,8 +19,9 @@ const config = {
|
||||
// 前端自带中间件始终放在候选列表最后一位兜底。
|
||||
// 未配置或为空数组时,直接使用前端自带中间件。
|
||||
Middleware: <string[]>[
|
||||
"https://middleware-1.api-ipw.wsmdn.top/"
|
||||
// "http://127.0.0.1:8092/",
|
||||
"https://middleware-1.api-ipw.wsmdn.top/",
|
||||
"https://boce-api.api-ipw.wsmdn.top/"
|
||||
|
||||
// "https://middleware-2.wsmdn.top/",
|
||||
],
|
||||
// Umami 统计
|
||||
@@ -35,24 +37,31 @@ const config = {
|
||||
v4OnlyAPI: "https://4.wsmdn.top",
|
||||
v6OnlyAPI: "https://6.wsmdn.top",
|
||||
DualStackAPI: "https://test.wsmdn.top",
|
||||
// 网站截图服务(screenshot 页主通道):thum.io 同步出图、CORS 全开,
|
||||
// 响应 content-type 即结果信号(PNG/JPEG=成品,GIF=生成中或目标不可达),
|
||||
// 失败自动回退 WordPress mshots(s0.wp.com,见 screenshot.vue)。
|
||||
// 写进 config 的另一个作用:nuxt.config 的 extractDomains 会把它收进
|
||||
// CSP connect-src 白名单,页面里的 fetch 才能通过安全头。
|
||||
screenshotAPI: "https://image.thum.io",
|
||||
// ---- 节点池结构 ----
|
||||
// IPLocationAPI:IP 归属查询(location / asn)上游节点池(纯数组,无栈区分)
|
||||
IPLocationAPI: [
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
},
|
||||
}
|
||||
// {
|
||||
// label: "中国 湖北 武汉 电信",
|
||||
// id: "cn-wuhan-chinatelecom",
|
||||
// url: ""
|
||||
// },
|
||||
{
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
|
||||
}
|
||||
|
||||
],
|
||||
// APIBaseURL:其余拨测(whois / ssl / detail / dns / dnssec / tcping / speed)上游节点池,含 { IPv6, IPv4, DualStack } 三栈
|
||||
APIBaseURL: {
|
||||
@@ -94,12 +103,19 @@ const config = {
|
||||
id: "us-la",
|
||||
url: "https://america-california-losangeles.ipwapi.zfap.wsmdn.top/"
|
||||
},
|
||||
// IP 直连新节点:URL 留空,仅经独立中间件转发
|
||||
{
|
||||
label: "山东 枣庄 双线",
|
||||
id: "zaozhuang",
|
||||
url: ""
|
||||
// 与 IPLocationAPI 池共享的节点:ASN 页取两池交集,
|
||||
// 只有同时出现在两个池里的节点才会显示(cn-xian 仅双栈池故不显示)
|
||||
label: "中国 四川 沙渠 电信[ZFC]",
|
||||
id: "cn2-sichuan",
|
||||
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
|
||||
},
|
||||
// IP 直连新节点:URL 留空,仅经独立中间件转发
|
||||
{
|
||||
label: "香港 Cogent",
|
||||
id: "hongkong",
|
||||
@@ -113,7 +129,6 @@ const config = {
|
||||
// }
|
||||
],
|
||||
DualStack: [
|
||||
|
||||
{
|
||||
label: "中国 江苏 移动",
|
||||
id: "cn-jiangsu",
|
||||
@@ -124,9 +139,25 @@ const config = {
|
||||
id: "cn-shenzhen",
|
||||
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
|
||||
},
|
||||
// {
|
||||
// label: "河南 安阳 联通",
|
||||
// "id": "anyang-cu"
|
||||
// },
|
||||
// 仅经独立中间件转发:URL 留空,内置中间件会直接报 502 "Backend URL not configured"
|
||||
{
|
||||
label: "河南 安阳 联通",
|
||||
"id": "anyang-cu"
|
||||
label: "上海市 上海市 奉贤区 移动",
|
||||
id: "70dc5411-0cb6-425d-838f-a9bc81984ed4",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "江西 南昌 移动",
|
||||
id: "97d8873f-f0bb-4e3a-bf41-0b78878b8f34",
|
||||
url: ""
|
||||
},
|
||||
{
|
||||
label: "中国 广东 梅州 电信",
|
||||
id: "b3e06025-d9bd-458d-b1c4-cbba27f2a537",
|
||||
url: ""
|
||||
}
|
||||
// {
|
||||
// label: "中国 湖北 武汉 电信",
|
||||
|
||||
@@ -131,7 +131,10 @@ export default defineNuxtConfig({
|
||||
}
|
||||
},
|
||||
runtimeConfig: {
|
||||
indexnowKey: '',
|
||||
// IndexNow 所有权凭证:与 public/<同名>.txt 一致(IndexNow 靠抓取该文件验证),
|
||||
// key 本身是公开凭证不是机密;环境变量 NUXT_INDEXNOW_KEY 可覆盖。
|
||||
// 消费方:server/api/indexnow.*.ts(运行时提交端点)。
|
||||
indexnowKey: 'd49c90fb2fb160cdd2cd4b09532bc380',
|
||||
apiKeys: '',
|
||||
public: {
|
||||
siteUrl: config.siteUrl,
|
||||
@@ -196,7 +199,7 @@ export default defineNuxtConfig({
|
||||
],
|
||||
|
||||
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
|
||||
'img-src': ["'self'", 'https://s0.wp.com', 'data:', 'https:'],
|
||||
'img-src': ["'self'", 'https://s0.wp.com', 'data:', 'blob:', 'https:'],
|
||||
'font-src': ["'self'", 'https:', 'data:'],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,14 @@
|
||||
"@shikijs/engine-javascript": "^4.4.1",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"@vueuse/nuxt": "^14.4.0",
|
||||
"element-plus": "^2.14.3",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
"is-ip": "^5.0.1",
|
||||
"leaflet": "1.9.4",
|
||||
"markdown-it": "^14.3.0",
|
||||
"markdown-it-anchor": "^9.2.1",
|
||||
"nuxt": "^4.5.1",
|
||||
|
||||
Generated
+172
-159
@@ -16,13 +16,13 @@ importers:
|
||||
version: 1.1.5(@element-plus/icons-vue@2.3.2(vue@3.5.40(typescript@5.9.3)))(element-plus@2.14.3(vue@3.5.40(typescript@5.9.3)))(esbuild@0.28.1)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))
|
||||
'@nuxtjs/robots':
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3(d7dabbf3135c346ebe465b397dbcc84c)
|
||||
version: 6.1.3(44499e2290ee408d98cc04f71ac84946)
|
||||
'@nuxtjs/sitemap':
|
||||
specifier: ^8.3.2
|
||||
version: 8.3.2(d7dabbf3135c346ebe465b397dbcc84c)
|
||||
version: 8.3.2(44499e2290ee408d98cc04f71ac84946)
|
||||
'@nuxtjs/tailwindcss':
|
||||
specifier: 6.14.0
|
||||
version: 6.14.0(magicast@0.5.4)(supports-color@10.0.0)(yaml@2.9.0)
|
||||
version: 6.14.0(magicast@0.5.4)(yaml@2.9.0)
|
||||
'@shikijs/core':
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1
|
||||
@@ -35,12 +35,15 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))
|
||||
'@types/leaflet':
|
||||
specifier: ^1.9.22
|
||||
version: 1.9.22
|
||||
'@vueuse/core':
|
||||
specifier: ^14.4.0
|
||||
version: 14.4.0(vue@3.5.40(typescript@5.9.3))
|
||||
'@vueuse/nuxt':
|
||||
specifier: ^14.4.0
|
||||
version: 14.4.0(magic-string@1.1.0)(magicast@0.5.4)(nuxt@4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1(supports-color@10.0.0))(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(supports-color@10.0.0)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))(vue@3.5.40(typescript@5.9.3))
|
||||
version: 14.4.0(magic-string@1.1.0)(magicast@0.5.4)(nuxt@4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))(vue@3.5.40(typescript@5.9.3))
|
||||
element-plus:
|
||||
specifier: ^2.14.3
|
||||
version: 2.14.3(vue@3.5.40(typescript@5.9.3))
|
||||
@@ -53,6 +56,9 @@ importers:
|
||||
is-ip:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
leaflet:
|
||||
specifier: 1.9.4
|
||||
version: 1.9.4
|
||||
markdown-it:
|
||||
specifier: ^14.3.0
|
||||
version: 14.3.0
|
||||
@@ -61,10 +67,10 @@ importers:
|
||||
version: 9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0)
|
||||
nuxt:
|
||||
specifier: ^4.5.1
|
||||
version: 4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1(supports-color@10.0.0))(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(supports-color@10.0.0)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0)
|
||||
version: 4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0)
|
||||
nuxt-security:
|
||||
specifier: 2.6.0
|
||||
version: 2.6.0(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(rollup@4.62.4)(supports-color@10.0.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))
|
||||
version: 2.6.0(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(rollup@4.62.4)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))
|
||||
vue:
|
||||
specifier: ^3.5.40
|
||||
version: 3.5.40(typescript@5.9.3)
|
||||
@@ -1658,12 +1664,18 @@ packages:
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/geojson@7946.0.16':
|
||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||
|
||||
'@types/hast@3.0.5':
|
||||
resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
|
||||
|
||||
'@types/jsesc@2.5.1':
|
||||
resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==}
|
||||
|
||||
'@types/leaflet@1.9.22':
|
||||
resolution: {integrity: sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==}
|
||||
|
||||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
@@ -2457,8 +2469,8 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
devalue@5.9.2:
|
||||
resolution: {integrity: sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==}
|
||||
devalue@5.9.4:
|
||||
resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==}
|
||||
|
||||
devframe@0.7.16:
|
||||
resolution: {integrity: sha512-U4vqEPgjYfovFEyOAmUlNG5DVGnb6Sx0Tgy9QiQAbtGieC5zTraOBL7GUyXGqdc53SCx/DhFfwjJcebt85T/Bw==}
|
||||
@@ -3118,6 +3130,9 @@ packages:
|
||||
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
||||
engines: {node: '>= 0.6.3'}
|
||||
|
||||
leaflet@1.9.4:
|
||||
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -4375,8 +4390,8 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shell-quote@1.10.0:
|
||||
resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==}
|
||||
shell-quote@1.12.0:
|
||||
resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
shiki@4.4.1:
|
||||
@@ -5267,20 +5282,20 @@ snapshots:
|
||||
|
||||
'@babel/compat-data@7.29.7': {}
|
||||
|
||||
'@babel/core@7.29.7(supports-color@10.0.0)':
|
||||
'@babel/core@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/generator': 7.29.8
|
||||
'@babel/helper-compilation-targets': 7.29.7
|
||||
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.0.0))(supports-color@10.0.0)
|
||||
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/helpers': 7.29.7
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/traverse': 7.29.8(supports-color@10.0.0)
|
||||
'@babel/traverse': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
debug: 4.4.3
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@@ -5316,41 +5331,41 @@ snapshots:
|
||||
lru-cache: 5.1.1
|
||||
semver: 6.3.1
|
||||
|
||||
'@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@10.0.0))(supports-color@10.0.0)':
|
||||
'@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-annotate-as-pure': 7.29.7
|
||||
'@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/helper-member-expression-to-functions': 7.29.7
|
||||
'@babel/helper-optimise-call-expression': 7.29.7
|
||||
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.0.0))(supports-color@10.0.0)
|
||||
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/traverse': 7.29.8(supports-color@10.0.0)
|
||||
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
|
||||
'@babel/traverse': 7.29.8
|
||||
semver: 6.3.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-globals@7.29.7': {}
|
||||
|
||||
'@babel/helper-member-expression-to-functions@7.29.7(supports-color@10.0.0)':
|
||||
'@babel/helper-member-expression-to-functions@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.8(supports-color@10.0.0)
|
||||
'@babel/traverse': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-imports@7.29.7(supports-color@10.0.0)':
|
||||
'@babel/helper-module-imports@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.8(supports-color@10.0.0)
|
||||
'@babel/traverse': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.0.0))(supports-color@10.0.0)':
|
||||
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
'@babel/traverse': 7.29.8(supports-color@10.0.0)
|
||||
'@babel/traverse': 7.29.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -5360,18 +5375,18 @@ snapshots:
|
||||
|
||||
'@babel/helper-plugin-utils@7.29.7': {}
|
||||
|
||||
'@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@10.0.0))(supports-color@10.0.0)':
|
||||
'@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-member-expression-to-functions': 7.29.7
|
||||
'@babel/helper-optimise-call-expression': 7.29.7
|
||||
'@babel/traverse': 7.29.8(supports-color@10.0.0)
|
||||
'@babel/traverse': 7.29.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@10.0.0)':
|
||||
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.8(supports-color@10.0.0)
|
||||
'@babel/traverse': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -5399,24 +5414,24 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 8.0.4
|
||||
|
||||
'@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.0.0))':
|
||||
'@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.29.7
|
||||
|
||||
'@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.0.0))':
|
||||
'@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.29.7
|
||||
|
||||
'@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.0.0))(supports-color@10.0.0)':
|
||||
'@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-annotate-as-pure': 7.29.7
|
||||
'@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.0.0))(supports-color@10.0.0)
|
||||
'@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/helper-plugin-utils': 7.29.7
|
||||
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.0.0))
|
||||
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
|
||||
'@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -5426,7 +5441,7 @@ snapshots:
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
|
||||
'@babel/traverse@7.29.8(supports-color@10.0.0)':
|
||||
'@babel/traverse@7.29.8':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/generator': 7.29.8
|
||||
@@ -5434,7 +5449,7 @@ snapshots:
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.8
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -5875,9 +5890,9 @@ snapshots:
|
||||
dependencies:
|
||||
zod: 4.4.3
|
||||
|
||||
'@koa/router@12.0.1(supports-color@10.0.0)':
|
||||
'@koa/router@12.0.1':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
debug: 4.4.3
|
||||
http-errors: 2.0.1
|
||||
koa-compose: 4.1.0
|
||||
methods: 1.1.2
|
||||
@@ -5885,19 +5900,19 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@kwsites/file-exists@1.1.1(supports-color@10.0.0)':
|
||||
'@kwsites/file-exists@1.1.1':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@kwsites/promise-deferred@1.1.1': {}
|
||||
|
||||
'@mapbox/node-pre-gyp@2.0.0(supports-color@10.0.0)':
|
||||
'@mapbox/node-pre-gyp@2.0.0':
|
||||
dependencies:
|
||||
consola: 3.4.2
|
||||
detect-libc: 2.1.2
|
||||
https-proxy-agent: 7.0.5(supports-color@10.0.0)
|
||||
https-proxy-agent: 7.0.5
|
||||
node-fetch: 2.7.0
|
||||
nopt: 8.0.0
|
||||
semver: 7.8.5
|
||||
@@ -5937,7 +5952,7 @@ snapshots:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@nuxt/cli@3.37.0(@nuxt/schema@4.5.1)(@parcel/watcher@2.5.6)(magicast@0.5.4)(supports-color@10.0.0)':
|
||||
'@nuxt/cli@3.37.0(@nuxt/schema@4.5.1)(@parcel/watcher@2.5.6)(magicast@0.5.4)':
|
||||
dependencies:
|
||||
'@bomb.sh/tab': 0.0.19(citty@0.2.2)
|
||||
'@clack/prompts': 1.7.0
|
||||
@@ -5945,7 +5960,7 @@ snapshots:
|
||||
citty: 0.2.2
|
||||
confbox: 0.2.4
|
||||
consola: 3.4.2
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
debug: 4.4.3
|
||||
defu: 6.1.7
|
||||
exsolve: 1.1.1
|
||||
fuse.js: 7.5.0
|
||||
@@ -6013,7 +6028,7 @@ snapshots:
|
||||
pkg-types: 2.3.1
|
||||
semver: 7.8.5
|
||||
|
||||
'@nuxt/devtools@3.4.0(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1(supports-color@10.0.0))(magic-string@1.1.0)(oxc-parser@0.140.0)(rolldown@1.2.1)(supports-color@10.0.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))':
|
||||
'@nuxt/devtools@3.4.0(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1)(magic-string@1.1.0)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@nuxt/devtools-kit': 3.4.0(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))
|
||||
'@nuxt/devtools-wizard': 3.4.0
|
||||
@@ -6039,11 +6054,11 @@ snapshots:
|
||||
perfect-debounce: 2.1.0
|
||||
pkg-types: 2.3.1
|
||||
semver: 7.8.5
|
||||
simple-git: 3.36.0(supports-color@10.0.0)
|
||||
simple-git: 3.36.0
|
||||
sirv: 3.0.2
|
||||
structured-clone-es: 2.0.1
|
||||
tinyglobby: 0.2.17
|
||||
unstorage: 1.17.5(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1(supports-color@10.0.0))
|
||||
unstorage: 1.17.5(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1)
|
||||
vite: 8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)
|
||||
vite-plugin-inspect: 11.4.1(@nuxt/kit@4.5.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))))(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))
|
||||
vite-plugin-vue-tracer: 1.4.0(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
|
||||
@@ -6164,7 +6179,7 @@ snapshots:
|
||||
- rolldown
|
||||
- unplugin
|
||||
|
||||
'@nuxt/nitro-server@4.5.1(00f251ea06a52b7897ea2534a2e9c338)':
|
||||
'@nuxt/nitro-server@4.5.1(4be7fb31efaae22ce2a686c8078eaafa)':
|
||||
dependencies:
|
||||
'@nuxt/devalue': 2.0.2
|
||||
'@nuxt/kit': 4.5.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))
|
||||
@@ -6173,7 +6188,7 @@ snapshots:
|
||||
consola: 3.4.2
|
||||
defu: 6.1.7
|
||||
destr: 2.0.5
|
||||
devalue: 5.9.2
|
||||
devalue: 5.9.4
|
||||
errx: 0.1.2
|
||||
escape-string-regexp: 5.0.0
|
||||
exsolve: 1.1.1
|
||||
@@ -6181,9 +6196,9 @@ snapshots:
|
||||
impound: 1.1.6(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))
|
||||
klona: 2.0.6
|
||||
mocked-exports: 0.1.1
|
||||
nitropack: 2.13.4(@parcel/watcher@2.5.6)(better-sqlite3@12.11.1)(oxc-parser@0.140.0)(rolldown@1.2.1)(srvx@0.11.22)(supports-color@10.0.0)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))
|
||||
nitropack: 2.13.4(@parcel/watcher@2.5.6)(better-sqlite3@12.11.1)(oxc-parser@0.140.0)(rolldown@1.2.1)(srvx@0.11.22)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))
|
||||
nostics: 1.2.0
|
||||
nuxt: 4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1(supports-color@10.0.0))(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(supports-color@10.0.0)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0)
|
||||
nuxt: 4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0)
|
||||
nypm: 0.6.9
|
||||
ohash: 2.0.11
|
||||
pathe: 2.0.3
|
||||
@@ -6191,12 +6206,12 @@ snapshots:
|
||||
std-env: 4.2.0
|
||||
ufo: 1.6.4
|
||||
unctx: 3.0.0(magic-string@1.1.0)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))
|
||||
unstorage: 1.17.5(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1(supports-color@10.0.0))
|
||||
unstorage: 1.17.5(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1)
|
||||
vue: 3.5.40(typescript@5.9.3)
|
||||
vue-bundle-renderer: 2.3.1
|
||||
vue-devtools-stub: 0.1.0
|
||||
optionalDependencies:
|
||||
'@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.0.0))
|
||||
'@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7)
|
||||
transitivePeerDependencies:
|
||||
- '@azure/app-configuration'
|
||||
- '@azure/cosmos'
|
||||
@@ -6269,11 +6284,11 @@ snapshots:
|
||||
rc9: 3.0.1
|
||||
std-env: 4.2.0
|
||||
|
||||
'@nuxt/vite-builder@4.5.1(90e47d9be2a5bdbf0faa18fc6b372899)':
|
||||
'@nuxt/vite-builder@4.5.1(d3f4e6fef6236f395bf4699fde3f7ca6)':
|
||||
dependencies:
|
||||
'@nuxt/kit': 4.5.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))
|
||||
'@vitejs/plugin-vue': 6.0.8(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue-jsx': 5.1.6(supports-color@10.0.0)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue-jsx': 5.1.6(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
|
||||
autoprefixer: 10.5.4(postcss@8.5.25)
|
||||
consola: 3.4.2
|
||||
cssnano: 8.0.2(postcss@8.5.25)
|
||||
@@ -6287,7 +6302,7 @@ snapshots:
|
||||
knitwork: 1.3.0
|
||||
mlly: 1.8.2
|
||||
mocked-exports: 0.1.1
|
||||
nuxt: 4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1(supports-color@10.0.0))(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(supports-color@10.0.0)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0)
|
||||
nuxt: 4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0)
|
||||
nypm: 0.6.9
|
||||
pathe: 2.0.3
|
||||
pkg-types: 2.3.1
|
||||
@@ -6297,23 +6312,19 @@ snapshots:
|
||||
std-env: 4.2.0
|
||||
ufo: 1.6.4
|
||||
unenv: 2.0.0-rc.24
|
||||
unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))
|
||||
vite: 8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)
|
||||
vite-node: 6.0.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)
|
||||
vite-plugin-checker: 0.14.5(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))
|
||||
vue: 3.5.40(typescript@5.9.3)
|
||||
vue-bundle-renderer: 2.3.1
|
||||
optionalDependencies:
|
||||
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.0.0))
|
||||
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
|
||||
rolldown: 1.2.1
|
||||
rollup-plugin-visualizer: 7.0.1(rolldown@1.2.1)(rollup@4.62.4)
|
||||
transitivePeerDependencies:
|
||||
- '@biomejs/biome'
|
||||
- '@farmfe/core'
|
||||
- '@rspack/core'
|
||||
- '@types/node'
|
||||
- '@vitejs/devtools'
|
||||
- bun-types-no-globals
|
||||
- esbuild
|
||||
- eslint
|
||||
- less
|
||||
@@ -6323,7 +6334,6 @@ snapshots:
|
||||
- optionator
|
||||
- oxc-parser
|
||||
- oxlint
|
||||
- rollup
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylelint
|
||||
@@ -6333,20 +6343,19 @@ snapshots:
|
||||
- terser
|
||||
- tsx
|
||||
- typescript
|
||||
- unloader
|
||||
- unplugin
|
||||
- vue-tsc
|
||||
- webpack
|
||||
- yaml
|
||||
|
||||
'@nuxtjs/robots@6.1.3(d7dabbf3135c346ebe465b397dbcc84c)':
|
||||
'@nuxtjs/robots@6.1.3(44499e2290ee408d98cc04f71ac84946)':
|
||||
dependencies:
|
||||
'@fingerprintjs/botd': 2.0.0
|
||||
'@nuxt/kit': 4.5.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))
|
||||
consola: 3.4.2
|
||||
defu: 6.1.7
|
||||
h3: 1.15.11
|
||||
nuxt-site-config: 4.1.2(d7dabbf3135c346ebe465b397dbcc84c)
|
||||
nuxtseo-shared: 5.3.6(f316e4244987355c955a735cd74b666d)
|
||||
nuxt-site-config: 4.1.2(44499e2290ee408d98cc04f71ac84946)
|
||||
nuxtseo-shared: 5.3.6(f5fc6f43a669b1b0cbad6a30003743a2)
|
||||
pathe: 2.0.3
|
||||
pkg-types: 2.3.1
|
||||
ufo: 1.6.4
|
||||
@@ -6363,13 +6372,13 @@ snapshots:
|
||||
- vite
|
||||
- vue
|
||||
|
||||
'@nuxtjs/sitemap@8.3.2(d7dabbf3135c346ebe465b397dbcc84c)':
|
||||
'@nuxtjs/sitemap@8.3.2(44499e2290ee408d98cc04f71ac84946)':
|
||||
dependencies:
|
||||
'@nuxt/kit': 4.5.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))
|
||||
consola: 3.4.2
|
||||
defu: 6.1.7
|
||||
nuxt-site-config: 4.1.2(d7dabbf3135c346ebe465b397dbcc84c)
|
||||
nuxtseo-shared: 5.3.6(f316e4244987355c955a735cd74b666d)
|
||||
nuxt-site-config: 4.1.2(44499e2290ee408d98cc04f71ac84946)
|
||||
nuxtseo-shared: 5.3.6(f5fc6f43a669b1b0cbad6a30003743a2)
|
||||
ofetch: 1.5.1
|
||||
pathe: 2.0.3
|
||||
pkg-types: 2.3.1
|
||||
@@ -6390,7 +6399,7 @@ snapshots:
|
||||
- vite
|
||||
- vue
|
||||
|
||||
'@nuxtjs/tailwindcss@6.14.0(magicast@0.5.4)(supports-color@10.0.0)(yaml@2.9.0)':
|
||||
'@nuxtjs/tailwindcss@6.14.0(magicast@0.5.4)(yaml@2.9.0)':
|
||||
dependencies:
|
||||
'@nuxt/kit': 3.21.10(magicast@0.5.4)
|
||||
autoprefixer: 10.5.4(postcss@8.5.25)
|
||||
@@ -6404,7 +6413,7 @@ snapshots:
|
||||
pkg-types: 2.3.1
|
||||
postcss: 8.5.25
|
||||
postcss-nesting: 13.0.1(postcss@8.5.25)
|
||||
tailwind-config-viewer: 2.0.4(supports-color@10.0.0)(tailwindcss@3.4.19(yaml@2.9.0))
|
||||
tailwind-config-viewer: 2.0.4(tailwindcss@3.4.19(yaml@2.9.0))
|
||||
tailwindcss: 3.4.19(yaml@2.9.0)
|
||||
ufo: 1.6.4
|
||||
unctx: 2.5.0
|
||||
@@ -6901,12 +6910,18 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
|
||||
'@types/hast@3.0.5':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/jsesc@2.5.1': {}
|
||||
|
||||
'@types/leaflet@1.9.22':
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/lodash-es@4.17.12':
|
||||
@@ -6989,9 +7004,9 @@ snapshots:
|
||||
dependencies:
|
||||
valibot: 1.4.2(typescript@5.9.3)
|
||||
|
||||
'@vercel/nft@1.10.2(rollup@4.62.4)(supports-color@10.0.0)':
|
||||
'@vercel/nft@1.10.2(rollup@4.62.4)':
|
||||
dependencies:
|
||||
'@mapbox/node-pre-gyp': 2.0.0(supports-color@10.0.0)
|
||||
'@mapbox/node-pre-gyp': 2.0.0
|
||||
'@rollup/pluginutils': 5.4.0(rollup@4.62.4)
|
||||
acorn: 8.18.0
|
||||
acorn-import-attributes: 1.9.5(acorn@8.18.0)
|
||||
@@ -7024,13 +7039,13 @@ snapshots:
|
||||
- srvx
|
||||
- typescript
|
||||
|
||||
'@vitejs/plugin-vue-jsx@5.1.6(supports-color@10.0.0)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue-jsx@5.1.6(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.0.0))
|
||||
'@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.0.0))(supports-color@10.0.0)
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7)
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
'@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@10.0.0))(supports-color@10.0.0)
|
||||
'@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7)
|
||||
vite: 8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)
|
||||
vue: 3.5.40(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
@@ -7054,27 +7069,27 @@ snapshots:
|
||||
|
||||
'@vue/babel-helper-vue-transform-on@2.0.1': {}
|
||||
|
||||
'@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7(supports-color@10.0.0))(supports-color@10.0.0)':
|
||||
'@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/helper-module-imports': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.29.7
|
||||
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.0.0))
|
||||
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/traverse': 7.29.8(supports-color@10.0.0)
|
||||
'@babel/traverse': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
'@vue/babel-helper-vue-transform-on': 2.0.1
|
||||
'@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.7(supports-color@10.0.0))(supports-color@10.0.0)
|
||||
'@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.7)
|
||||
'@vue/shared': 3.5.40
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/core': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.7(supports-color@10.0.0))(supports-color@10.0.0)':
|
||||
'@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/core': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.29.7
|
||||
'@babel/parser': 7.29.8
|
||||
'@vue/compiler-sfc': 3.5.40
|
||||
@@ -7172,13 +7187,13 @@ snapshots:
|
||||
|
||||
'@vueuse/metadata@14.4.0': {}
|
||||
|
||||
'@vueuse/nuxt@14.4.0(magic-string@1.1.0)(magicast@0.5.4)(nuxt@4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1(supports-color@10.0.0))(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(supports-color@10.0.0)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))(vue@3.5.40(typescript@5.9.3))':
|
||||
'@vueuse/nuxt@14.4.0(magic-string@1.1.0)(magicast@0.5.4)(nuxt@4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))(vue@3.5.40(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@nuxt/kit': 4.5.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))
|
||||
'@vueuse/core': 14.4.0(vue@3.5.40(typescript@5.9.3))
|
||||
'@vueuse/metadata': 14.4.0
|
||||
local-pkg: 1.2.1
|
||||
nuxt: 4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1(supports-color@10.0.0))(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(supports-color@10.0.0)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0)
|
||||
nuxt: 4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0)
|
||||
vue: 3.5.40(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- magic-string
|
||||
@@ -7212,9 +7227,9 @@ snapshots:
|
||||
|
||||
acorn@8.18.0: {}
|
||||
|
||||
agent-base@7.0.2(supports-color@10.0.0):
|
||||
agent-base@7.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7688,17 +7703,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
better-sqlite3: 12.11.1
|
||||
|
||||
debug@3.2.7(supports-color@10.0.0):
|
||||
debug@3.2.7:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optionalDependencies:
|
||||
supports-color: 10.0.0
|
||||
|
||||
debug@4.4.3(supports-color@10.0.0):
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optionalDependencies:
|
||||
supports-color: 10.0.0
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
@@ -7741,7 +7752,7 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
devalue@5.9.2: {}
|
||||
devalue@5.9.4: {}
|
||||
|
||||
devframe@0.7.16(srvx@0.11.22)(typescript@5.9.3):
|
||||
dependencies:
|
||||
@@ -8212,10 +8223,10 @@ snapshots:
|
||||
|
||||
http-shutdown@1.2.2: {}
|
||||
|
||||
https-proxy-agent@7.0.5(supports-color@10.0.0):
|
||||
https-proxy-agent@7.0.5:
|
||||
dependencies:
|
||||
agent-base: 7.0.2(supports-color@10.0.0)
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
agent-base: 7.0.2
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -8263,11 +8274,11 @@ snapshots:
|
||||
|
||||
ini@4.1.1: {}
|
||||
|
||||
ioredis@5.11.1(supports-color@10.0.0):
|
||||
ioredis@5.11.1:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.10.0
|
||||
cluster-key-slot: 1.1.1
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
debug: 4.4.3
|
||||
denque: 2.1.0
|
||||
redis-errors: 1.2.0
|
||||
redis-parser: 3.0.0
|
||||
@@ -8403,29 +8414,29 @@ snapshots:
|
||||
co: 4.6.0
|
||||
koa-compose: 4.1.0
|
||||
|
||||
koa-send@5.0.1(supports-color@10.0.0):
|
||||
koa-send@5.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
debug: 4.4.3
|
||||
http-errors: 1.8.1
|
||||
resolve-path: 1.4.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
koa-static@5.0.0(supports-color@10.0.0):
|
||||
koa-static@5.0.0:
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@10.0.0)
|
||||
koa-send: 5.0.1(supports-color@10.0.0)
|
||||
debug: 3.2.7
|
||||
koa-send: 5.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
koa@2.16.4(supports-color@10.0.0):
|
||||
koa@2.16.4:
|
||||
dependencies:
|
||||
accepts: 1.3.8
|
||||
cache-content-type: 1.0.1
|
||||
content-disposition: 0.5.2
|
||||
content-type: 1.0.4
|
||||
cookies: 0.9.1
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
debug: 4.4.3
|
||||
delegates: 1.0.0
|
||||
depd: 2.0.0
|
||||
destroy: 1.2.0
|
||||
@@ -8449,12 +8460,14 @@ snapshots:
|
||||
launch-editor@2.14.1:
|
||||
dependencies:
|
||||
picocolors: 1.1.1
|
||||
shell-quote: 1.10.0
|
||||
shell-quote: 1.12.0
|
||||
|
||||
lazystream@1.0.1:
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
|
||||
leaflet@1.9.4: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
@@ -8811,7 +8824,7 @@ snapshots:
|
||||
mlly: 1.8.2
|
||||
pkg-types: 2.3.1
|
||||
|
||||
nitropack@2.13.4(@parcel/watcher@2.5.6)(better-sqlite3@12.11.1)(oxc-parser@0.140.0)(rolldown@1.2.1)(srvx@0.11.22)(supports-color@10.0.0)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)):
|
||||
nitropack@2.13.4(@parcel/watcher@2.5.6)(better-sqlite3@12.11.1)(oxc-parser@0.140.0)(rolldown@1.2.1)(srvx@0.11.22)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@cloudflare/kv-asset-handler': 0.4.2
|
||||
'@rollup/plugin-alias': 6.0.0(rollup@4.62.4)
|
||||
@@ -8821,7 +8834,7 @@ snapshots:
|
||||
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.4)
|
||||
'@rollup/plugin-replace': 6.0.3(rollup@4.62.4)
|
||||
'@rollup/plugin-terser': 1.0.0(rollup@4.62.4)
|
||||
'@vercel/nft': 1.10.2(rollup@4.62.4)(supports-color@10.0.0)
|
||||
'@vercel/nft': 1.10.2(rollup@4.62.4)
|
||||
archiver: 7.0.1
|
||||
c12: 3.3.4(magicast@0.5.4)
|
||||
chokidar: 5.0.0
|
||||
@@ -8845,7 +8858,7 @@ snapshots:
|
||||
h3: 1.15.11
|
||||
hookable: 5.5.3
|
||||
httpxy: 0.5.5
|
||||
ioredis: 5.11.1(supports-color@10.0.0)
|
||||
ioredis: 5.11.1
|
||||
jiti: 2.7.0
|
||||
klona: 2.0.6
|
||||
knitwork: 1.3.0
|
||||
@@ -8868,7 +8881,7 @@ snapshots:
|
||||
scule: 1.3.0
|
||||
semver: 7.8.5
|
||||
serve-placeholder: 2.0.2
|
||||
serve-static: 2.2.1(supports-color@10.0.0)
|
||||
serve-static: 2.2.1
|
||||
source-map: 0.7.6
|
||||
std-env: 4.2.0
|
||||
ufo: 1.6.4
|
||||
@@ -8878,7 +8891,7 @@ snapshots:
|
||||
unenv: 2.0.0-rc.24
|
||||
unimport: 6.4.0(esbuild@0.28.1)(oxc-parser@0.140.0)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))
|
||||
unplugin-utils: 0.3.2
|
||||
unstorage: 1.17.5(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1(supports-color@10.0.0))
|
||||
unstorage: 1.17.5(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1)
|
||||
untyped: 2.0.0
|
||||
unwasm: 0.5.3
|
||||
youch: 4.1.1
|
||||
@@ -8980,14 +8993,14 @@ snapshots:
|
||||
- rolldown
|
||||
- unplugin
|
||||
|
||||
nuxt-security@2.6.0(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(rollup@4.62.4)(supports-color@10.0.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))):
|
||||
nuxt-security@2.6.0(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(rollup@4.62.4)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))):
|
||||
dependencies:
|
||||
'@nuxt/kit': 4.5.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))
|
||||
basic-auth: 2.0.1
|
||||
defu: 6.1.7
|
||||
nuxt-csurf: 1.6.6(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))
|
||||
pathe: 2.0.3
|
||||
unplugin-remove: 1.0.3(rollup@4.62.4)(supports-color@10.0.0)
|
||||
unplugin-remove: 1.0.3(rollup@4.62.4)
|
||||
xss: 1.0.15
|
||||
transitivePeerDependencies:
|
||||
- magic-string
|
||||
@@ -9012,13 +9025,13 @@ snapshots:
|
||||
- unplugin
|
||||
- vue
|
||||
|
||||
nuxt-site-config@4.1.2(d7dabbf3135c346ebe465b397dbcc84c):
|
||||
nuxt-site-config@4.1.2(44499e2290ee408d98cc04f71ac84946):
|
||||
dependencies:
|
||||
'@nuxt/devalue': 2.0.2
|
||||
'@nuxt/kit': 4.5.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))
|
||||
h3: 1.15.11
|
||||
nuxt-site-config-kit: 4.1.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))(vue@3.5.40(typescript@5.9.3))
|
||||
nuxtseo-shared: 5.3.6(f316e4244987355c955a735cd74b666d)
|
||||
nuxtseo-shared: 5.3.6(f5fc6f43a669b1b0cbad6a30003743a2)
|
||||
pathe: 2.0.3
|
||||
pkg-types: 2.3.1
|
||||
site-config-stack: 4.1.2(vue@3.5.40(typescript@5.9.3))
|
||||
@@ -9035,16 +9048,16 @@ snapshots:
|
||||
- vue
|
||||
- zod
|
||||
|
||||
nuxt@4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1(supports-color@10.0.0))(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(supports-color@10.0.0)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0):
|
||||
nuxt@4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0):
|
||||
dependencies:
|
||||
'@dxup/nuxt': 0.5.5(esbuild@0.28.1)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))
|
||||
'@nuxt/cli': 3.37.0(@nuxt/schema@4.5.1)(@parcel/watcher@2.5.6)(magicast@0.5.4)(supports-color@10.0.0)
|
||||
'@nuxt/devtools': 3.4.0(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1(supports-color@10.0.0))(magic-string@1.1.0)(oxc-parser@0.140.0)(rolldown@1.2.1)(supports-color@10.0.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
|
||||
'@nuxt/cli': 3.37.0(@nuxt/schema@4.5.1)(@parcel/watcher@2.5.6)(magicast@0.5.4)
|
||||
'@nuxt/devtools': 3.4.0(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1)(magic-string@1.1.0)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
|
||||
'@nuxt/kit': 4.5.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))
|
||||
'@nuxt/nitro-server': 4.5.1(00f251ea06a52b7897ea2534a2e9c338)
|
||||
'@nuxt/nitro-server': 4.5.1(4be7fb31efaae22ce2a686c8078eaafa)
|
||||
'@nuxt/schema': 4.5.1
|
||||
'@nuxt/telemetry': 2.8.0(@nuxt/kit@4.5.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))))
|
||||
'@nuxt/vite-builder': 4.5.1(90e47d9be2a5bdbf0faa18fc6b372899)
|
||||
'@nuxt/vite-builder': 4.5.1(d3f4e6fef6236f395bf4699fde3f7ca6)
|
||||
'@unhead/vue': 3.2.3(@oxc-project/types@0.142.0)(esbuild@0.28.1)(lightningcss@1.33.0)(rolldown@1.2.1)(rollup@4.62.4)(srvx@0.11.22)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
|
||||
'@vue/shared': 3.5.40
|
||||
chokidar: 5.0.0
|
||||
@@ -9052,7 +9065,7 @@ snapshots:
|
||||
consola: 3.4.2
|
||||
cookie-es: 3.1.1
|
||||
defu: 6.1.7
|
||||
devalue: 5.9.2
|
||||
devalue: 5.9.4
|
||||
errx: 0.1.2
|
||||
escape-string-regexp: 5.0.0
|
||||
exsolve: 1.1.1
|
||||
@@ -9175,7 +9188,7 @@ snapshots:
|
||||
- xml2js
|
||||
- yaml
|
||||
|
||||
nuxtseo-shared@5.3.6(f316e4244987355c955a735cd74b666d):
|
||||
nuxtseo-shared@5.3.6(f5fc6f43a669b1b0cbad6a30003743a2):
|
||||
dependencies:
|
||||
'@clack/prompts': 1.7.0
|
||||
'@nuxt/devtools-kit': 4.0.0-alpha.7(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)))(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))
|
||||
@@ -9184,7 +9197,7 @@ snapshots:
|
||||
birpc: 4.0.0
|
||||
consola: 3.4.2
|
||||
defu: 6.1.7
|
||||
nuxt: 4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.0.0)))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1(supports-color@10.0.0))(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(supports-color@10.0.0)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0)
|
||||
nuxt: 4.5.1(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.142.0)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.11.1)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(sass-embedded@1.100.0)(sass@1.100.0)(srvx@0.11.22)(terser@5.49.0)(typescript@5.9.3)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0))(yaml@2.9.0)
|
||||
nypm: 0.6.9
|
||||
ofetch: 1.5.1
|
||||
pathe: 2.0.3
|
||||
@@ -9195,7 +9208,7 @@ snapshots:
|
||||
ufo: 1.6.4
|
||||
vue: 3.5.40(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
nuxt-site-config: 4.1.2(d7dabbf3135c346ebe465b397dbcc84c)
|
||||
nuxt-site-config: 4.1.2(44499e2290ee408d98cc04f71ac84946)
|
||||
zod: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- magic-string
|
||||
@@ -9364,10 +9377,10 @@ snapshots:
|
||||
exsolve: 1.1.1
|
||||
pathe: 2.0.3
|
||||
|
||||
portfinder@1.0.38(supports-color@10.0.0):
|
||||
portfinder@1.0.38:
|
||||
dependencies:
|
||||
async: 3.2.6
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -9911,9 +9924,9 @@ snapshots:
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
send@1.2.1(supports-color@10.0.0):
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
debug: 4.4.3
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
@@ -9935,12 +9948,12 @@ snapshots:
|
||||
dependencies:
|
||||
defu: 6.1.7
|
||||
|
||||
serve-static@2.2.1(supports-color@10.0.0):
|
||||
serve-static@2.2.1:
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
parseurl: 1.3.3
|
||||
send: 1.2.1(supports-color@10.0.0)
|
||||
send: 1.2.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -9988,7 +10001,7 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shell-quote@1.10.0: {}
|
||||
shell-quote@1.12.0: {}
|
||||
|
||||
shiki@4.4.1:
|
||||
dependencies:
|
||||
@@ -10015,13 +10028,13 @@ snapshots:
|
||||
simple-concat: 1.0.1
|
||||
optional: true
|
||||
|
||||
simple-git@3.36.0(supports-color@10.0.0):
|
||||
simple-git@3.36.0:
|
||||
dependencies:
|
||||
'@kwsites/file-exists': 1.1.1(supports-color@10.0.0)
|
||||
'@kwsites/file-exists': 1.1.1
|
||||
'@kwsites/promise-deferred': 1.1.1
|
||||
'@simple-git/args-pathspec': 1.0.3
|
||||
'@simple-git/argv-parser': 1.1.1
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -10211,15 +10224,15 @@ snapshots:
|
||||
|
||||
tagged-tag@1.0.0: {}
|
||||
|
||||
tailwind-config-viewer@2.0.4(supports-color@10.0.0)(tailwindcss@3.4.19(yaml@2.9.0)):
|
||||
tailwind-config-viewer@2.0.4(tailwindcss@3.4.19(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@koa/router': 12.0.1(supports-color@10.0.0)
|
||||
'@koa/router': 12.0.1
|
||||
commander: 6.0.0
|
||||
fs-extra: 9.0.1
|
||||
koa: 2.16.4(supports-color@10.0.0)
|
||||
koa-static: 5.0.0(supports-color@10.0.0)
|
||||
koa: 2.16.4
|
||||
koa-static: 5.0.0
|
||||
open: 7.4.2
|
||||
portfinder: 1.0.38(supports-color@10.0.0)
|
||||
portfinder: 1.0.38
|
||||
replace-in-file: 6.1.0
|
||||
tailwindcss: 3.4.19(yaml@2.9.0)
|
||||
transitivePeerDependencies:
|
||||
@@ -10488,12 +10501,12 @@ snapshots:
|
||||
|
||||
universalify@2.0.1: {}
|
||||
|
||||
unplugin-remove@1.0.3(rollup@4.62.4)(supports-color@10.0.0):
|
||||
unplugin-remove@1.0.3(rollup@4.62.4):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@10.0.0)
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/generator': 7.29.8
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/traverse': 7.29.8(supports-color@10.0.0)
|
||||
'@babel/traverse': 7.29.8
|
||||
'@rollup/pluginutils': 5.4.0(rollup@4.62.4)
|
||||
magic-string: 0.30.11
|
||||
unplugin: 1.12.0
|
||||
@@ -10536,7 +10549,7 @@ snapshots:
|
||||
escape-string-regexp: 5.0.0
|
||||
ufo: 1.6.4
|
||||
|
||||
unstorage@1.17.5(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1(supports-color@10.0.0)):
|
||||
unstorage@1.17.5(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1):
|
||||
dependencies:
|
||||
anymatch: 3.1.3
|
||||
chokidar: 5.0.0
|
||||
@@ -10548,7 +10561,7 @@ snapshots:
|
||||
ufo: 1.6.4
|
||||
optionalDependencies:
|
||||
db0: 0.3.4(better-sqlite3@12.11.1)
|
||||
ioredis: 5.11.1(supports-color@10.0.0)
|
||||
ioredis: 5.11.1
|
||||
|
||||
untun@0.2.2: {}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
d49c90fb2fb160cdd2cd4b09532bc380
|
||||
@@ -0,0 +1,154 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1240 640" width="1240" height="640" data-generator="fireworks-tech-graph" data-schema-version="1" data-text-metrics="heuristic-v1" data-style-id="7" data-visual-theme="OpenAI" data-diagram-type="architecture" data-motion-scene="" data-semantic-profile="generic" data-semantic-valid="true" data-quality-profile="standard" data-max-bends-per-edge="12" data-max-total-bends="100" data-max-route-stretch="5.0" data-max-bridged-crossings="8" data-min-node-gap="0.0" data-min-container-gutter="0.0" data-min-label-clearance="2.0" data-min-segment-length="0.0">
|
||||
<defs>
|
||||
<marker id="arrowA" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#10a37f"/>
|
||||
</marker>
|
||||
<marker id="arrowB" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#0f766e"/>
|
||||
</marker>
|
||||
<marker id="arrowC" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#0891b2"/>
|
||||
</marker>
|
||||
<marker id="arrowE" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#f59e0b"/>
|
||||
</marker>
|
||||
<marker id="arrowF" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#64748b"/>
|
||||
</marker>
|
||||
<marker id="arrowG" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#475569"/>
|
||||
</marker>
|
||||
<marker id="arrowH" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8"/>
|
||||
</marker>
|
||||
<style>
|
||||
text { font-family: 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Noto Sans CJK SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif; }
|
||||
.title { font-size: 24px; font-weight: 700; fill: #0f172a; }
|
||||
.subtitle { font-size: 13px; font-weight: 500; fill: #64748b; }
|
||||
.section { font-size: 13px; font-weight: 700; fill: #087f5b; letter-spacing: 1.4px; }
|
||||
.section-sub { font-size: 12px; font-weight: 500; fill: #64748b; }
|
||||
.node-title { font-weight: 700; fill: #0f172a; }
|
||||
.node-sub { font-weight: 500; fill: #475569; }
|
||||
.node-type { font-size: 11px; font-weight: 700; fill: #64748b; letter-spacing: 0.08em; }
|
||||
.arrow-label { font-size: 12px; font-weight: 600; fill: #475569; }
|
||||
.legend { font-size: 12px; font-weight: 500; fill: #475569; }
|
||||
.footnote { font-size: 12px; font-weight: 500; fill: #64748b; }
|
||||
.metric-label { font-size: 8.5px; font-weight: 700; fill: #64748b; text-transform: uppercase; }
|
||||
.metric-value { font-size: 9.5px; font-weight: 700; fill: #0f172a; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect data-graph-role="background" width="1240.0" height="640.0" fill="#ffffff"/>
|
||||
<text x="48.0" y="48" text-anchor="start" class="title">DNSSEC 验证链与本地验签</text>
|
||||
<text x="48.0" y="72" text-anchor="start" class="subtitle">DS 信任链自根向下逐级建立,递归侧以 DNSKEY 验证 RRset 签名</text>
|
||||
<line x1="48" y1="100" x2="1192.0" y2="100" stroke="#e2e8f0" stroke-width="1"/>
|
||||
<g id="trust" data-graph-role="container" data-container-id="trust" data-semantic-role="boundary" data-graph-bounds="40,110,1200,260">
|
||||
<rect data-graph-role="container" x="40.0" y="110.0" width="1160.0" height="150.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
|
||||
<text x="58.0" y="134.0" class="section">信任链 · 由根向下逐级签发</text>
|
||||
<rect id="trust-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="116" width="189.9" height="30" fill="none" stroke="none"/>
|
||||
</g>
|
||||
<g id="verify" data-graph-role="container" data-container-id="verify" data-semantic-role="boundary" data-graph-bounds="40,310,1200,560">
|
||||
<rect data-graph-role="container" x="40.0" y="310.0" width="1160.0" height="250.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
|
||||
<text x="58.0" y="334.0" class="section">递归解析侧 · 本地验签</text>
|
||||
<rect id="verify-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="316" width="163.9" height="30" fill="none" stroke="none"/>
|
||||
</g>
|
||||
<path id="ds-tld" data-graph-role="edge" data-edge-id="ds-tld" data-source="root" data-target="tld" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 330,195 L 495,195" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="ds-auth" data-graph-role="edge" data-edge-id="ds-auth" data-source="tld" data-target="auth" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 745,195 L 905,195" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="pub-key" data-graph-role="edge" data-edge-id="pub-key" data-source="auth" data-target="dnskey" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 1023.5,225 L 1023.5,346 L 205,346 L 205,370" fill="none" stroke="#0f766e" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
|
||||
<path id="sign" data-graph-role="edge" data-edge-id="sign" data-source="auth" data-target="rrset" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="6" data-route-stretch="1.872" data-bridges="" d="M 1041.5,225 L 1041.5,245.4 L 1184,245.4 L 1184,454 L 745,454 L 745,349.6 L 620,349.6 L 620,370" fill="none" stroke="#0f766e" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
|
||||
<path id="fetch-key-bridge-mask" data-graph-role="bridge-mask" data-owner="fetch-key" d="M 330,400 L 350.4,400 L 350.4,461 L 884.6,461 L 884.6,391 L 905,391" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="fetch-key" data-graph-role="edge" data-edge-id="fetch-key" data-source="dnskey" data-target="resolver" data-edge-kind="flow" data-topic-id="" data-flow="read" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.209" data-bridges="884.6,454" d="M 330,400 L 350.4,400 L 350.4,461 L 884.6,461 L 884.6,391 L 905,391" fill="none" stroke="#0891b2" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowC)"/>
|
||||
<path id="fetch-rr-bridge-mask" data-graph-role="bridge-mask" data-owner="fetch-rr" d="M 745,400 L 748.28,400 L 748.28,409 L 879.6,409 A 5 5 0 0 0 889.6,409 L 905,409" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="fetch-rr" data-graph-role="edge" data-edge-id="fetch-rr" data-source="rrset" data-target="resolver" data-edge-kind="flow" data-topic-id="" data-flow="read" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="745,400;884.6,409" d="M 745,400 L 748.28,400 L 748.28,409 L 879.6,409 A 5 5 0 0 0 889.6,409 L 905,409" fill="none" stroke="#0891b2" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowC)"/>
|
||||
<path id="a-check-bridge-mask" data-graph-role="bridge-mask" data-owner="a-check" d="M 1032.5,430 L 1032.5,450.4 L 1044,450.4 L 1044,565 L 590,565 L 590,485 L 445,485 L 445,485" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="a-check" data-graph-role="edge" data-edge-id="a-check" data-source="resolver" data-target="check" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="5" data-route-stretch="1.285" data-bridges="1044,454" d="M 1032.5,430 L 1032.5,450.4 L 1044,450.4 L 1044,565 L 590,565 L 590,485 L 445,485 L 445,485" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-pass-bridge-mask" data-graph-role="bridge-mask" data-owner="a-pass" d="M 590,513 L 730,513" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="a-pass" data-graph-role="edge" data-edge-id="a-pass" data-source="check" data-target="pass" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="590,513" d="M 590,513 L 730,513" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<g id="node-root" data-graph-role="node" data-node-id="root" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="80,165,330,225">
|
||||
<rect x="80.0" y="165.0" width="250.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="205.0" y="183.0" text-anchor="middle" class="node-type">ROOT · 信任锚</text>
|
||||
<text data-text-role="title" data-full-text="根 DNS(内置信任锚)" data-text-max-width="226.0" x="205.0" y="201.0" text-anchor="middle" class="node-title" font-size="18">根 DNS(内置信任锚)</text>
|
||||
</g>
|
||||
<g id="node-tld" data-graph-role="node" data-node-id="tld" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="495,165,745,225">
|
||||
<rect x="495.0" y="165.0" width="250.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#10a37f" stroke-width="2.0"/>
|
||||
<rect x="501.0" y="171.0" width="238.0" height="48.0" rx="11.0" fill="none" stroke="#10a37f" stroke-width="1.2" opacity="0.65"/>
|
||||
<text x="620.0" y="183.0" text-anchor="middle" class="node-type">TLD · .top</text>
|
||||
<text data-text-role="title" data-full-text="顶级 DNS(发布 DS)" data-text-max-width="218.0" x="620.0" y="201.0" text-anchor="middle" class="node-title" font-size="18">顶级 DNS(发布 DS)</text>
|
||||
</g>
|
||||
<g id="node-auth" data-graph-role="node" data-node-id="auth" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="905,165,1160,225">
|
||||
<rect x="905.0" y="165.0" width="255.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#10a37f" stroke-width="2.0"/>
|
||||
<rect x="911.0" y="171.0" width="243.0" height="48.0" rx="11.0" fill="none" stroke="#10a37f" stroke-width="1.2" opacity="0.65"/>
|
||||
<text x="1032.5" y="183.0" text-anchor="middle" class="node-type">AUTHORITATIVE</text>
|
||||
<text data-text-role="title" data-full-text="权威 DNS(发布 DNSKEY)" data-text-max-width="223.0" x="1032.5" y="201.0" text-anchor="middle" class="node-title" font-size="17.29">权威 DNS(发布 DNSKEY)</text>
|
||||
</g>
|
||||
<g id="node-dnskey" data-graph-role="node" data-node-id="dnskey" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="80,370,330,430">
|
||||
<rect x="80.0" y="370.0" width="250.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="205.0" y="388.0" text-anchor="middle" class="node-type">DNSKEY</text>
|
||||
<text data-text-role="title" data-full-text="KSK / ZSK 密钥" data-text-max-width="226.0" x="205.0" y="406.0" text-anchor="middle" class="node-title" font-size="18">KSK / ZSK 密钥</text>
|
||||
</g>
|
||||
<g id="node-rrset" data-graph-role="node" data-node-id="rrset" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="495,370,745,430">
|
||||
<rect x="495.0" y="370.0" width="250.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="620.0" y="388.0" text-anchor="middle" class="node-type">RRSET + RRSIG</text>
|
||||
<text data-text-role="title" data-full-text="记录数据与签名" data-text-max-width="226.0" x="620.0" y="406.0" text-anchor="middle" class="node-title" font-size="18">记录数据与签名</text>
|
||||
</g>
|
||||
<g id="node-resolver" data-graph-role="node" data-node-id="resolver" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="905,370,1160,430">
|
||||
<rect x="905.0" y="370.0" width="255.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#1d4ed8" stroke-width="2.0"/>
|
||||
<rect x="911.0" y="376.0" width="243.0" height="48.0" rx="11.0" fill="none" stroke="#1d4ed8" stroke-width="1.2" opacity="0.65"/>
|
||||
<text x="1032.5" y="388.0" text-anchor="middle" class="node-type">RECURSIVE · DO=1</text>
|
||||
<text data-text-role="title" data-full-text="递归解析器(带 DO 查询)" data-text-max-width="223.0" x="1032.5" y="406.0" text-anchor="middle" class="node-title" font-size="17.38">递归解析器(带 DO 查询)</text>
|
||||
</g>
|
||||
<g id="node-check" data-graph-role="node" data-node-id="check" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="300,485,590,541">
|
||||
<rect x="300.0" y="485.0" width="290.0" height="56.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="445.0" y="503.0" text-anchor="middle" class="node-type">VERIFY</text>
|
||||
<text data-text-role="title" data-full-text="RRSIG.Verify 本地验签" data-text-max-width="266.0" x="445.0" y="519.0" text-anchor="middle" class="node-title" font-size="18">RRSIG.Verify 本地验签</text>
|
||||
</g>
|
||||
<g id="node-pass" data-graph-role="node" data-node-id="pass" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="730,485,1020,541">
|
||||
<rect x="730.0" y="485.0" width="290.0" height="56.0" rx="14.0" fill="#ffffff" stroke="#10a37f" stroke-width="2.0"/>
|
||||
<rect x="736.0" y="491.0" width="278.0" height="44.0" rx="11.0" fill="none" stroke="#10a37f" stroke-width="1.2" opacity="0.65"/>
|
||||
<text x="875.0" y="503.0" text-anchor="middle" class="node-type">RESULT</text>
|
||||
<text data-text-role="title" data-full-text="验证通过 · 数据完整可信" data-text-max-width="258.0" x="875.0" y="519.0" text-anchor="middle" class="node-title" font-size="18">验证通过 · 数据完整可信</text>
|
||||
</g>
|
||||
<g id="ds-tld-label" data-graph-role="label" data-owner="ds-tld" data-graph-bounds="384.38,164,440.62,184">
|
||||
<rect x="384.38" y="164.0" width="56.24" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="412.5" y="178.0" text-anchor="middle" class="arrow-label">DS 签发</text>
|
||||
</g>
|
||||
<g id="ds-auth-label" data-graph-role="label" data-owner="ds-auth" data-graph-bounds="772.28,164,877.72,184">
|
||||
<rect x="772.28" y="164.0" width="105.44" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="825.0" y="178.0" text-anchor="middle" class="arrow-label">DS 指向下级 KEY</text>
|
||||
</g>
|
||||
<g id="pub-key-label" data-graph-role="label" data-owner="pub-key" data-graph-bounds="583.25,315,645.25,335">
|
||||
<rect x="583.25" y="315.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="614.25" y="329.0" text-anchor="middle" class="arrow-label">发布密钥</text>
|
||||
</g>
|
||||
<g id="sign-label" data-graph-role="label" data-owner="sign" data-graph-bounds="933.5,441,995.5,461">
|
||||
<rect x="933.5" y="441.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="964.5" y="455.0" text-anchor="middle" class="arrow-label">私钥签名</text>
|
||||
</g>
|
||||
<g id="fetch-key-label" data-graph-role="label" data-owner="fetch-key" data-graph-bounds="575.46,434,659.54,454">
|
||||
<rect x="575.46" y="434.0" width="84.08" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="617.5" y="448.0" text-anchor="middle" class="arrow-label">取回 DNSKEY</text>
|
||||
</g>
|
||||
<g id="fetch-rr-label" data-graph-role="label" data-owner="fetch-rr" data-graph-bounds="789.64,378,863.64,398">
|
||||
<rect x="789.64" y="378.0" width="74.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="826.64" y="392.0" text-anchor="middle" class="arrow-label">带签名应答</text>
|
||||
</g>
|
||||
<g id="a-check-label" data-graph-role="label" data-owner="a-check" data-graph-bounds="772.92,552,861.08,572">
|
||||
<rect x="772.92" y="552.0" width="88.16" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="817.0" y="566.0" text-anchor="middle" class="arrow-label">RRSIG.Verify</text>
|
||||
</g>
|
||||
<g id="a-pass-label" data-graph-role="label" data-owner="a-pass" data-graph-bounds="629,482,691,502">
|
||||
<rect x="629.0" y="482.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="660.0" y="496.0" text-anchor="middle" class="arrow-label">验签通过</text>
|
||||
</g>
|
||||
<g id="legend" data-graph-role="legend">
|
||||
<rect id="legend-zone" data-graph-role="reserved" data-reserved-kind="legend" x="32" y="516" width="183.6" height="90" rx="10" fill="none" stroke="none"/>
|
||||
<line data-graph-role="decoration" x1="42.0" y1="530.0" x2="72.0" y2="530.0" stroke="#10a37f" stroke-width="2.0" marker-end="url(#arrowA)"/>
|
||||
<text data-graph-role="decoration" x="82.0" y="534.0" class="legend">信任链 / 主验证路径</text>
|
||||
<line data-graph-role="decoration" x1="42.0" y1="552.0" x2="72.0" y2="552.0" stroke="#0891b2" stroke-width="2.0" marker-end="url(#arrowC)"/>
|
||||
<text data-graph-role="decoration" x="82.0" y="556.0" class="legend">数据获取</text>
|
||||
<line data-graph-role="decoration" x1="42.0" y1="574.0" x2="72.0" y2="574.0" stroke="#0f766e" stroke-width="2.0" marker-end="url(#arrowB)"/>
|
||||
<text data-graph-role="decoration" x="82.0" y="578.0" class="legend">权威侧写操作</text>
|
||||
</g>
|
||||
<g id="footer" data-graph-role="reserved" data-graph-bounds="48,613,391,629">
|
||||
<text x="48.0" y="625.0" class="footnote">Style 7 · OpenAI Official · DNSSEC chain of trust</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 20 KiB |
@@ -1,225 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 880" width="960" height="880" data-generator="fireworks-tech-graph" data-schema-version="1" data-text-metrics="heuristic-v1" data-style-id="10" data-visual-theme="Cloud Fabric" data-diagram-type="deployment" data-motion-scene="" data-semantic-profile="generic" data-semantic-valid="true" data-quality-profile="standard" data-max-bends-per-edge="12" data-max-total-bends="100" data-max-route-stretch="5.0" data-max-bridged-crossings="8" data-min-node-gap="0.0" data-min-container-gutter="0.0" data-min-label-clearance="2.0" data-min-segment-length="0.0">
|
||||
<defs>
|
||||
<marker id="arrowA" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#2563eb"/>
|
||||
</marker>
|
||||
<marker id="arrowB" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#ea580c"/>
|
||||
</marker>
|
||||
<marker id="arrowC" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#0891b2"/>
|
||||
</marker>
|
||||
<marker id="arrowE" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#059669"/>
|
||||
</marker>
|
||||
<marker id="arrowF" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#7c3aed"/>
|
||||
</marker>
|
||||
<marker id="arrowG" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#db2777"/>
|
||||
</marker>
|
||||
<marker id="arrowH" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#64748b"/>
|
||||
</marker>
|
||||
<filter id="shadowSoft" x="-20%" y="-20%" width="140%" height="160%">
|
||||
<feDropShadow dx="0" dy="3" stdDeviation="6" flood-color="#0f172a" flood-opacity="0.12"/>
|
||||
</filter>
|
||||
<filter id="shadowGlass" x="-20%" y="-20%" width="140%" height="160%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="16" flood-color="#020617" flood-opacity="0.28"/>
|
||||
</filter>
|
||||
<linearGradient id="cloudGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#f8fcff"/>
|
||||
<stop offset="100%" stop-color="#dfedf7"/>
|
||||
</linearGradient>
|
||||
<pattern id="cloudGrid" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||
<path d="M 32 0 L 0 0 0 32" fill="none" stroke="#7fa3c2" stroke-opacity="0.10" stroke-width="1"/>
|
||||
</pattern>
|
||||
<style>
|
||||
text { font-family: Inter, 'Helvetica Neue', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'SimHei', 'Noto Sans CJK SC', sans-serif; }
|
||||
.title { font-size: 27px; font-weight: 700; fill: #102a43; }
|
||||
.subtitle { font-size: 13px; font-weight: 500; fill: #52718d; }
|
||||
.section { font-size: 13px; font-weight: 700; fill: #315d7e; letter-spacing: 1.4px; }
|
||||
.section-sub { font-size: 12px; font-weight: 500; fill: #7892a8; }
|
||||
.node-title { font-weight: 700; fill: #102a43; }
|
||||
.node-sub { font-size: 12px; font-weight: 500; fill: #486581; }
|
||||
.node-type { font-size: 10px; font-weight: 700; fill: #6b879d; letter-spacing: 0.08em; }
|
||||
.arrow-label { font-size: 12px; font-weight: 600; fill: #334e68; }
|
||||
.legend { font-size: 12px; font-weight: 500; fill: #486581; }
|
||||
.footnote { font-size: 12px; font-weight: 500; fill: #829ab1; }
|
||||
.metric-label { font-size: 8.5px; font-weight: 700; fill: #829ab1; text-transform: uppercase; }
|
||||
.metric-value { font-size: 9.5px; font-weight: 700; fill: #102a43; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect data-graph-role="background" width="960.0" height="880.0" fill="url(#cloudGradient)"/>
|
||||
<rect data-graph-role="decoration" width="960.0" height="880.0" fill="url(#cloudGrid)"/>
|
||||
<text x="28" y="24" font-size="11.0" font-weight="600" fill="#829ab1">WHOIS · REGISTRY · REGISTRAR</text>
|
||||
<text x="932.0" y="24" text-anchor="end" font-size="11.0" font-weight="600" fill="#829ab1">Cloud Fabric · Style 10</text>
|
||||
<text x="48.0" y="48" text-anchor="start" class="title">域名生命周期</text>
|
||||
<text x="48.0" y="72" text-anchor="start" class="subtitle">Domain Name Lifecycle · Cloud Fabric</text>
|
||||
<g id="style-signature" data-graph-role="decoration" data-style-signature="cloud-ownership-map">
|
||||
<rect x="736.0" y="22.0" width="176.0" height="34.0" rx="9" fill="#ffffff" fill-opacity="0.82" stroke="#7fa3c2" stroke-width="1.1"/>
|
||||
<rect x="747.0" y="31.0" width="14" height="14" rx="4" fill="#dbeafe" stroke="#2563eb" stroke-width="1"/>
|
||||
<rect x="756.0" y="35.0" width="14" height="14" rx="4" fill="#dcfce7" stroke="#059669" stroke-width="1"/>
|
||||
<text data-full-text="CLOUD · 1 REGIONS" x="779.0" y="36.0" font-size="8.5" font-weight="800" fill="#315d7e">CLOUD · 1 REGIONS</text>
|
||||
<text data-full-text="DEPLOYMENT MAP" x="779.0" y="49.0" font-size="8" font-weight="700" fill="#52718d">DEPLOYMENT MAP</text>
|
||||
</g>
|
||||
<g id="global" data-graph-role="container" data-container-id="global" data-semantic-role="global" data-graph-bounds="30,120,930,210">
|
||||
<rect data-graph-role="container" x="30.0" y="120.0" width="900.0" height="90.0" rx="16.0" fill="rgba(255,255,255,0.54)" stroke="#7fa3c2" stroke-width="1.4" stroke-dasharray="7 5"/>
|
||||
<rect data-graph-role="decoration" x="862.0" y="130.0" width="54.0" height="18" rx="9" fill="#dbeafe" stroke="#93c5fd" stroke-width="0.8"/>
|
||||
<text data-graph-role="decoration" x="889.0" y="142.5" text-anchor="middle" font-size="9" font-weight="700" fill="#315d7e">GLOBAL</text>
|
||||
<line data-graph-role="decoration" x1="38.0" y1="162.0" x2="38.0" y2="196.0" stroke="#2563eb" stroke-width="2.2" stroke-linecap="round" opacity="0.72"/>
|
||||
<text x="48.0" y="144.0" class="section">GLOBAL</text>
|
||||
<rect id="global-header" data-graph-role="reserved" data-reserved-kind="container-header" x="38" y="126" width="75.24" height="30" fill="none" stroke="none"/>
|
||||
</g>
|
||||
<g id="lifecycle" data-graph-role="container" data-container-id="lifecycle" data-semantic-role="region" data-graph-bounds="30,230,930,930">
|
||||
<rect data-graph-role="container" x="30.0" y="230.0" width="900.0" height="700.0" rx="16.0" fill="rgba(255,255,255,0.54)" stroke="#7fa3c2" stroke-width="1.4" stroke-dasharray="7 5"/>
|
||||
<rect data-graph-role="decoration" x="862.0" y="240.0" width="54.0" height="18" rx="9" fill="#dbeafe" stroke="#93c5fd" stroke-width="0.8"/>
|
||||
<text data-graph-role="decoration" x="889.0" y="252.5" text-anchor="middle" font-size="9" font-weight="700" fill="#315d7e">REGION</text>
|
||||
<line data-graph-role="decoration" x1="38.0" y1="272.0" x2="38.0" y2="916.0" stroke="#0891b2" stroke-width="2.2" stroke-linecap="round" opacity="0.72"/>
|
||||
<text x="48.0" y="254.0" class="section">LIFECYCLE</text>
|
||||
<rect id="lifecycle-header" data-graph-role="reserved" data-reserved-kind="container-header" x="38" y="236" width="94.48" height="30" fill="none" stroke="none"/>
|
||||
</g>
|
||||
<path id="pending-to-available" data-graph-role="edge" data-edge-id="pending-to-available" data-source="pending-delete" data-target="available" data-edge-kind="flow" data-topic-id="" data-flow="async" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="6" data-route-stretch="1.968" data-bridges="" d="M 380,770 L 380,742 L 160,742 L 160,210 L 160,130 L 170,130 L 170,220.4 L 451,220.4 L 451,200" fill="none" stroke="#7c3aed" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowF)"/>
|
||||
<path id="register" data-graph-role="edge" data-edge-id="register" data-source="available" data-target="add-period" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 469,200 L 469,225 L 380,225 L 380,250" fill="none" stroke="#2563eb" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="configure-ns" data-graph-role="edge" data-edge-id="configure-ns" data-source="add-period" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 380,322 L 380,350" fill="none" stroke="#ea580c" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
|
||||
<path id="renew" data-graph-role="edge" data-edge-id="renew" data-source="active" data-target="renew-period" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 389,430 L 389,470" fill="none" stroke="#2563eb" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="manual-renew" data-graph-role="edge" data-edge-id="manual-renew" data-source="renew-period" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.75" data-bridges="" d="M 371,470 L 371,459.8 L 386,459.8 L 386,434.08 L 371,434.08 L 371,430" fill="none" stroke="#ea580c" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
|
||||
<path id="auto-renew" data-graph-role="edge" data-edge-id="auto-renew" data-source="active" data-target="auto-renew-period" data-edge-kind="flow" data-topic-id="" data-flow="async" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.924" data-bridges="" d="M 353,430 L 353,434.08 L 280,434.08 L 280,549.6 L 371,549.6 L 371,570" fill="none" stroke="#7c3aed" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowF)"/>
|
||||
<path id="renew-success" data-graph-role="edge" data-edge-id="renew-success" data-source="auto-renew-period" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.924" data-bridges="" d="M 389,570 L 389,549.6 L 480,549.6 L 480,450.4 L 407,450.4 L 407,430" fill="none" stroke="#ea580c" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
|
||||
<path id="to-redemption" data-graph-role="edge" data-edge-id="to-redemption" data-source="auto-renew-period" data-target="redemption-period" data-edge-kind="flow" data-topic-id="" data-flow="data" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.272" data-bridges="" d="M 480,606 L 500.4,606 L 500.4,642 L 259.6,642 L 259.6,706 L 280,706" fill="none" stroke="#059669" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowE)"/>
|
||||
<path id="to-transfer" data-graph-role="edge" data-edge-id="to-transfer" data-source="active" data-target="transfer" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 480,381 L 504,381 L 504,326 L 680,326" fill="none" stroke="#2563eb" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="transfer-complete" data-graph-role="edge" data-edge-id="transfer-complete" data-source="transfer" data-target="transfer-period" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 780,362 L 780,390" fill="none" stroke="#ea580c" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
|
||||
<path id="transfer-ok" data-graph-role="edge" data-edge-id="transfer-ok" data-source="transfer-period" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="write" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="1" data-route-stretch="1.0" data-bridges="" d="M 680,426 L 480,426 L 480,399 L 480,399" fill="none" stroke="#ea580c" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowB)"/>
|
||||
<g id="node-available" data-graph-role="node" data-node-id="available" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="global" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="360,130,560,200">
|
||||
<rect x="360.0" y="130.0" width="200.0" height="70.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
|
||||
<rect x="372.0" y="144.0" width="42.0" height="42.0" rx="11" fill="#2563eb" opacity="0.12" stroke="#2563eb" stroke-width="1.2"/>
|
||||
<circle cx="393.0" cy="165.0" r="11" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 382.0 165.0 H 404.0 M 393.0 154.0 C 387.0 160.0 387.0 170.0 393.0 176.0 M 393.0 154.0 C 399.0 160.0 399.0 170.0 393.0 176.0" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text data-text-role="title" data-full-text="Available" x="426.0" y="161.0" text-anchor="start" class="node-title" font-size="13.5">Available</text>
|
||||
<text data-text-role="subtitle" data-full-text="域名可注册" x="426.0" y="180.0" text-anchor="start" class="node-sub" font-size="11.5">域名可注册</text>
|
||||
<text x="550.0" y="144.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">DNS</text>
|
||||
</g>
|
||||
<g id="node-add-period" data-graph-role="node" data-node-id="add-period" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="280,250,480,322">
|
||||
<rect x="280.0" y="250.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
|
||||
<rect x="292.0" y="265.0" width="42.0" height="42.0" rx="11" fill="#0891b2" opacity="0.12" stroke="#0891b2" stroke-width="1.2"/>
|
||||
<path d="M 313.0 274.0 L 325.0 286.0 L 313.0 298.0 L 301.0 286.0 Z" fill="none" stroke="#0891b2" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 307.0 286.0 H 319.0 M 316.0 283.0 L 319.0 286.0 L 316.0 289.0" fill="none" stroke="#0891b2" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text data-text-role="title" data-full-text="AddPeriod" x="346.0" y="281.0" text-anchor="start" class="node-title" font-size="13.5">AddPeriod</text>
|
||||
<text data-text-role="subtitle" data-full-text="注册完成 · 5天" x="346.0" y="300.0" text-anchor="start" class="node-sub" font-size="11.5">注册完成 · 5天</text>
|
||||
<text x="470.0" y="264.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRAR</text>
|
||||
</g>
|
||||
<g id="node-active" data-graph-role="node" data-node-id="active" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="280,350,480,430">
|
||||
<rect x="280.0" y="350.0" width="200.0" height="80.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
|
||||
<rect x="292.0" y="369.0" width="42.0" height="42.0" rx="11" fill="#059669" opacity="0.12" stroke="#059669" stroke-width="1.2"/>
|
||||
<rect x="302.0" y="381.0" width="22" height="18" rx="4" fill="none" stroke="#059669" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 307.0 387.0 H 319.0 M 307.0 393.0 H 316.0" fill="none" stroke="#059669" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text data-text-role="title" data-full-text="Active (ok)" x="346.0" y="381.0" text-anchor="start" class="node-title" font-size="13.5">Active (ok)</text>
|
||||
<text data-text-role="subtitle" data-full-text="正常解析 · 配置NS" x="346.0" y="400.0" text-anchor="start" class="node-sub" font-size="11.5">正常解析 · 配置NS</text>
|
||||
<text x="470.0" y="364.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRY</text>
|
||||
</g>
|
||||
<g id="node-renew-period" data-graph-role="node" data-node-id="renew-period" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="280,470,480,542">
|
||||
<rect x="280.0" y="470.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
|
||||
<rect x="292.0" y="485.0" width="42.0" height="42.0" rx="11" fill="#7c3aed" opacity="0.12" stroke="#7c3aed" stroke-width="1.2"/>
|
||||
<path d="M 301.0 499.0 H 318.0 M 308.0 506.0 H 325.0 M 301.0 513.0 H 318.0" fill="none" stroke="#7c3aed" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="321.0" cy="499.0" r="2.4" fill="#7c3aed"/><circle cx="305.0" cy="506.0" r="2.4" fill="#7c3aed"/><circle cx="321.0" cy="513.0" r="2.4" fill="#7c3aed"/>
|
||||
<text data-text-role="title" data-full-text="RenewPeriod" x="346.0" y="501.0" text-anchor="start" class="node-title" font-size="13.5">RenewPeriod</text>
|
||||
<text data-text-role="subtitle" data-full-text="手动续费 · 5天" x="346.0" y="520.0" text-anchor="start" class="node-sub" font-size="11.5">手动续费 · 5天</text>
|
||||
<text x="470.0" y="484.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRAR</text>
|
||||
</g>
|
||||
<g id="node-auto-renew-period" data-graph-role="node" data-node-id="auto-renew-period" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="280,570,480,642">
|
||||
<rect x="280.0" y="570.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
|
||||
<rect x="292.0" y="585.0" width="42.0" height="42.0" rx="11" fill="#ea580c" opacity="0.12" stroke="#ea580c" stroke-width="1.2"/>
|
||||
<path d="M 301.0 599.0 H 318.0 M 308.0 606.0 H 325.0 M 301.0 613.0 H 318.0" fill="none" stroke="#ea580c" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="321.0" cy="599.0" r="2.4" fill="#ea580c"/><circle cx="305.0" cy="606.0" r="2.4" fill="#ea580c"/><circle cx="321.0" cy="613.0" r="2.4" fill="#ea580c"/>
|
||||
<text data-text-role="title" data-full-text="AutoRenewPeriod" x="346.0" y="601.0" text-anchor="start" class="node-title" font-size="13.38">AutoRenewPeriod</text>
|
||||
<text data-text-role="subtitle" data-full-text="自动续费 · 等待扣费" x="346.0" y="620.0" text-anchor="start" class="node-sub" font-size="11.5">自动续费 · 等待扣费</text>
|
||||
<text x="470.0" y="584.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRY</text>
|
||||
</g>
|
||||
<g id="node-redemption-period" data-graph-role="node" data-node-id="redemption-period" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="280,670,480,742">
|
||||
<rect x="280.0" y="670.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
|
||||
<rect x="292.0" y="685.0" width="42.0" height="42.0" rx="11" fill="#dc2626" opacity="0.12" stroke="#dc2626" stroke-width="1.2"/>
|
||||
<ellipse cx="313.0" cy="698.0" rx="11" ry="4" fill="none" stroke="#dc2626" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 302.0 698.0 V 714.0 C 302.0 719.0 324.0 719.0 324.0 714.0 V 698.0" fill="none" stroke="#dc2626" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 302.0 706.0 C 302.0 711.0 324.0 711.0 324.0 706.0" fill="none" stroke="#dc2626" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text data-text-role="title" data-full-text="RedemptionPeriod" x="346.0" y="701.0" text-anchor="start" class="node-title" font-size="12.89">RedemptionPeriod</text>
|
||||
<text data-text-role="subtitle" data-full-text="赎回期 · 30天" x="346.0" y="720.0" text-anchor="start" class="node-sub" font-size="11.5">赎回期 · 30天</text>
|
||||
<text x="470.0" y="684.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRY</text>
|
||||
</g>
|
||||
<g id="node-pending-delete" data-graph-role="node" data-node-id="pending-delete" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="280,770,480,842">
|
||||
<rect x="280.0" y="770.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
|
||||
<rect x="292.0" y="785.0" width="42.0" height="42.0" rx="11" fill="#64748b" opacity="0.12" stroke="#64748b" stroke-width="1.2"/>
|
||||
<path d="M 300.0 806.0 Q 313.0 794.0 326.0 806.0 Q 313.0 818.0 300.0 806.0 Z" fill="none" stroke="#64748b" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 307.0 806.0 H 311.0 L 314.0 801.0 L 317.0 811.0 L 320.0 806.0" fill="none" stroke="#64748b" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text data-text-role="title" data-full-text="PendingDelete" x="346.0" y="801.0" text-anchor="start" class="node-title" font-size="13.5">PendingDelete</text>
|
||||
<text data-text-role="subtitle" data-full-text="删除倒计时 · 5天" x="346.0" y="820.0" text-anchor="start" class="node-sub" font-size="11.5">删除倒计时 · 5天</text>
|
||||
<text x="470.0" y="784.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRY</text>
|
||||
</g>
|
||||
<g id="node-transfer" data-graph-role="node" data-node-id="transfer" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="680,290,880,362">
|
||||
<rect x="680.0" y="290.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
|
||||
<rect x="692.0" y="305.0" width="42.0" height="42.0" rx="11" fill="#2563eb" opacity="0.12" stroke="#2563eb" stroke-width="1.2"/>
|
||||
<path d="M 713.0 314.0 L 725.0 326.0 L 713.0 338.0 L 701.0 326.0 Z" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 707.0 326.0 H 719.0 M 716.0 323.0 L 719.0 326.0 L 716.0 329.0" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text data-text-role="title" data-full-text="PendingTransfer" x="746.0" y="321.0" text-anchor="start" class="node-title" font-size="13.38">PendingTransfer</text>
|
||||
<text data-text-role="subtitle" data-full-text="转移中 · 注册商切换" x="746.0" y="340.0" text-anchor="start" class="node-sub" font-size="11.5">转移中 · 注册商切换</text>
|
||||
<text x="870.0" y="304.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRAR</text>
|
||||
</g>
|
||||
<g id="node-transfer-period" data-graph-role="node" data-node-id="transfer-period" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="680,390,880,462">
|
||||
<rect x="680.0" y="390.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
|
||||
<rect x="692.0" y="405.0" width="42.0" height="42.0" rx="11" fill="#0891b2" opacity="0.12" stroke="#0891b2" stroke-width="1.2"/>
|
||||
<path d="M 701.0 419.0 H 718.0 M 708.0 426.0 H 725.0 M 701.0 433.0 H 718.0" fill="none" stroke="#0891b2" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="721.0" cy="419.0" r="2.4" fill="#0891b2"/><circle cx="705.0" cy="426.0" r="2.4" fill="#0891b2"/><circle cx="721.0" cy="433.0" r="2.4" fill="#0891b2"/>
|
||||
<text data-text-role="title" data-full-text="TransferPeriod" x="746.0" y="421.0" text-anchor="start" class="node-title" font-size="13.5">TransferPeriod</text>
|
||||
<text data-text-role="subtitle" data-full-text="转移完成 · 5天" x="746.0" y="440.0" text-anchor="start" class="node-sub" font-size="11.5">转移完成 · 5天</text>
|
||||
<text x="870.0" y="404.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRAR</text>
|
||||
</g>
|
||||
<g id="node-client-hold" data-graph-role="node" data-node-id="client-hold" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="680,490,880,562">
|
||||
<rect x="680.0" y="490.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
|
||||
<rect x="692.0" y="505.0" width="42.0" height="42.0" rx="11" fill="#f59e0b" opacity="0.12" stroke="#f59e0b" stroke-width="1.2"/>
|
||||
<path d="M 700.0 526.0 Q 713.0 514.0 726.0 526.0 Q 713.0 538.0 700.0 526.0 Z" fill="none" stroke="#f59e0b" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 707.0 526.0 H 711.0 L 714.0 521.0 L 717.0 531.0 L 720.0 526.0" fill="none" stroke="#f59e0b" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text data-text-role="title" data-full-text="ClientHold" x="746.0" y="521.0" text-anchor="start" class="node-title" font-size="13.5">ClientHold</text>
|
||||
<text data-text-role="subtitle" data-full-text="客户暂停 · DNS停止" x="746.0" y="540.0" text-anchor="start" class="node-sub" font-size="11.5">客户暂停 · DNS停止</text>
|
||||
<text x="870.0" y="504.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRAR</text>
|
||||
</g>
|
||||
<g id="node-server-hold" data-graph-role="node" data-node-id="server-hold" data-semantic-role="cloud_service" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="lifecycle" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="680,590,880,662">
|
||||
<rect x="680.0" y="590.0" width="200.0" height="72.0" rx="12" fill="#ffffff" stroke="#9bb7cf" stroke-width="1.4" filter="url(#shadowSoft)"/>
|
||||
<rect x="692.0" y="605.0" width="42.0" height="42.0" rx="11" fill="#ef4444" opacity="0.12" stroke="#ef4444" stroke-width="1.2"/>
|
||||
<path d="M 700.0 626.0 Q 713.0 614.0 726.0 626.0 Q 713.0 638.0 700.0 626.0 Z" fill="none" stroke="#ef4444" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 707.0 626.0 H 711.0 L 714.0 621.0 L 717.0 631.0 L 720.0 626.0" fill="none" stroke="#ef4444" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text data-text-role="title" data-full-text="ServerHold" x="746.0" y="621.0" text-anchor="start" class="node-title" font-size="13.5">ServerHold</text>
|
||||
<text data-text-role="subtitle" data-full-text="注册局暂停 · 违规" x="746.0" y="640.0" text-anchor="start" class="node-sub" font-size="11.5">注册局暂停 · 违规</text>
|
||||
<text x="870.0" y="604.0" text-anchor="end" font-size="8.5" font-weight="800" fill="#6b879d">REGISTRY</text>
|
||||
</g>
|
||||
<g id="register-label" data-graph-role="label" data-owner="register" data-graph-bounds="455.3,215,493.3,235">
|
||||
<rect x="455.3" y="215.0" width="38.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
|
||||
<text x="474.3" y="229.0" text-anchor="middle" class="arrow-label">注册</text>
|
||||
</g>
|
||||
<g id="renew-label" data-graph-role="label" data-owner="renew" data-graph-bounds="348,436,386,456">
|
||||
<rect x="348.0" y="436.0" width="38.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
|
||||
<text x="367.0" y="450.0" text-anchor="middle" class="arrow-label">续费</text>
|
||||
</g>
|
||||
<g id="auto-renew-label" data-graph-role="label" data-owner="auto-renew" data-graph-bounds="221,477.84,259,497.84">
|
||||
<rect x="221.0" y="477.84" width="38.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
|
||||
<text x="240.0" y="491.84" text-anchor="middle" class="arrow-label">到期</text>
|
||||
</g>
|
||||
<g id="to-redemption-label" data-graph-role="label" data-owner="to-redemption" data-graph-bounds="355,646,405,666">
|
||||
<rect x="355.0" y="646.0" width="50.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
|
||||
<text x="380.0" y="660.0" text-anchor="middle" class="arrow-label">未支付</text>
|
||||
</g>
|
||||
<g id="to-transfer-label" data-graph-role="label" data-owner="to-transfer" data-graph-bounds="573,295,611,315">
|
||||
<rect x="573.0" y="295.0" width="38.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
|
||||
<text x="592.0" y="309.0" text-anchor="middle" class="arrow-label">转移</text>
|
||||
</g>
|
||||
<g id="transfer-complete-label" data-graph-role="label" data-owner="transfer-complete" data-graph-bounds="749,366,811,386">
|
||||
<rect x="749.0" y="366.0" width="62.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
|
||||
<text x="780.0" y="380.0" text-anchor="middle" class="arrow-label">转移完成</text>
|
||||
</g>
|
||||
<g id="transfer-ok-label" data-graph-role="label" data-owner="transfer-ok" data-graph-bounds="537,395,623,415">
|
||||
<rect x="537.0" y="395.0" width="86.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
|
||||
<text x="580.0" y="409.0" text-anchor="middle" class="arrow-label">期满回到正常</text>
|
||||
</g>
|
||||
<g id="pending-to-available-label" data-graph-role="label" data-owner="pending-to-available" data-graph-bounds="119,462,157,482">
|
||||
<rect x="119.0" y="462.0" width="38.0" height="20" rx="6" fill="#f7fbfe" opacity="0.96"/>
|
||||
<text x="138.0" y="476.0" text-anchor="middle" class="arrow-label">释放</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,198 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1240 880" width="1240" height="880" data-generator="fireworks-tech-graph" data-schema-version="1" data-text-metrics="heuristic-v1" data-style-id="7" data-visual-theme="OpenAI" data-diagram-type="architecture" data-motion-scene="" data-semantic-profile="generic" data-semantic-valid="true" data-quality-profile="standard" data-max-bends-per-edge="4" data-max-total-bends="30" data-max-route-stretch="5.0" data-max-bridged-crossings="14" data-min-node-gap="0.0" data-min-container-gutter="0.0" data-min-label-clearance="2.0" data-min-segment-length="0.0">
|
||||
<defs>
|
||||
<marker id="arrowA" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#10a37f"/>
|
||||
</marker>
|
||||
<marker id="arrowB" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#0f766e"/>
|
||||
</marker>
|
||||
<marker id="arrowC" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#0891b2"/>
|
||||
</marker>
|
||||
<marker id="arrowE" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#f59e0b"/>
|
||||
</marker>
|
||||
<marker id="arrowF" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#64748b"/>
|
||||
</marker>
|
||||
<marker id="arrowG" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#475569"/>
|
||||
</marker>
|
||||
<marker id="arrowH" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8"/>
|
||||
</marker>
|
||||
<style>
|
||||
text { font-family: 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Noto Sans CJK SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif; }
|
||||
.title { font-size: 24px; font-weight: 700; fill: #0f172a; }
|
||||
.subtitle { font-size: 13px; font-weight: 500; fill: #64748b; }
|
||||
.section { font-size: 13px; font-weight: 700; fill: #087f5b; letter-spacing: 1.4px; }
|
||||
.section-sub { font-size: 12px; font-weight: 500; fill: #64748b; }
|
||||
.node-title { font-weight: 700; fill: #0f172a; }
|
||||
.node-sub { font-weight: 500; fill: #475569; }
|
||||
.node-type { font-size: 11px; font-weight: 700; fill: #64748b; letter-spacing: 0.08em; }
|
||||
.arrow-label { font-size: 12px; font-weight: 600; fill: #475569; }
|
||||
.legend { font-size: 12px; font-weight: 500; fill: #475569; }
|
||||
.footnote { font-size: 12px; font-weight: 500; fill: #64748b; }
|
||||
.metric-label { font-size: 8.5px; font-weight: 700; fill: #64748b; text-transform: uppercase; }
|
||||
.metric-value { font-size: 9.5px; font-weight: 700; fill: #0f172a; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect data-graph-role="background" width="1240.0" height="880.0" fill="#ffffff"/>
|
||||
<text x="48.0" y="48" text-anchor="start" class="title">域名生命周期状态机</text>
|
||||
<text x="48.0" y="72" text-anchor="start" class="subtitle">从可注册到释放的完整 EPP 状态流转(REGISTRY / REGISTRAR 管理方标注)</text>
|
||||
<line x1="48" y1="100" x2="1192.0" y2="100" stroke="#e2e8f0" stroke-width="1"/>
|
||||
<g id="reg" data-graph-role="container" data-container-id="reg" data-semantic-role="boundary" data-graph-bounds="40,70,1200,290">
|
||||
<rect data-graph-role="container" x="40.0" y="70.0" width="1160.0" height="220.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
|
||||
<text x="58.0" y="94.0" class="section">注册与活跃</text>
|
||||
<rect id="reg-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="76" width="95" height="30" fill="none" stroke="none"/>
|
||||
</g>
|
||||
<g id="grace" data-graph-role="container" data-container-id="grace" data-semantic-role="boundary" data-graph-bounds="40,330,1200,470">
|
||||
<rect data-graph-role="container" x="40.0" y="330.0" width="1160.0" height="140.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
|
||||
<text x="58.0" y="354.0" class="section">续费 · 到期 · 转移中</text>
|
||||
<rect id="grace-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="336" width="154.8" height="30" fill="none" stroke="none"/>
|
||||
</g>
|
||||
<g id="end" data-graph-role="container" data-container-id="end" data-semantic-role="boundary" data-graph-bounds="40,510,1200,810">
|
||||
<rect data-graph-role="container" x="40.0" y="510.0" width="1160.0" height="300.0" rx="16.0" fill="none" stroke="#e2e8f0" stroke-width="1.4" stroke-dasharray="5 4"/>
|
||||
<text x="58.0" y="534.0" class="section">赎回 · 释放 · 转移完成</text>
|
||||
<rect id="end-header" data-graph-role="reserved" data-reserved-kind="container-header" x="48" y="516" width="167.8" height="30" fill="none" stroke="none"/>
|
||||
</g>
|
||||
<path id="a-reg" data-graph-role="edge" data-edge-id="a-reg" data-source="available" data-target="addperiod" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 270,170 L 380,170" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-ok" data-graph-role="edge" data-edge-id="a-ok" data-source="addperiod" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 600,170 L 620.4,170 L 620.4,179 L 700,179" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-hold-c" data-graph-role="edge" data-edge-id="a-hold-c" data-source="active" data-target="clienthold" data-edge-kind="flow" data-topic-id="" data-flow="feedback" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 910,152 L 930.4,152 L 930.4,137 L 990,137" fill="none" stroke="#475569" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowG)"/>
|
||||
<path id="a-hold-s" data-graph-role="edge" data-edge-id="a-hold-s" data-source="active" data-target="serverhold" data-edge-kind="flow" data-topic-id="" data-flow="feedback" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 910,170 L 930.4,170 L 930.4,232 L 990,232" fill="none" stroke="#475569" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowG)"/>
|
||||
<path id="a-renew" data-graph-role="edge" data-edge-id="a-renew" data-source="active" data-target="renewperiod" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="2" data-route-stretch="1.0" data-bridges="" d="M 787,200 L 787,220.4 L 796,220.4 L 796,380" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-renew-back" data-graph-role="edge" data-edge-id="a-renew-back" data-source="renewperiod" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="feedback" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.032" data-bridges="" d="M 814,380 L 814,359.6 L 817,359.6 L 817,220.4 L 805,220.4 L 805,200" fill="none" stroke="#475569" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowG)"/>
|
||||
<path id="a-expire-bridge-mask" data-graph-role="bridge-mask" data-owner="a-expire" d="M 700,161 L 692.86,161 L 692.86,174 A 5 5 0 0 1 692.86,184 L 692.86,224 L 510,224 L 510,380" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="a-expire" data-graph-role="edge" data-edge-id="a-expire" data-source="active" data-target="autorenew" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="3" data-route-stretch="1.0" data-bridges="692.86,179" d="M 700,161 L 692.86,161 L 692.86,174 A 5 5 0 0 1 692.86,184 L 692.86,224 L 510,224 L 510,380" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-no-pay" data-graph-role="edge" data-edge-id="a-no-pay" data-source="autorenew" data-target="redemption" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 510,440 L 510,560" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-redemption-end" data-graph-role="edge" data-edge-id="a-redemption-end" data-source="redemption" data-target="pendingdelete" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="0" data-route-stretch="1.0" data-bridges="" d="M 380,590 L 310,590" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-release" data-graph-role="edge" data-edge-id="a-release" data-source="pendingdelete" data-target="released" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="2.625" data-bridges="" d="M 190,620 L 190,640.4 L 305.6,640.4 L 305.6,732.86 L 200,732.86 L 200,740" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-transfer" data-graph-role="edge" data-edge-id="a-transfer" data-source="active" data-target="pendingtransfer" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.0" data-bridges="" d="M 910,188 L 930,188 L 930,356 L 959.6,356 L 959.6,410 L 980,410" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-transfer-done" data-graph-role="edge" data-edge-id="a-transfer-done" data-source="pendingtransfer" data-target="transferperiod" data-edge-kind="flow" data-topic-id="" data-flow="control" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="3" data-route-stretch="1.078" data-bridges="" d="M 1080,440 L 1080,464 L 679.6,464 L 679.6,581 L 700,581" fill="none" stroke="#10a37f" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowA)"/>
|
||||
<path id="a-transfer-ok-bridge-mask" data-graph-role="bridge-mask" data-owner="a-transfer-ok" d="M 700,599 L 676,599 L 676,230.4 L 700,230.4 L 700,200 L 782,200 A 5 5 0 0 0 792,200 L 800,200 A 5 5 0 0 0 810,200 L 823,200 L 823,200" fill="none" stroke="#ffffff" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="a-transfer-ok" data-graph-role="edge" data-edge-id="a-transfer-ok" data-source="transferperiod" data-target="active" data-edge-kind="flow" data-topic-id="" data-flow="feedback" data-motion-role="" data-motion-stage="" data-motion-order="" data-protocol="" data-via="" data-critical-path-id="" data-critical-hop="" data-critical-hops="" data-critical="false" data-bends="4" data-route-stretch="1.092" data-bridges="787,200;805,200" d="M 700,599 L 676,599 L 676,230.4 L 700,230.4 L 700,200 L 782,200 A 5 5 0 0 0 792,200 L 800,200 A 5 5 0 0 0 810,200 L 823,200 L 823,200" fill="none" stroke="#475569" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowG)"/>
|
||||
<g id="node-available" data-graph-role="node" data-node-id="available" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="70,140,270,200">
|
||||
<rect x="70.0" y="140.0" width="200.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="170.0" y="158.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="Available · 域名可注册" data-text-max-width="176.0" x="170.0" y="176.0" text-anchor="middle" class="node-title" font-size="15.17">Available · 域名可注册</text>
|
||||
</g>
|
||||
<g id="node-addperiod" data-graph-role="node" data-node-id="addperiod" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="380,140,600,200">
|
||||
<rect x="380.0" y="140.0" width="220.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="490.0" y="158.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="AddPeriod · 注册完成 5天" data-text-max-width="196.0" x="490.0" y="176.0" text-anchor="middle" class="node-title" font-size="14.87">AddPeriod · 注册完成 5天</text>
|
||||
</g>
|
||||
<g id="node-active" data-graph-role="node" data-node-id="active" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="700,140,910,200">
|
||||
<rect x="700.0" y="140.0" width="210.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#10a37f" stroke-width="2.0"/>
|
||||
<rect x="706.0" y="146.0" width="198.0" height="48.0" rx="11.0" fill="none" stroke="#10a37f" stroke-width="1.2" opacity="0.65"/>
|
||||
<text x="805.0" y="158.0" text-anchor="middle" class="node-type">REGISTRAR · Active</text>
|
||||
<text data-text-role="title" data-full-text="Active (ok) · 正常解析" data-text-max-width="178.0" x="805.0" y="176.0" text-anchor="middle" class="node-title" font-size="14.71">Active (ok) · 正常解析</text>
|
||||
</g>
|
||||
<g id="node-clienthold" data-graph-role="node" data-node-id="clienthold" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="990,110,1190,164">
|
||||
<rect x="990.0" y="110.0" width="200.0" height="54.0" rx="14.0" fill="#ffffff" stroke="#f97316" stroke-width="1.8"/>
|
||||
<text x="1090.0" y="128.0" text-anchor="middle" class="node-type">REGISTRAR</text>
|
||||
<text data-text-role="title" data-full-text="ClientHold · 客户暂停" data-text-max-width="176.0" x="1090.0" y="143.0" text-anchor="middle" class="node-title" font-size="15.79">ClientHold · 客户暂停</text>
|
||||
</g>
|
||||
<g id="node-serverhold" data-graph-role="node" data-node-id="serverhold" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="990,205,1190,259">
|
||||
<rect x="990.0" y="205.0" width="200.0" height="54.0" rx="14.0" fill="#ffffff" stroke="#f97316" stroke-width="1.8"/>
|
||||
<text x="1090.0" y="223.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="ServerHold · 注册局暂停" data-text-max-width="176.0" x="1090.0" y="238.0" text-anchor="middle" class="node-title" font-size="13.76">ServerHold · 注册局暂停</text>
|
||||
</g>
|
||||
<g id="node-renewperiod" data-graph-role="node" data-node-id="renewperiod" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="700,380,910,440">
|
||||
<rect x="700.0" y="380.0" width="210.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="805.0" y="398.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="RenewPeriod · 手动续费 5天" data-text-max-width="186.0" x="805.0" y="416.0" text-anchor="middle" class="node-title" font-size="12.89">RenewPeriod · 手动续费 5天</text>
|
||||
</g>
|
||||
<g id="node-autorenew" data-graph-role="node" data-node-id="autorenew" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="380,380,640,440">
|
||||
<rect x="380.0" y="380.0" width="260.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="510.0" y="398.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="AutoRenewPeriod · 等待扣费" data-text-max-width="236.0" x="510.0" y="416.0" text-anchor="middle" class="node-title" font-size="15.9">AutoRenewPeriod · 等待扣费</text>
|
||||
</g>
|
||||
<g id="node-pendingtransfer" data-graph-role="node" data-node-id="pendingtransfer" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="980,380,1180,440">
|
||||
<rect x="980.0" y="380.0" width="200.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="1080.0" y="398.0" text-anchor="middle" class="node-type">REGISTRAR</text>
|
||||
<text data-text-role="title" data-full-text="PendingTransfer · 转移中" data-text-max-width="176.0" x="1080.0" y="416.0" text-anchor="middle" class="node-title" font-size="12.79">PendingTransfer · 转移中</text>
|
||||
</g>
|
||||
<g id="node-transferperiod" data-graph-role="node" data-node-id="transferperiod" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="700,560,910,620">
|
||||
<rect x="700.0" y="560.0" width="210.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="805.0" y="578.0" text-anchor="middle" class="node-type">REGISTRAR</text>
|
||||
<text data-text-role="title" data-full-text="TransferPeriod · 5天" data-text-max-width="186.0" x="805.0" y="596.0" text-anchor="middle" class="node-title" font-size="16.03">TransferPeriod · 5天</text>
|
||||
</g>
|
||||
<g id="node-redemption" data-graph-role="node" data-node-id="redemption" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="380,560,640,620">
|
||||
<rect x="380.0" y="560.0" width="260.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="510.0" y="578.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="RedemptionPeriod · 赎回 30天" data-text-max-width="236.0" x="510.0" y="596.0" text-anchor="middle" class="node-title" font-size="14.98">RedemptionPeriod · 赎回 30天</text>
|
||||
</g>
|
||||
<g id="node-pendingdelete" data-graph-role="node" data-node-id="pendingdelete" data-semantic-role="rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="70,560,310,620">
|
||||
<rect x="70.0" y="560.0" width="240.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#dce5e3" stroke-width="1.8"/>
|
||||
<text x="190.0" y="578.0" text-anchor="middle" class="node-type">REGISTRY</text>
|
||||
<text data-text-role="title" data-full-text="PendingDelete · 删除 5天" data-text-max-width="216.0" x="190.0" y="596.0" text-anchor="middle" class="node-title" font-size="16.31">PendingDelete · 删除 5天</text>
|
||||
</g>
|
||||
<g id="node-released" data-graph-role="node" data-node-id="released" data-semantic-role="double_rect" data-motion-role="" data-motion-stage="" data-motion-order="" data-parent="" data-deployment-id="" data-topic-id="" data-span-id="" data-station-order="" data-status="" data-start-ms="" data-duration-ms="" data-parent-span="" data-graph-bounds="70,740,330,800">
|
||||
<rect x="70.0" y="740.0" width="260.0" height="60.0" rx="14.0" fill="#ffffff" stroke="#1d4ed8" stroke-width="2.0"/>
|
||||
<rect x="76.0" y="746.0" width="248.0" height="48.0" rx="11.0" fill="none" stroke="#1d4ed8" stroke-width="1.2" opacity="0.65"/>
|
||||
<text x="200.0" y="758.0" text-anchor="middle" class="node-type">END</text>
|
||||
<text data-text-role="title" data-full-text="已释放 · 重新开放注册" data-text-max-width="228.0" x="200.0" y="776.0" text-anchor="middle" class="node-title" font-size="18">已释放 · 重新开放注册</text>
|
||||
</g>
|
||||
<g id="a-reg-label" data-graph-role="label" data-owner="a-reg" data-graph-bounds="306,139,344,159">
|
||||
<rect x="306.0" y="139.0" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="325.0" y="153.0" text-anchor="middle" class="arrow-label">注册</text>
|
||||
</g>
|
||||
<g id="a-ok-label" data-graph-role="label" data-owner="a-ok" data-graph-bounds="629.2,148,691.2,168">
|
||||
<rect x="629.2" y="148.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="660.2" y="162.0" text-anchor="middle" class="arrow-label">注册完成</text>
|
||||
</g>
|
||||
<g id="a-hold-c-label" data-graph-role="label" data-owner="a-hold-c" data-graph-bounds="911.2,106,973.2,126">
|
||||
<rect x="911.2" y="106.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="942.2" y="120.0" text-anchor="middle" class="arrow-label">暂停解析</text>
|
||||
</g>
|
||||
<g id="a-hold-s-label" data-graph-role="label" data-owner="a-hold-s" data-graph-bounds="865.4,205,927.4,225">
|
||||
<rect x="865.4" y="205.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="896.4" y="219.0" text-anchor="middle" class="arrow-label">违规暂停</text>
|
||||
</g>
|
||||
<g id="a-renew-label" data-graph-role="label" data-owner="a-renew" data-graph-bounds="755,286.2,793,306.2">
|
||||
<rect x="755.0" y="286.2" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="774.0" y="300.2" text-anchor="middle" class="arrow-label">续费</text>
|
||||
</g>
|
||||
<g id="a-renew-back-label" data-graph-role="label" data-owner="a-renew-back" data-graph-bounds="820,276,906,296">
|
||||
<rect x="820.0" y="276.0" width="86.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="863.0" y="290.0" text-anchor="middle" class="arrow-label">期满回到正常</text>
|
||||
</g>
|
||||
<g id="a-expire-label" data-graph-role="label" data-owner="a-expire" data-graph-bounds="582.43,211,620.43,231">
|
||||
<rect x="582.43" y="211.0" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="601.43" y="225.0" text-anchor="middle" class="arrow-label">到期</text>
|
||||
</g>
|
||||
<g id="a-no-pay-label" data-graph-role="label" data-owner="a-no-pay" data-graph-bounds="457,486,507,506">
|
||||
<rect x="457.0" y="486.0" width="50.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="482.0" y="500.0" text-anchor="middle" class="arrow-label">未支付</text>
|
||||
</g>
|
||||
<g id="a-redemption-end-label" data-graph-role="label" data-owner="a-redemption-end" data-graph-bounds="314,559,376,579">
|
||||
<rect x="314.0" y="559.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="345.0" y="573.0" text-anchor="middle" class="arrow-label">赎回期满</text>
|
||||
</g>
|
||||
<g id="a-release-label" data-graph-role="label" data-owner="a-release" data-graph-bounds="228.8,627.4,266.8,647.4">
|
||||
<rect x="228.8" y="627.4" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="247.8" y="641.4" text-anchor="middle" class="arrow-label">释放</text>
|
||||
</g>
|
||||
<g id="a-transfer-label" data-graph-role="label" data-owner="a-transfer" data-graph-bounds="889,248,927,268">
|
||||
<rect x="889.0" y="248.0" width="38.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="908.0" y="262.0" text-anchor="middle" class="arrow-label">转移</text>
|
||||
</g>
|
||||
<g id="a-transfer-done-label" data-graph-role="label" data-owner="a-transfer-done" data-graph-bounds="848.8,451,910.8,471">
|
||||
<rect x="848.8" y="451.0" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="879.8" y="465.0" text-anchor="middle" class="arrow-label">转移完成</text>
|
||||
</g>
|
||||
<g id="a-transfer-ok-label" data-graph-role="label" data-owner="a-transfer-ok" data-graph-bounds="611,474.42,673,494.42">
|
||||
<rect x="611.0" y="474.42" width="62.0" height="20" rx="6" fill="#ffffff" opacity="0.96"/>
|
||||
<text x="642.0" y="488.42" text-anchor="middle" class="arrow-label">转移生效</text>
|
||||
</g>
|
||||
<g id="legend" data-graph-role="legend">
|
||||
<rect id="legend-zone" data-graph-role="reserved" data-reserved-kind="legend" x="32" y="684" width="273.6" height="46" rx="10" fill="none" stroke="none"/>
|
||||
<line data-graph-role="decoration" x1="42.0" y1="698.0" x2="72.0" y2="698.0" stroke="#10a37f" stroke-width="2.0" marker-end="url(#arrowA)"/>
|
||||
<text data-graph-role="decoration" x="82.0" y="702.0" class="legend">状态流转</text>
|
||||
<line data-graph-role="decoration" x1="158.0" y1="698.0" x2="188.0" y2="698.0" stroke="#475569" stroke-width="2.0" marker-end="url(#arrowG)"/>
|
||||
<text data-graph-role="decoration" x="198.0" y="702.0" class="legend">回到活跃 / 暂停</text>
|
||||
</g>
|
||||
<g id="footer" data-graph-role="reserved" data-graph-bounds="48,833,433,849">
|
||||
<text x="48.0" y="845.0" class="footnote">Style 7 · OpenAI Official · EPP domain lifecycle states</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
// CI 部署完成后一次性提交 sitemap 全部 URL 到 IndexNow(Bing / Yandex / Seznam / Yep 共用端点)。
|
||||
// 用法:node scripts/indexnow-submit.mjs --site https://nipw.cn --key <key>
|
||||
//
|
||||
// key 必须与 frontend-ssr/public/<key>.txt 完全一致 —— IndexNow 的所有权校验就是抓取
|
||||
// 这个文件比对,所以 key 本身是公开凭证、写在 CI 里并不泄密。
|
||||
// 逻辑与 server/utils/indexnow.ts 保持一致(那边是运行时端点,这边是构建机直跑)。
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
function argOf(name) {
|
||||
const i = args.indexOf(name)
|
||||
return i >= 0 ? args[i + 1] : undefined
|
||||
}
|
||||
|
||||
const site = (argOf('--site') || 'https://nipw.cn').replace(/\/$/, '')
|
||||
const key = argOf('--key')
|
||||
|
||||
if (!key) {
|
||||
console.error('usage: node scripts/indexnow-submit.mjs --site <url> --key <key>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/IndexNow'
|
||||
const sitemapUrl = `${site}/sitemap.xml`
|
||||
|
||||
const res = await fetch(sitemapUrl)
|
||||
if (!res.ok) {
|
||||
console.error(`sitemap fetch failed: ${res.status} ${sitemapUrl}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const xml = await res.text()
|
||||
const urls = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1].trim())
|
||||
if (!urls.length) {
|
||||
console.error('sitemap has no <loc> entries, nothing to submit')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// IndexNow 单请求最多 1 万条,分片兜底
|
||||
const CHUNK = 10_000
|
||||
let ok = 0
|
||||
for (let i = 0; i < urls.length; i += CHUNK) {
|
||||
const urlList = urls.slice(i, i + CHUNK)
|
||||
const r = await fetch(INDEXNOW_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({
|
||||
host: new URL(site).host,
|
||||
key,
|
||||
keyLocation: `${site}/${key}.txt`,
|
||||
urlList,
|
||||
}),
|
||||
})
|
||||
const body = await r.text().catch(() => '')
|
||||
console.log(`IndexNow chunk ${i / CHUNK + 1}: HTTP ${r.status} (${urlList.length} urls) ${body.slice(0, 120)}`)
|
||||
if (r.ok || r.status === 202) ok += urlList.length
|
||||
}
|
||||
console.log(`submitted ${ok}/${urls.length} urls from ${sitemapUrl}`)
|
||||
// IndexNow 202 = 已受理;4xx 校验失败会让 CI 亮红,属预期失败信号
|
||||
if (!ok) process.exit(1)
|
||||
@@ -4,12 +4,33 @@ import { defineSitemapEventHandler } from '#imports'
|
||||
// 相对路径与 server/routes/middleware/[...slug].get.ts 保持一致(`~` 在 Nuxt 4 里指向 app/,指不到根目录的 config/)。
|
||||
import { docConfig } from '../../../config/doc'
|
||||
|
||||
// 显式枚举全部工具页并标注权重:与 nuxt:pages 自动扫描并存(模块会按 loc 去重合并),
|
||||
// 好处是收录不依赖扫描行为、且带 priority/changefreq。
|
||||
// 注意 /ipv6 不在列表里:它已改为 server/routes/ipv6.get.ts 的服务端 301,不再是页面。
|
||||
const TOOL_PAGES: Array<{ loc: string; priority: number; changefreq: string }> = [
|
||||
{ loc: '/', priority: 1.0, changefreq: 'daily' },
|
||||
{ loc: '/location', priority: 0.9, changefreq: 'weekly' },
|
||||
{ loc: '/asn', priority: 0.9, changefreq: 'weekly' },
|
||||
{ loc: '/dns', priority: 0.9, changefreq: 'weekly' },
|
||||
{ loc: '/dnssec', priority: 0.9, changefreq: 'weekly' },
|
||||
{ loc: '/whois', priority: 0.9, changefreq: 'weekly' },
|
||||
{ loc: '/ssl', priority: 0.9, changefreq: 'weekly' },
|
||||
{ loc: '/tcping', priority: 0.9, changefreq: 'weekly' },
|
||||
{ loc: '/speedtest', priority: 0.9, changefreq: 'weekly' },
|
||||
{ loc: '/ipv6speedtest', priority: 0.9, changefreq: 'weekly' },
|
||||
{ loc: '/ipv6tcping', priority: 0.9, changefreq: 'weekly' },
|
||||
{ loc: '/ipv6webcheck', priority: 0.9, changefreq: 'weekly' },
|
||||
{ loc: '/screenshot', priority: 0.8, changefreq: 'weekly' },
|
||||
]
|
||||
|
||||
export default defineSitemapEventHandler(async () => {
|
||||
const urls = Object.keys(docConfig).map(path => ({ path }));
|
||||
|
||||
return urls.map(url => ({
|
||||
loc: url.path,
|
||||
_encoded: true,
|
||||
const toolPages = TOOL_PAGES.map(({ loc, priority, changefreq }) => ({ loc, priority, changefreq }))
|
||||
// 文档页权重:索引页 /doc 高一档,正文页低一档
|
||||
const docPages = Object.keys(docConfig).map(path => ({
|
||||
loc: path,
|
||||
priority: path === '/doc' ? 0.8 : 0.7,
|
||||
changefreq: 'monthly',
|
||||
}))
|
||||
|
||||
return [...toolPages, ...docPages]
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// GET /api/indexnow?key=<key> —— 把 sitemap 里的全部 URL 提交到 IndexNow。
|
||||
// 给运维手动触发(curl)或外部监控调用;key 校验失败一律 403。
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig(event)
|
||||
const key = getQuery(event).key
|
||||
if (!config.indexnowKey || key !== config.indexnowKey) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'invalid key' })
|
||||
}
|
||||
const siteUrl = config.public.siteUrl
|
||||
const urls = await fetchSitemapUrls(siteUrl)
|
||||
if (!urls.length) {
|
||||
return { submitted: 0, status: 0, body: 'sitemap is empty' }
|
||||
}
|
||||
return await submitIndexNow(urls, siteUrl)
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
// POST /api/indexnow?key=<key>(或请求头 x-indexnow-key),body: { urls: string[] }
|
||||
// —— 精确提交新增/变更的 URL(发布新文档、工具页上线等场景)。
|
||||
// URL 限本站域名(相对路径按 siteUrl 补全),跨域 URL 直接过滤。
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig(event)
|
||||
const key = getQuery(event).key || getHeader(event, 'x-indexnow-key')
|
||||
if (!config.indexnowKey || key !== config.indexnowKey) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'invalid key' })
|
||||
}
|
||||
const body = await readBody<{ urls?: unknown[] }>(event).catch(() => null)
|
||||
const urls = normalizeIndexNowUrls(body?.urls ?? [], config.public.siteUrl)
|
||||
if (!urls.length) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'no valid urls (same host only)' })
|
||||
}
|
||||
return await submitIndexNow(urls, config.public.siteUrl)
|
||||
})
|
||||
@@ -0,0 +1,501 @@
|
||||
{
|
||||
"canvas": {
|
||||
"height": 640.0,
|
||||
"width": 1240.0
|
||||
},
|
||||
"composition": {
|
||||
"limits": {
|
||||
"max_bends_per_edge": 12,
|
||||
"max_bridged_crossings": 8,
|
||||
"max_route_stretch": 5.0,
|
||||
"max_total_bends": 100,
|
||||
"min_container_gutter": 0.0,
|
||||
"min_label_clearance": 2.0,
|
||||
"min_node_gap": 0.0,
|
||||
"min_segment_length": 0.0,
|
||||
"profile": "standard"
|
||||
},
|
||||
"metrics": {
|
||||
"bridged_crossings": 5,
|
||||
"max_route_stretch": 1.872,
|
||||
"minimum_container_gutter": 19.0,
|
||||
"minimum_node_gap": 55.0,
|
||||
"shortest_segment": 3.28,
|
||||
"total_bends": 19
|
||||
},
|
||||
"ok": true,
|
||||
"profile": "standard",
|
||||
"score": 38,
|
||||
"violations": []
|
||||
},
|
||||
"edges": [
|
||||
{
|
||||
"bends": 0,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "ds-tld",
|
||||
"length": 165.0,
|
||||
"route": [
|
||||
[
|
||||
330.0,
|
||||
195.0
|
||||
],
|
||||
[
|
||||
495.0,
|
||||
195.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "root",
|
||||
"source_port": [
|
||||
330.0,
|
||||
195.0
|
||||
],
|
||||
"target": "tld",
|
||||
"target_port": [
|
||||
495.0,
|
||||
195.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 0,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "ds-auth",
|
||||
"length": 160.0,
|
||||
"route": [
|
||||
[
|
||||
745.0,
|
||||
195.0
|
||||
],
|
||||
[
|
||||
905.0,
|
||||
195.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "tld",
|
||||
"source_port": [
|
||||
745.0,
|
||||
195.0
|
||||
],
|
||||
"target": "auth",
|
||||
"target_port": [
|
||||
905.0,
|
||||
195.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 2,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "pub-key",
|
||||
"length": 963.5,
|
||||
"route": [
|
||||
[
|
||||
1023.5,
|
||||
225.0
|
||||
],
|
||||
[
|
||||
1023.5,
|
||||
346.0
|
||||
],
|
||||
[
|
||||
205.0,
|
||||
346.0
|
||||
],
|
||||
[
|
||||
205.0,
|
||||
370.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "auth",
|
||||
"source_port": [
|
||||
1023.5,
|
||||
225.0
|
||||
],
|
||||
"target": "dnskey",
|
||||
"target_port": [
|
||||
205.0,
|
||||
370.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 6,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "sign",
|
||||
"length": 1060.3,
|
||||
"route": [
|
||||
[
|
||||
1041.5,
|
||||
225.0
|
||||
],
|
||||
[
|
||||
1041.5,
|
||||
245.4
|
||||
],
|
||||
[
|
||||
1184.0,
|
||||
245.4
|
||||
],
|
||||
[
|
||||
1184.0,
|
||||
454.0
|
||||
],
|
||||
[
|
||||
745.0,
|
||||
454.0
|
||||
],
|
||||
[
|
||||
745.0,
|
||||
349.6
|
||||
],
|
||||
[
|
||||
620.0,
|
||||
349.6
|
||||
],
|
||||
[
|
||||
620.0,
|
||||
370.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.872,
|
||||
"source": "auth",
|
||||
"source_port": [
|
||||
1041.5,
|
||||
225.0
|
||||
],
|
||||
"target": "rrset",
|
||||
"target_port": [
|
||||
620.0,
|
||||
370.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 4,
|
||||
"bridges": [
|
||||
[
|
||||
884.6,
|
||||
454.0
|
||||
]
|
||||
],
|
||||
"crossings": [
|
||||
[
|
||||
884.6,
|
||||
454.0
|
||||
]
|
||||
],
|
||||
"id": "fetch-key",
|
||||
"length": 706.0,
|
||||
"route": [
|
||||
[
|
||||
330.0,
|
||||
400.0
|
||||
],
|
||||
[
|
||||
350.4,
|
||||
400.0
|
||||
],
|
||||
[
|
||||
350.4,
|
||||
461.0
|
||||
],
|
||||
[
|
||||
884.6,
|
||||
461.0
|
||||
],
|
||||
[
|
||||
884.6,
|
||||
391.0
|
||||
],
|
||||
[
|
||||
905.0,
|
||||
391.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.209,
|
||||
"source": "dnskey",
|
||||
"source_port": [
|
||||
330.0,
|
||||
400.0
|
||||
],
|
||||
"target": "resolver",
|
||||
"target_port": [
|
||||
905.0,
|
||||
391.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 2,
|
||||
"bridges": [
|
||||
[
|
||||
745.0,
|
||||
400.0
|
||||
],
|
||||
[
|
||||
884.6,
|
||||
409.0
|
||||
]
|
||||
],
|
||||
"crossings": [
|
||||
[
|
||||
745.0,
|
||||
400.0
|
||||
],
|
||||
[
|
||||
884.6,
|
||||
409.0
|
||||
]
|
||||
],
|
||||
"id": "fetch-rr",
|
||||
"length": 169.0,
|
||||
"route": [
|
||||
[
|
||||
745.0,
|
||||
400.0
|
||||
],
|
||||
[
|
||||
748.28,
|
||||
400.0
|
||||
],
|
||||
[
|
||||
748.28,
|
||||
409.0
|
||||
],
|
||||
[
|
||||
905.0,
|
||||
409.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "rrset",
|
||||
"source_port": [
|
||||
745.0,
|
||||
400.0
|
||||
],
|
||||
"target": "resolver",
|
||||
"target_port": [
|
||||
905.0,
|
||||
409.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 5,
|
||||
"bridges": [
|
||||
[
|
||||
1044.0,
|
||||
454.0
|
||||
]
|
||||
],
|
||||
"crossings": [
|
||||
[
|
||||
1044.0,
|
||||
454.0
|
||||
]
|
||||
],
|
||||
"id": "a-check",
|
||||
"length": 825.5,
|
||||
"route": [
|
||||
[
|
||||
1032.5,
|
||||
430.0
|
||||
],
|
||||
[
|
||||
1032.5,
|
||||
450.4
|
||||
],
|
||||
[
|
||||
1044.0,
|
||||
450.4
|
||||
],
|
||||
[
|
||||
1044.0,
|
||||
565.0
|
||||
],
|
||||
[
|
||||
590.0,
|
||||
565.0
|
||||
],
|
||||
[
|
||||
590.0,
|
||||
485.0
|
||||
],
|
||||
[
|
||||
445.0,
|
||||
485.0
|
||||
],
|
||||
[
|
||||
445.0,
|
||||
485.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.285,
|
||||
"source": "resolver",
|
||||
"source_port": [
|
||||
1032.5,
|
||||
430.0
|
||||
],
|
||||
"target": "check",
|
||||
"target_port": [
|
||||
445.0,
|
||||
485.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 0,
|
||||
"bridges": [
|
||||
[
|
||||
590.0,
|
||||
513.0
|
||||
]
|
||||
],
|
||||
"crossings": [
|
||||
[
|
||||
590.0,
|
||||
513.0
|
||||
]
|
||||
],
|
||||
"id": "a-pass",
|
||||
"length": 140.0,
|
||||
"route": [
|
||||
[
|
||||
590.0,
|
||||
513.0
|
||||
],
|
||||
[
|
||||
730.0,
|
||||
513.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "check",
|
||||
"source_port": [
|
||||
590.0,
|
||||
513.0
|
||||
],
|
||||
"target": "pass",
|
||||
"target_port": [
|
||||
730.0,
|
||||
513.0
|
||||
],
|
||||
"waypoints": []
|
||||
}
|
||||
],
|
||||
"input_schema": "v1",
|
||||
"issues": [
|
||||
{
|
||||
"code": "EDGE_CROSSING_BRIDGED",
|
||||
"coordinates": [
|
||||
[
|
||||
884.6,
|
||||
454.0
|
||||
]
|
||||
],
|
||||
"element": "fetch-key",
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"code": "EDGE_CROSSING_BRIDGED",
|
||||
"coordinates": [
|
||||
[
|
||||
745.0,
|
||||
400.0
|
||||
],
|
||||
[
|
||||
884.6,
|
||||
409.0
|
||||
]
|
||||
],
|
||||
"element": "fetch-rr",
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"code": "EDGE_CROSSING_BRIDGED",
|
||||
"coordinates": [
|
||||
[
|
||||
1044.0,
|
||||
454.0
|
||||
]
|
||||
],
|
||||
"element": "a-check",
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"code": "EDGE_CROSSING_BRIDGED",
|
||||
"coordinates": [
|
||||
[
|
||||
590.0,
|
||||
513.0
|
||||
]
|
||||
],
|
||||
"element": "a-pass",
|
||||
"severity": "info"
|
||||
}
|
||||
],
|
||||
"mode": "architecture",
|
||||
"ok": true,
|
||||
"palette": {
|
||||
"below_target": [],
|
||||
"minimum_target": 4.5,
|
||||
"ratios": {
|
||||
"section_sub_fill": 4.76,
|
||||
"text_muted": 4.76,
|
||||
"text_primary": 17.85,
|
||||
"text_secondary": 7.58
|
||||
},
|
||||
"scope": "default opaque canvas text tokens; not a full rendered accessibility audit",
|
||||
"unsupported_tokens": []
|
||||
},
|
||||
"placements": {
|
||||
"legend": {
|
||||
"actual": [
|
||||
42.0,
|
||||
530.0
|
||||
],
|
||||
"bounds": [
|
||||
32.0,
|
||||
516.0,
|
||||
215.6,
|
||||
606.0
|
||||
],
|
||||
"moved": true,
|
||||
"requested": [
|
||||
42.0,
|
||||
540.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"schema_version": 1,
|
||||
"semantics": {
|
||||
"details": {},
|
||||
"ok": true,
|
||||
"profile": "generic",
|
||||
"style": 7,
|
||||
"visual_theme": "OpenAI"
|
||||
},
|
||||
"style": {
|
||||
"id": 7,
|
||||
"name": "OpenAI"
|
||||
},
|
||||
"summary": {
|
||||
"bridged_crossings": 5,
|
||||
"edges": 8,
|
||||
"nodes": 8
|
||||
},
|
||||
"text_metrics": "heuristic-v1",
|
||||
"typography": {
|
||||
"complete_text": true,
|
||||
"hint": null,
|
||||
"metrics": "heuristic widths; inspect the actual rendered font",
|
||||
"truncated": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"mode": "architecture",
|
||||
"template_type": "architecture",
|
||||
"style": 7,
|
||||
"quality_profile": "standard",
|
||||
"width": 1240,
|
||||
"height": 640,
|
||||
"title": "DNSSEC 验证链与本地验签",
|
||||
"subtitle": "DS 信任链自根向下逐级建立,递归侧以 DNSKEY 验证 RRset 签名",
|
||||
"containers": [
|
||||
{
|
||||
"id": "trust",
|
||||
"x": 40,
|
||||
"y": 110,
|
||||
"width": 1160,
|
||||
"height": 150,
|
||||
"label": "信任链 · 由根向下逐级签发",
|
||||
"stroke": "#e2e8f0",
|
||||
"fill": "none"
|
||||
},
|
||||
{
|
||||
"id": "verify",
|
||||
"x": 40,
|
||||
"y": 310,
|
||||
"width": 1160,
|
||||
"height": 250,
|
||||
"label": "递归解析侧 · 本地验签",
|
||||
"stroke": "#e2e8f0",
|
||||
"fill": "none"
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "root",
|
||||
"kind": "rect",
|
||||
"x": 80,
|
||||
"y": 165,
|
||||
"width": 250,
|
||||
"height": 60,
|
||||
"type_label": "ROOT · 信任锚",
|
||||
"label": "根 DNS(内置信任锚)",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#dce5e3",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "tld",
|
||||
"kind": "double_rect",
|
||||
"x": 495,
|
||||
"y": 165,
|
||||
"width": 250,
|
||||
"height": 60,
|
||||
"type_label": "TLD · .top",
|
||||
"label": "顶级 DNS(发布 DS)",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#10a37f"
|
||||
},
|
||||
{
|
||||
"id": "auth",
|
||||
"kind": "double_rect",
|
||||
"x": 905,
|
||||
"y": 165,
|
||||
"width": 255,
|
||||
"height": 60,
|
||||
"type_label": "AUTHORITATIVE",
|
||||
"label": "权威 DNS(发布 DNSKEY)",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#10a37f"
|
||||
},
|
||||
{
|
||||
"id": "dnskey",
|
||||
"kind": "rect",
|
||||
"x": 80,
|
||||
"y": 370,
|
||||
"width": 250,
|
||||
"height": 60,
|
||||
"type_label": "DNSKEY",
|
||||
"label": "KSK / ZSK 密钥",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#dce5e3",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "rrset",
|
||||
"kind": "rect",
|
||||
"x": 495,
|
||||
"y": 370,
|
||||
"width": 250,
|
||||
"height": 60,
|
||||
"type_label": "RRSET + RRSIG",
|
||||
"label": "记录数据与签名",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#dce5e3",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "resolver",
|
||||
"kind": "double_rect",
|
||||
"x": 905,
|
||||
"y": 370,
|
||||
"width": 255,
|
||||
"height": 60,
|
||||
"type_label": "RECURSIVE · DO=1",
|
||||
"label": "递归解析器(带 DO 查询)",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#1d4ed8"
|
||||
},
|
||||
{
|
||||
"id": "check",
|
||||
"kind": "rect",
|
||||
"x": 300,
|
||||
"y": 485,
|
||||
"width": 290,
|
||||
"height": 56,
|
||||
"type_label": "VERIFY",
|
||||
"label": "RRSIG.Verify 本地验签",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#dce5e3",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "pass",
|
||||
"kind": "double_rect",
|
||||
"x": 730,
|
||||
"y": 485,
|
||||
"width": 290,
|
||||
"height": 56,
|
||||
"type_label": "RESULT",
|
||||
"label": "验证通过 · 数据完整可信",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#10a37f"
|
||||
}
|
||||
],
|
||||
"arrows": [
|
||||
{
|
||||
"id": "ds-tld",
|
||||
"source": "root",
|
||||
"target": "tld",
|
||||
"source_port": "right",
|
||||
"target_port": "left",
|
||||
"flow": "control",
|
||||
"label": "DS 签发"
|
||||
},
|
||||
{
|
||||
"id": "ds-auth",
|
||||
"source": "tld",
|
||||
"target": "auth",
|
||||
"source_port": "right",
|
||||
"target_port": "left",
|
||||
"flow": "control",
|
||||
"label": "DS 指向下级 KEY"
|
||||
},
|
||||
{
|
||||
"id": "pub-key",
|
||||
"source": "auth",
|
||||
"target": "dnskey",
|
||||
"source_port": "bottom",
|
||||
"target_port": "top",
|
||||
"flow": "write",
|
||||
"label": "发布密钥"
|
||||
},
|
||||
{
|
||||
"id": "sign",
|
||||
"source": "auth",
|
||||
"target": "rrset",
|
||||
"source_port": "bottom",
|
||||
"target_port": "top",
|
||||
"flow": "write",
|
||||
"label": "私钥签名"
|
||||
},
|
||||
{
|
||||
"id": "fetch-key",
|
||||
"source": "dnskey",
|
||||
"target": "resolver",
|
||||
"source_port": "right",
|
||||
"target_port": "left",
|
||||
"flow": "read",
|
||||
"label": "取回 DNSKEY"
|
||||
},
|
||||
{
|
||||
"id": "fetch-rr",
|
||||
"source": "rrset",
|
||||
"target": "resolver",
|
||||
"source_port": "right",
|
||||
"target_port": "left",
|
||||
"flow": "read",
|
||||
"label": "带签名应答"
|
||||
},
|
||||
{
|
||||
"id": "a-check",
|
||||
"source": "resolver",
|
||||
"target": "check",
|
||||
"source_port": "bottom",
|
||||
"target_port": "top",
|
||||
"flow": "control",
|
||||
"label": "RRSIG.Verify"
|
||||
},
|
||||
{
|
||||
"id": "a-pass",
|
||||
"source": "check",
|
||||
"target": "pass",
|
||||
"source_port": "right",
|
||||
"target_port": "left",
|
||||
"flow": "control",
|
||||
"label": "验签通过"
|
||||
}
|
||||
],
|
||||
"legend": [
|
||||
{
|
||||
"flow": "control",
|
||||
"label": "信任链 / 主验证路径"
|
||||
},
|
||||
{
|
||||
"flow": "read",
|
||||
"label": "数据获取"
|
||||
},
|
||||
{
|
||||
"flow": "write",
|
||||
"label": "权威侧写操作"
|
||||
}
|
||||
],
|
||||
"footer": "Style 7 · OpenAI Official · DNSSEC chain of trust",
|
||||
"footer_x": 48,
|
||||
"footer_y": 625
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
{
|
||||
"canvas": {
|
||||
"height": 880.0,
|
||||
"width": 1240.0
|
||||
},
|
||||
"composition": {
|
||||
"limits": {
|
||||
"max_bends_per_edge": 4,
|
||||
"max_bridged_crossings": 14,
|
||||
"max_route_stretch": 5.0,
|
||||
"max_total_bends": 30,
|
||||
"min_container_gutter": 0.0,
|
||||
"min_label_clearance": 2.0,
|
||||
"min_node_gap": 0.0,
|
||||
"min_segment_length": 0.0,
|
||||
"profile": "standard"
|
||||
},
|
||||
"metrics": {
|
||||
"bridged_crossings": 3,
|
||||
"max_route_stretch": 2.625,
|
||||
"minimum_container_gutter": 10.0,
|
||||
"minimum_node_gap": 41.0,
|
||||
"shortest_segment": 3.0,
|
||||
"total_bends": 30
|
||||
},
|
||||
"ok": true,
|
||||
"profile": "standard",
|
||||
"score": 42,
|
||||
"violations": []
|
||||
},
|
||||
"edges": [
|
||||
{
|
||||
"bends": 0,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "a-reg",
|
||||
"length": 110.0,
|
||||
"route": [
|
||||
[
|
||||
270.0,
|
||||
170.0
|
||||
],
|
||||
[
|
||||
380.0,
|
||||
170.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "available",
|
||||
"source_port": [
|
||||
270.0,
|
||||
170.0
|
||||
],
|
||||
"target": "addperiod",
|
||||
"target_port": [
|
||||
380.0,
|
||||
170.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 2,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "a-ok",
|
||||
"length": 109.0,
|
||||
"route": [
|
||||
[
|
||||
600.0,
|
||||
170.0
|
||||
],
|
||||
[
|
||||
620.4,
|
||||
170.0
|
||||
],
|
||||
[
|
||||
620.4,
|
||||
179.0
|
||||
],
|
||||
[
|
||||
700.0,
|
||||
179.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "addperiod",
|
||||
"source_port": [
|
||||
600.0,
|
||||
170.0
|
||||
],
|
||||
"target": "active",
|
||||
"target_port": [
|
||||
700.0,
|
||||
179.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 2,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "a-hold-c",
|
||||
"length": 95.0,
|
||||
"route": [
|
||||
[
|
||||
910.0,
|
||||
152.0
|
||||
],
|
||||
[
|
||||
930.4,
|
||||
152.0
|
||||
],
|
||||
[
|
||||
930.4,
|
||||
137.0
|
||||
],
|
||||
[
|
||||
990.0,
|
||||
137.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "active",
|
||||
"source_port": [
|
||||
910.0,
|
||||
152.0
|
||||
],
|
||||
"target": "clienthold",
|
||||
"target_port": [
|
||||
990.0,
|
||||
137.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 2,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "a-hold-s",
|
||||
"length": 142.0,
|
||||
"route": [
|
||||
[
|
||||
910.0,
|
||||
170.0
|
||||
],
|
||||
[
|
||||
930.4,
|
||||
170.0
|
||||
],
|
||||
[
|
||||
930.4,
|
||||
232.0
|
||||
],
|
||||
[
|
||||
990.0,
|
||||
232.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "active",
|
||||
"source_port": [
|
||||
910.0,
|
||||
170.0
|
||||
],
|
||||
"target": "serverhold",
|
||||
"target_port": [
|
||||
990.0,
|
||||
232.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 2,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "a-renew",
|
||||
"length": 189.0,
|
||||
"route": [
|
||||
[
|
||||
787.0,
|
||||
200.0
|
||||
],
|
||||
[
|
||||
787.0,
|
||||
220.4
|
||||
],
|
||||
[
|
||||
796.0,
|
||||
220.4
|
||||
],
|
||||
[
|
||||
796.0,
|
||||
380.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "active",
|
||||
"source_port": [
|
||||
787.0,
|
||||
200.0
|
||||
],
|
||||
"target": "renewperiod",
|
||||
"target_port": [
|
||||
796.0,
|
||||
380.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 4,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "a-renew-back",
|
||||
"length": 195.0,
|
||||
"route": [
|
||||
[
|
||||
814.0,
|
||||
380.0
|
||||
],
|
||||
[
|
||||
814.0,
|
||||
359.6
|
||||
],
|
||||
[
|
||||
817.0,
|
||||
359.6
|
||||
],
|
||||
[
|
||||
817.0,
|
||||
220.4
|
||||
],
|
||||
[
|
||||
805.0,
|
||||
220.4
|
||||
],
|
||||
[
|
||||
805.0,
|
||||
200.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.032,
|
||||
"source": "renewperiod",
|
||||
"source_port": [
|
||||
814.0,
|
||||
380.0
|
||||
],
|
||||
"target": "active",
|
||||
"target_port": [
|
||||
805.0,
|
||||
200.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 3,
|
||||
"bridges": [
|
||||
[
|
||||
692.86,
|
||||
179.0
|
||||
]
|
||||
],
|
||||
"crossings": [
|
||||
[
|
||||
692.86,
|
||||
179.0
|
||||
]
|
||||
],
|
||||
"id": "a-expire",
|
||||
"length": 409.0,
|
||||
"route": [
|
||||
[
|
||||
700.0,
|
||||
161.0
|
||||
],
|
||||
[
|
||||
692.86,
|
||||
161.0
|
||||
],
|
||||
[
|
||||
692.86,
|
||||
224.0
|
||||
],
|
||||
[
|
||||
510.0,
|
||||
224.0
|
||||
],
|
||||
[
|
||||
510.0,
|
||||
380.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "active",
|
||||
"source_port": [
|
||||
700.0,
|
||||
161.0
|
||||
],
|
||||
"target": "autorenew",
|
||||
"target_port": [
|
||||
510.0,
|
||||
380.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 0,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "a-no-pay",
|
||||
"length": 120.0,
|
||||
"route": [
|
||||
[
|
||||
510.0,
|
||||
440.0
|
||||
],
|
||||
[
|
||||
510.0,
|
||||
560.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "autorenew",
|
||||
"source_port": [
|
||||
510.0,
|
||||
440.0
|
||||
],
|
||||
"target": "redemption",
|
||||
"target_port": [
|
||||
510.0,
|
||||
560.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 0,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "a-redemption-end",
|
||||
"length": 70.0,
|
||||
"route": [
|
||||
[
|
||||
380.0,
|
||||
590.0
|
||||
],
|
||||
[
|
||||
310.0,
|
||||
590.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "redemption",
|
||||
"source_port": [
|
||||
380.0,
|
||||
590.0
|
||||
],
|
||||
"target": "pendingdelete",
|
||||
"target_port": [
|
||||
310.0,
|
||||
590.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 4,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "a-release",
|
||||
"length": 341.2,
|
||||
"route": [
|
||||
[
|
||||
190.0,
|
||||
620.0
|
||||
],
|
||||
[
|
||||
190.0,
|
||||
640.4
|
||||
],
|
||||
[
|
||||
305.6,
|
||||
640.4
|
||||
],
|
||||
[
|
||||
305.6,
|
||||
732.86
|
||||
],
|
||||
[
|
||||
200.0,
|
||||
732.86
|
||||
],
|
||||
[
|
||||
200.0,
|
||||
740.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 2.625,
|
||||
"source": "pendingdelete",
|
||||
"source_port": [
|
||||
190.0,
|
||||
620.0
|
||||
],
|
||||
"target": "released",
|
||||
"target_port": [
|
||||
200.0,
|
||||
740.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 4,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "a-transfer",
|
||||
"length": 292.0,
|
||||
"route": [
|
||||
[
|
||||
910.0,
|
||||
188.0
|
||||
],
|
||||
[
|
||||
930.0,
|
||||
188.0
|
||||
],
|
||||
[
|
||||
930.0,
|
||||
356.0
|
||||
],
|
||||
[
|
||||
959.6,
|
||||
356.0
|
||||
],
|
||||
[
|
||||
959.6,
|
||||
410.0
|
||||
],
|
||||
[
|
||||
980.0,
|
||||
410.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.0,
|
||||
"source": "active",
|
||||
"source_port": [
|
||||
910.0,
|
||||
188.0
|
||||
],
|
||||
"target": "pendingtransfer",
|
||||
"target_port": [
|
||||
980.0,
|
||||
410.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 3,
|
||||
"bridges": [],
|
||||
"crossings": [],
|
||||
"id": "a-transfer-done",
|
||||
"length": 561.8,
|
||||
"route": [
|
||||
[
|
||||
1080.0,
|
||||
440.0
|
||||
],
|
||||
[
|
||||
1080.0,
|
||||
464.0
|
||||
],
|
||||
[
|
||||
679.6,
|
||||
464.0
|
||||
],
|
||||
[
|
||||
679.6,
|
||||
581.0
|
||||
],
|
||||
[
|
||||
700.0,
|
||||
581.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.078,
|
||||
"source": "pendingtransfer",
|
||||
"source_port": [
|
||||
1080.0,
|
||||
440.0
|
||||
],
|
||||
"target": "transferperiod",
|
||||
"target_port": [
|
||||
700.0,
|
||||
581.0
|
||||
],
|
||||
"waypoints": []
|
||||
},
|
||||
{
|
||||
"bends": 4,
|
||||
"bridges": [
|
||||
[
|
||||
787.0,
|
||||
200.0
|
||||
],
|
||||
[
|
||||
805.0,
|
||||
200.0
|
||||
]
|
||||
],
|
||||
"crossings": [
|
||||
[
|
||||
787.0,
|
||||
200.0
|
||||
],
|
||||
[
|
||||
805.0,
|
||||
200.0
|
||||
]
|
||||
],
|
||||
"id": "a-transfer-ok",
|
||||
"length": 570.0,
|
||||
"route": [
|
||||
[
|
||||
700.0,
|
||||
599.0
|
||||
],
|
||||
[
|
||||
676.0,
|
||||
599.0
|
||||
],
|
||||
[
|
||||
676.0,
|
||||
230.4
|
||||
],
|
||||
[
|
||||
700.0,
|
||||
230.4
|
||||
],
|
||||
[
|
||||
700.0,
|
||||
200.0
|
||||
],
|
||||
[
|
||||
823.0,
|
||||
200.0
|
||||
],
|
||||
[
|
||||
823.0,
|
||||
200.0
|
||||
]
|
||||
],
|
||||
"route_stretch": 1.092,
|
||||
"source": "transferperiod",
|
||||
"source_port": [
|
||||
700.0,
|
||||
599.0
|
||||
],
|
||||
"target": "active",
|
||||
"target_port": [
|
||||
823.0,
|
||||
200.0
|
||||
],
|
||||
"waypoints": []
|
||||
}
|
||||
],
|
||||
"input_schema": "v1",
|
||||
"issues": [
|
||||
{
|
||||
"code": "EDGE_CROSSING_BRIDGED",
|
||||
"coordinates": [
|
||||
[
|
||||
692.86,
|
||||
179.0
|
||||
]
|
||||
],
|
||||
"element": "a-expire",
|
||||
"severity": "info"
|
||||
},
|
||||
{
|
||||
"code": "EDGE_CROSSING_BRIDGED",
|
||||
"coordinates": [
|
||||
[
|
||||
787.0,
|
||||
200.0
|
||||
],
|
||||
[
|
||||
805.0,
|
||||
200.0
|
||||
]
|
||||
],
|
||||
"element": "a-transfer-ok",
|
||||
"severity": "info"
|
||||
}
|
||||
],
|
||||
"mode": "architecture",
|
||||
"ok": true,
|
||||
"palette": {
|
||||
"below_target": [],
|
||||
"minimum_target": 4.5,
|
||||
"ratios": {
|
||||
"section_sub_fill": 4.76,
|
||||
"text_muted": 4.76,
|
||||
"text_primary": 17.85,
|
||||
"text_secondary": 7.58
|
||||
},
|
||||
"scope": "default opaque canvas text tokens; not a full rendered accessibility audit",
|
||||
"unsupported_tokens": []
|
||||
},
|
||||
"placements": {
|
||||
"legend": {
|
||||
"actual": [
|
||||
42.0,
|
||||
698.0
|
||||
],
|
||||
"bounds": [
|
||||
32.0,
|
||||
684.0,
|
||||
305.6,
|
||||
730.0
|
||||
],
|
||||
"moved": true,
|
||||
"requested": [
|
||||
42.0,
|
||||
798.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"schema_version": 1,
|
||||
"semantics": {
|
||||
"details": {},
|
||||
"ok": true,
|
||||
"profile": "generic",
|
||||
"style": 7,
|
||||
"visual_theme": "OpenAI"
|
||||
},
|
||||
"style": {
|
||||
"id": 7,
|
||||
"name": "OpenAI"
|
||||
},
|
||||
"summary": {
|
||||
"bridged_crossings": 3,
|
||||
"edges": 13,
|
||||
"nodes": 12
|
||||
},
|
||||
"text_metrics": "heuristic-v1",
|
||||
"typography": {
|
||||
"complete_text": true,
|
||||
"hint": null,
|
||||
"metrics": "heuristic widths; inspect the actual rendered font",
|
||||
"truncated": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"mode": "architecture",
|
||||
"template_type": "architecture",
|
||||
"style": 7,
|
||||
"quality_profile": "standard",
|
||||
"width": 1240,
|
||||
"height": 880,
|
||||
"title": "域名生命周期状态机",
|
||||
"subtitle": "从可注册到释放的完整 EPP 状态流转(REGISTRY / REGISTRAR 管理方标注)",
|
||||
"composition": {
|
||||
"profile": "standard",
|
||||
"max_bridged_crossings": 14,
|
||||
"max_total_bends": 30,
|
||||
"max_bends_per_edge": 4
|
||||
},
|
||||
"containers": [
|
||||
{
|
||||
"id": "reg",
|
||||
"x": 40,
|
||||
"y": 70,
|
||||
"width": 1160,
|
||||
"height": 220,
|
||||
"label": "注册与活跃",
|
||||
"stroke": "#e2e8f0",
|
||||
"fill": "none"
|
||||
},
|
||||
{
|
||||
"id": "grace",
|
||||
"x": 40,
|
||||
"y": 330,
|
||||
"width": 1160,
|
||||
"height": 140,
|
||||
"label": "续费 · 到期 · 转移中",
|
||||
"stroke": "#e2e8f0",
|
||||
"fill": "none"
|
||||
},
|
||||
{
|
||||
"id": "end",
|
||||
"x": 40,
|
||||
"y": 510,
|
||||
"width": 1160,
|
||||
"height": 300,
|
||||
"label": "赎回 · 释放 · 转移完成",
|
||||
"stroke": "#e2e8f0",
|
||||
"fill": "none"
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "available",
|
||||
"kind": "rect",
|
||||
"x": 70,
|
||||
"y": 140,
|
||||
"width": 200,
|
||||
"height": 60,
|
||||
"type_label": "REGISTRY",
|
||||
"label": "Available · 域名可注册",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#dce5e3",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "addperiod",
|
||||
"kind": "rect",
|
||||
"x": 380,
|
||||
"y": 140,
|
||||
"width": 220,
|
||||
"height": 60,
|
||||
"type_label": "REGISTRY",
|
||||
"label": "AddPeriod · 注册完成 5天",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#dce5e3",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "active",
|
||||
"kind": "double_rect",
|
||||
"x": 700,
|
||||
"y": 140,
|
||||
"width": 210,
|
||||
"height": 60,
|
||||
"type_label": "REGISTRAR · Active",
|
||||
"label": "Active (ok) · 正常解析",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#10a37f"
|
||||
},
|
||||
{
|
||||
"id": "clienthold",
|
||||
"kind": "rect",
|
||||
"x": 990,
|
||||
"y": 110,
|
||||
"width": 200,
|
||||
"height": 54,
|
||||
"type_label": "REGISTRAR",
|
||||
"label": "ClientHold · 客户暂停",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#f97316",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "serverhold",
|
||||
"kind": "rect",
|
||||
"x": 990,
|
||||
"y": 205,
|
||||
"width": 200,
|
||||
"height": 54,
|
||||
"type_label": "REGISTRY",
|
||||
"label": "ServerHold · 注册局暂停",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#f97316",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "renewperiod",
|
||||
"kind": "rect",
|
||||
"x": 700,
|
||||
"y": 380,
|
||||
"width": 210,
|
||||
"height": 60,
|
||||
"type_label": "REGISTRY",
|
||||
"label": "RenewPeriod · 手动续费 5天",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#dce5e3",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "autorenew",
|
||||
"kind": "rect",
|
||||
"x": 380,
|
||||
"y": 380,
|
||||
"width": 260,
|
||||
"height": 60,
|
||||
"type_label": "REGISTRY",
|
||||
"label": "AutoRenewPeriod · 等待扣费",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#dce5e3",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "pendingtransfer",
|
||||
"kind": "rect",
|
||||
"x": 980,
|
||||
"y": 380,
|
||||
"width": 200,
|
||||
"height": 60,
|
||||
"type_label": "REGISTRAR",
|
||||
"label": "PendingTransfer · 转移中",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#dce5e3",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "transferperiod",
|
||||
"kind": "rect",
|
||||
"x": 700,
|
||||
"y": 560,
|
||||
"width": 210,
|
||||
"height": 60,
|
||||
"type_label": "REGISTRAR",
|
||||
"label": "TransferPeriod · 5天",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#dce5e3",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "redemption",
|
||||
"kind": "rect",
|
||||
"x": 380,
|
||||
"y": 560,
|
||||
"width": 260,
|
||||
"height": 60,
|
||||
"type_label": "REGISTRY",
|
||||
"label": "RedemptionPeriod · 赎回 30天",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#dce5e3",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "pendingdelete",
|
||||
"kind": "rect",
|
||||
"x": 70,
|
||||
"y": 560,
|
||||
"width": 240,
|
||||
"height": 60,
|
||||
"type_label": "REGISTRY",
|
||||
"label": "PendingDelete · 删除 5天",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#dce5e3",
|
||||
"flat": true
|
||||
},
|
||||
{
|
||||
"id": "released",
|
||||
"kind": "double_rect",
|
||||
"x": 70,
|
||||
"y": 740,
|
||||
"width": 260,
|
||||
"height": 60,
|
||||
"type_label": "END",
|
||||
"label": "已释放 · 重新开放注册",
|
||||
"fill": "#ffffff",
|
||||
"stroke": "#1d4ed8"
|
||||
}
|
||||
],
|
||||
"arrows": [
|
||||
{
|
||||
"id": "a-reg",
|
||||
"source": "available",
|
||||
"target": "addperiod",
|
||||
"source_port": "right",
|
||||
"target_port": "left",
|
||||
"flow": "control",
|
||||
"label": "注册"
|
||||
},
|
||||
{
|
||||
"id": "a-ok",
|
||||
"source": "addperiod",
|
||||
"target": "active",
|
||||
"source_port": "right",
|
||||
"target_port": "left",
|
||||
"flow": "control",
|
||||
"label": "注册完成"
|
||||
},
|
||||
{
|
||||
"id": "a-hold-c",
|
||||
"source": "active",
|
||||
"target": "clienthold",
|
||||
"source_port": "right",
|
||||
"target_port": "left",
|
||||
"flow": "feedback",
|
||||
"label": "暂停解析"
|
||||
},
|
||||
{
|
||||
"id": "a-hold-s",
|
||||
"source": "active",
|
||||
"target": "serverhold",
|
||||
"source_port": "right",
|
||||
"target_port": "left",
|
||||
"flow": "feedback",
|
||||
"label": "违规暂停"
|
||||
},
|
||||
{
|
||||
"id": "a-renew",
|
||||
"source": "active",
|
||||
"target": "renewperiod",
|
||||
"source_port": "bottom",
|
||||
"target_port": "top",
|
||||
"flow": "control",
|
||||
"label": "续费"
|
||||
},
|
||||
{
|
||||
"id": "a-renew-back",
|
||||
"source": "renewperiod",
|
||||
"target": "active",
|
||||
"source_port": "top",
|
||||
"target_port": "bottom",
|
||||
"flow": "feedback",
|
||||
"label": "期满回到正常"
|
||||
},
|
||||
{
|
||||
"id": "a-expire",
|
||||
"source": "active",
|
||||
"target": "autorenew",
|
||||
"source_port": "left",
|
||||
"target_port": "top",
|
||||
"flow": "control",
|
||||
"label": "到期"
|
||||
},
|
||||
{
|
||||
"id": "a-no-pay",
|
||||
"source": "autorenew",
|
||||
"target": "redemption",
|
||||
"source_port": "bottom",
|
||||
"target_port": "top",
|
||||
"flow": "control",
|
||||
"label": "未支付"
|
||||
},
|
||||
{
|
||||
"id": "a-redemption-end",
|
||||
"source": "redemption",
|
||||
"target": "pendingdelete",
|
||||
"source_port": "left",
|
||||
"target_port": "right",
|
||||
"flow": "control",
|
||||
"label": "赎回期满"
|
||||
},
|
||||
{
|
||||
"id": "a-release",
|
||||
"source": "pendingdelete",
|
||||
"target": "released",
|
||||
"source_port": "bottom",
|
||||
"target_port": "top",
|
||||
"flow": "control",
|
||||
"label": "释放"
|
||||
},
|
||||
{
|
||||
"id": "a-transfer",
|
||||
"source": "active",
|
||||
"target": "pendingtransfer",
|
||||
"source_port": "right",
|
||||
"target_port": "left",
|
||||
"flow": "control",
|
||||
"label": "转移"
|
||||
},
|
||||
{
|
||||
"id": "a-transfer-done",
|
||||
"source": "pendingtransfer",
|
||||
"target": "transferperiod",
|
||||
"source_port": "bottom",
|
||||
"target_port": "left",
|
||||
"flow": "control",
|
||||
"label": "转移完成"
|
||||
},
|
||||
{
|
||||
"id": "a-transfer-ok",
|
||||
"source": "transferperiod",
|
||||
"target": "active",
|
||||
"source_port": "left",
|
||||
"target_port": "bottom",
|
||||
"flow": "feedback",
|
||||
"label": "转移生效"
|
||||
}
|
||||
],
|
||||
"legend_orientation": "horizontal",
|
||||
"legend": [
|
||||
{
|
||||
"flow": "control",
|
||||
"label": "状态流转"
|
||||
},
|
||||
{
|
||||
"flow": "feedback",
|
||||
"label": "回到活跃 / 暂停"
|
||||
}
|
||||
],
|
||||
"footer": "Style 7 · OpenAI Official · EPP domain lifecycle states",
|
||||
"footer_x": 48,
|
||||
"footer_y": 845
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# DNSSEC 原理介绍
|
||||
|
||||
DNSSEC(Domain Name System Security Extensions,DNS 安全扩展)通过**数字签名**为 DNS 应答提供来源验证与数据完整性保护:解析器收到应答后,用域名公钥本地验证签名,确认记录确实来自该域名的权威 DNS、且在传输途中没有被篡改或劫持。它防的是 DNS 欺骗与中间人攻击,但不加密内容——机密性仍由 TLS 负责。
|
||||
|
||||

|
||||
|
||||
## 三类关键记录
|
||||
|
||||
| 记录 | 发布位置 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `DNSKEY` | 域名自身 | 域名公钥,分 KSK(签密钥集)与 ZSK(签记录集) |
|
||||
| `RRSIG` | 域名自身 | 对每类记录集(RRset)的数字签名,随应答一并返回 |
|
||||
| `DS` | **父区**(如 `.top`) | 子域 DNSKEY 的摘要,是把信任链"挂"到上级的凭证 |
|
||||
|
||||
## 信任链如何建立
|
||||
|
||||
验证从根开始逐级向下:根的信任锚 → 顶级域的 `DS` → 域名的 `DNSKEY`。解析器拿到 `RRSIG` 后,用对应的 `DNSKEY` 做 `RRSIG.Verify` 本地验签;`DNSKEY` 自身的真伪再由父区 `DS` 核对,如此递归到根——任何一环对不上,应答即判定为 BOGUS(伪造)。
|
||||
|
||||
**本站的验证路径**:查询 `DNSKEY` → 带 DO 位查询 A 记录取回 `RRSIG` → 本地 `RRSIG.Verify` 验签(对 TLD / 无 A 记录的域,退回用 DNSKEY 自验签)→ 核对 `DS`。因国内公共 DNS(DNSPod / doh.pub / AliDNS)即使带 DO 位也不返回 RRSIG,本站默认使用实测支持 DNSSEC 的境外解析器列表(`1.1.1.1` / `8.8.8.8` 等,IPv4/IPv6 双栈)。
|
||||
|
||||
## 查询结果字段说明
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `enabled` | 是否启用 DNSSEC(存在 RRSIG 或 DNSKEY) |
|
||||
| `valid` | 签名是否验证通过 |
|
||||
| `has_rrsig` / `has_dnskey` / `has_ds` | 是否查到对应记录 |
|
||||
| `algorithm` / `key_tag` | 签名算法与密钥标签(如算法 13 = ECDSAP256SHA256) |
|
||||
| `validation` | 人类可读的判定说明 |
|
||||
|
||||
## 常见"验证失败"原因
|
||||
|
||||
- 域名本身**未配置 DNSSEC**(最常见——先在注册商处确认是否已开启);
|
||||
- `DNSKEY` 与 `DS` 不匹配(密钥轮换后父区 `DS` 未同步更新);
|
||||
- 解析器不支持 DNSSEC(带 DO 位也不返回 RRSIG,本站已默认规避);
|
||||
- 域名处于 ClientHold / ServerHold 暂停状态。
|
||||
|
||||
[DNS 解析查询](/dns) | [Whois 域名查询](/whois) | [回到顶部](#dnssec-原理介绍)
|
||||
@@ -59,6 +59,8 @@
|
||||
| 字段 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `ip` | String | 请求方公网 IP(本节点视角) |
|
||||
| `lemondb` | Object | `country`/`country_code`/`administrative_area`/`city`/`district`/`division_code`/`isp`/`type`/`latitude`/`longitude`/`coord_system`(自家 LemonDB 月更库:全球打底 + 中国省市县高精度层,坐标统一 WGS-84,上高德底图需自转 GCJ-02) |
|
||||
| `lemondb_asn` | Object | `asn`/`org`(GeoLite2-ASN 打底 + APNIC CN 精修) |
|
||||
| `ip2region` | Object | `country`/`administrative_area`/`city`(部分记录另带 `isp`) |
|
||||
| `ip2location` | Object | `country`/`country_code`/`administrative_area`/`city`/`district`/`zipcode`/`latitude`/`longitude`/`timezone`/`isp`/`asn`/`usagetype` |
|
||||
| `ip2location_asn` | Object | `asn`/`as` |
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
### 域名生命周期流程图
|
||||
|
||||

|
||||

|
||||
### 域名状态详解
|
||||
|
||||
| 状态 | 描述 | 特征/影响 |
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// /ipv6 是历史对外地址(ipw.cn 同款 ?ip= 参数曾大量被外部引用,不能 404)。
|
||||
// 原实现是一个客户端 onMounted 跳转的空壳页:无 title、无 description、无 h1,
|
||||
// 被 SEO 审计判为「缺少 H1」页,还以低质量页面的身份混进 sitemap。
|
||||
// 这里改为服务端 301:爬虫直接拿到重定向,HTML 空壳页不复存在,
|
||||
// query 原样带到 /location(?ip=xxx → /location?ip=xxx)。
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const qs = new URLSearchParams(
|
||||
Object.entries(query).flatMap(([k, v]) =>
|
||||
v === undefined ? [] : Array.isArray(v) ? v.map((item) => [k, String(item)] as [string, string]) : [[k, String(v)] as [string, string]]
|
||||
)
|
||||
).toString()
|
||||
return sendRedirect(event, '/location' + (qs ? `?${qs}` : ''), 301)
|
||||
})
|
||||
@@ -41,10 +41,20 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
|
||||
}
|
||||
|
||||
const slugString: string = event.context.params?.slug as any
|
||||
if (!slugString) {
|
||||
const rawSlug: string = event.context.params?.slug as any
|
||||
if (!rawSlug) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing slug parameter' })
|
||||
}
|
||||
}
|
||||
// h3 出于路径安全不会把 %2F 解码回 '/',而 detail/ssl/speed 的目标 URL 恰恰依赖它
|
||||
// (前端 encodeURIComponent('https://x') → 'https%3A%2F%2Fx')。远程中间件(fiber)会解码,
|
||||
// 这里补齐同样的语义,否则内置中间件转发带协议头的拨测目标必然拼坏
|
||||
// (下游拿到 'https://https:%2F%2Fx')。畸形 % 序列解码失败时按原文处理。
|
||||
let slugString: string = rawSlug
|
||||
try {
|
||||
slugString = decodeURIComponent(rawSlug)
|
||||
} catch {
|
||||
// keep raw
|
||||
}
|
||||
// 将路径参数转为数组
|
||||
let slug: string[] = slugString ? slugString.split('/') : []
|
||||
console.debug('[middleware debug] raw slugString:', slugString, '| split:', slug)
|
||||
@@ -133,7 +143,11 @@ export default defineEventHandler(async (event) => {
|
||||
if (apiBaseUrl.slice(-1) != '/') {
|
||||
apiBaseUrl = `${apiBaseUrl}/`
|
||||
}
|
||||
data = await $fetch(`${apiBaseUrl}v1/${apiType}/${raw}`, {
|
||||
const queryString = new URLSearchParams(
|
||||
Object.entries(query).filter(([_, v]) => v !== undefined) as [string, string][]
|
||||
).toString()
|
||||
|
||||
data = await $fetch(`${apiBaseUrl}v1/${apiType}/${raw}${queryString ? '?' + queryString : ''}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders,
|
||||
timeout: 15_000,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// IndexNow 提交的共享实现:server/api/indexnow.get.ts / indexnow.post.ts 与
|
||||
// scripts/indexnow-submit.mjs(CI 部署后一次性提交)三方语义保持一致。
|
||||
//
|
||||
// key 是「站点所有权凭证」而非机密:IndexNow 的校验方式就是抓取
|
||||
// https://nipw.cn/<key>.txt 并比对,所以 key 必须能公开访问(见 public/ 下的 key 文件)。
|
||||
// 放进 runtimeConfig 只是为了支持环境变量覆盖(NUXT_INDEXNOW_KEY),默认值与 key 文件一致。
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
|
||||
const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/IndexNow'
|
||||
|
||||
export interface IndexNowResult {
|
||||
submitted: number
|
||||
status: number
|
||||
body: string
|
||||
}
|
||||
|
||||
// 归一化 + 同域校验:只允许提交本站 URL(相对路径按 siteUrl 补全),
|
||||
// 防止提交端点被当作向 IndexNow 提交任意域名的跳板。
|
||||
export function normalizeIndexNowUrls(input: unknown[], siteUrl: string): string[] {
|
||||
const origin = new URL(siteUrl).origin
|
||||
const out = new Set<string>()
|
||||
for (const raw of input) {
|
||||
if (typeof raw !== 'string' || !raw) continue
|
||||
const abs = new URL(raw, siteUrl)
|
||||
if (abs.origin !== origin) continue
|
||||
out.add(abs.href)
|
||||
}
|
||||
return [...out]
|
||||
}
|
||||
|
||||
// 拉取 sitemap.xml 并抽出全部 <loc>(本站 sitemap 的 XML 很简单,正则足够且免依赖)
|
||||
export async function fetchSitemapUrls(siteUrl: string): Promise<string[]> {
|
||||
const res = await fetch(new URL('sitemap.xml', siteUrl).href)
|
||||
if (!res.ok) throw new Error(`sitemap fetch failed: ${res.status}`)
|
||||
const xml = await res.text()
|
||||
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1].trim())
|
||||
}
|
||||
|
||||
export async function submitIndexNow(urls: string[], siteUrl: string): Promise<IndexNowResult> {
|
||||
const key = useRuntimeConfig().indexnowKey
|
||||
if (!key) throw new Error('indexnowKey is not configured')
|
||||
const res = await fetch(INDEXNOW_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({
|
||||
host: new URL(siteUrl).host,
|
||||
key,
|
||||
keyLocation: new URL(`/${key}.txt`, siteUrl).href,
|
||||
// IndexNow 单请求最多 1 万条 URL,超出截断(本站量级远够)
|
||||
urlList: urls.slice(0, 10_000),
|
||||
}),
|
||||
})
|
||||
const body = await res.text().catch(() => '')
|
||||
return { submitted: urls.length, status: res.status, body: body.slice(0, 200) }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// WGS84 → GCJ-02(火星坐标)互转:国内地图底图(高德/腾讯)均为 GCJ-02 坐标系,
|
||||
// IP 库返回的经纬度是 WGS84,直接叠点会偏移 100~700 米,须先转换再打点。
|
||||
// 境外坐标原样返回(GCJ-02 仅在中国境内有偏移)。
|
||||
|
||||
const PI = Math.PI
|
||||
const A = 6378245 // 长半轴
|
||||
const EE = 0.00669342162296594323 // 偏心率平方
|
||||
|
||||
function outOfChina(lat: number, lng: number): boolean {
|
||||
return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271
|
||||
}
|
||||
|
||||
function transformLat(x: number, y: number): number {
|
||||
let ret =
|
||||
-100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x))
|
||||
ret += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3
|
||||
ret += ((20 * Math.sin(y * PI) + 40 * Math.sin((y / 3) * PI)) * 2) / 3
|
||||
ret += ((160 * Math.sin((y / 12) * PI) + 320 * Math.sin((y * PI) / 30)) * 2) / 3
|
||||
return ret
|
||||
}
|
||||
|
||||
function transformLng(x: number, y: number): number {
|
||||
let ret =
|
||||
300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x))
|
||||
ret += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3
|
||||
ret += ((20 * Math.sin(x * PI) + 40 * Math.sin((x / 3) * PI)) * 2) / 3
|
||||
ret += ((150 * Math.sin((x / 12) * PI) + 300 * Math.sin((x / 30) * PI)) * 2) / 3
|
||||
return ret
|
||||
}
|
||||
|
||||
/** WGS84 → GCJ-02;境外坐标原样返回 */
|
||||
export function wgs84ToGcj02(lat: number, lng: number): { lat: number; lng: number } {
|
||||
if (outOfChina(lat, lng)) return { lat, lng }
|
||||
let dLat = transformLat(lng - 105, lat - 35)
|
||||
let dLng = transformLng(lng - 105, lat - 35)
|
||||
const radLat = (lat / 180) * PI
|
||||
let magic = Math.sin(radLat)
|
||||
magic = 1 - EE * magic * magic
|
||||
const sqrtMagic = Math.sqrt(magic)
|
||||
dLat = (dLat * 180) / ((A * (1 - EE)) / sqrtMagic * PI)
|
||||
dLng = (dLng * 180) / (A / sqrtMagic * Math.cos(radLat) * PI)
|
||||
return { lat: lat + dLat, lng: lng + dLng }
|
||||
}
|
||||
@@ -40,3 +40,17 @@ export function isIPv4(ip: string): boolean {
|
||||
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
return ipRegex.test(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把参数对象拼到 URL 上(空值跳过,自动判断用 ? 还是 &)。
|
||||
* 用于高级选项:dns4/dns6/resolve/resolve4/resolve6 这些都是「请求级解析参数」,
|
||||
* 只有用户填了才下发,没填时 URL 与加参数之前逐字节一致(缓存键也不变)。
|
||||
*/
|
||||
export function withQuery(url: string, params: Record<string, string>): string {
|
||||
const pairs: string[] = []
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(value))
|
||||
}
|
||||
if (pairs.length === 0) return url
|
||||
return url + (url.includes('?') ? '&' : '?') + pairs.join('&')
|
||||
}
|
||||
+14
-12
@@ -348,6 +348,18 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
|
||||
query := c.Queries()
|
||||
|
||||
// 请求级 query 透传给节点(过滤空值,等价 TS 版 URLSearchParams 过滤):
|
||||
// 两个分支都要用 —— tcping/udping/speed 的 port/count/version,以及 detail/ssl/dns/whois 的 dns/dns4/dns6。
|
||||
// HTTP 侧用 url.Values 重新编码后拼接(原样透传会被下一跳的 query 解析器重新切分),WS 侧以 map 形式进 RequestProbe。
|
||||
queryString := url.Values{}
|
||||
queryMap := make(map[string]string)
|
||||
for k, v := range query {
|
||||
if v != "" {
|
||||
queryString.Set(k, v)
|
||||
queryMap[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// 从 API_KEYS 查找 token,未配置时回退到环境变量 API_KEYS (JSON 字符串)
|
||||
apiKey := lookupAPIKey(backendID)
|
||||
|
||||
@@ -378,7 +390,7 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
// 不再静默落回空 URL 的 HTTP 转发(必然 502 且掩盖真实配置错误)
|
||||
return c.Status(fiber.StatusBadGateway).SendString("Node " + backendID + " is WS-only but WS channel is disabled (ws-port=0 or bind failed)")
|
||||
}
|
||||
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, nil, wsProbeTimeout())
|
||||
status, body, err := wsSrv.RequestProbe(backendID, apiType, raw, queryMap, wsProbeTimeout())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).SendString("WS probe failed: " + err.Error())
|
||||
}
|
||||
@@ -391,22 +403,12 @@ func middlewareHandler(c fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 上游错误原样透传(状态码 + body),网络错误返回 502
|
||||
return forwardUpstream(c, apiBaseUrl, apiBaseUrl+"v1/"+apiType+"/"+raw, authHeaders, nil)
|
||||
return forwardUpstream(c, apiBaseUrl, apiBaseUrl+"v1/"+apiType+"/"+raw, authHeaders, queryString)
|
||||
|
||||
case "tcping", "udping", "speed":
|
||||
// tcping/udping/speed 统一走 APIBaseURL 节点池
|
||||
apiBaseUrls := flattenStack(API_BASE_URLS)
|
||||
|
||||
// 转发 query 参数(过滤空值,等价于 TS 中 URLSearchParams 过滤 undefined)
|
||||
queryString := url.Values{}
|
||||
queryMap := make(map[string]string)
|
||||
for k, v := range query {
|
||||
if v != "" {
|
||||
queryString.Set(k, v)
|
||||
queryMap[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
node := findNode(apiBaseUrls, backendID)
|
||||
if node == nil || (!node.UseWS() && node.URL == "") {
|
||||
return badRequest(c, "Invalid backend ID")
|
||||
|
||||
@@ -184,8 +184,11 @@ type SSLCheckDetail struct {
|
||||
IssuerOrganization []string `json:"issuer_organization"`
|
||||
IssuerCommonName string `json:"issuer_common_name"`
|
||||
SubjectCommonName string `json:"subject_common_name"`
|
||||
IsExpired bool `json:"is_expired"`
|
||||
IsReachable bool `json:"is_reachable"`
|
||||
// SAN 证书主体别名(Subject Alternative Names):DNS 名在前、IP 在后,
|
||||
// 与 CN 相互独立——校验「证书覆盖了哪些域名」以这里为准
|
||||
SAN []string `json:"san"`
|
||||
IsExpired bool `json:"is_expired"`
|
||||
IsReachable bool `json:"is_reachable"`
|
||||
}
|
||||
|
||||
type SSLCheckResult struct {
|
||||
@@ -202,26 +205,39 @@ func initHttpClient() {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
return &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) {
|
||||
if ssrf.Enabled() {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
// DNS 接管与 SSRF 解耦:域名一律先经 webtest 解析成 IP 再拨号。
|
||||
// 服务器取请求级覆盖(dns=/dns4=/dns6=,经 ctx 传来),无覆盖则用配置 dns-server;
|
||||
// 不这样时关掉 SSRF 会绕过配置/系统 DNS,detail/ssl/speed 与 tcping/dns 各问各的。
|
||||
// SSRF 只保留「私网校验」职责,不再是 DNS 接管的开关。
|
||||
// 同源:SSRF 预检(ValidateOutboundTarget)经 InstallSSRFResolver 读到的是同一份 ctx 配置。
|
||||
req := webtest.ReqResolveFromContext(ctx)
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 强制解析(resolve=/resolve4=/resolve6=):命中就把目标填回 host。
|
||||
// 目标是 IP → 走下面的字面 IP 分支(私网检测后直连,不再查 DNS);
|
||||
// 目标是域名 → 当作新 host 交给下面的解析分支。
|
||||
if target := req.Override(host, network == "tcp6"); target != "" {
|
||||
host = strings.Trim(target, "[]")
|
||||
addr = net.JoinHostPort(host, port)
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if ssrf.Enabled() && ssrf.IsPrivateIP(ip) {
|
||||
slog.Warn("Blocked connection to private IP", "host", host)
|
||||
return nil, fmt.Errorf("request to private/internal address is not allowed")
|
||||
}
|
||||
} else {
|
||||
var dnsResult webtest.DNSResult
|
||||
if network == "tcp4" {
|
||||
dnsResult, err = webtest.ResolveARecord(host, req.Servers("ipv4"))
|
||||
} else {
|
||||
dnsResult, err = webtest.ResolveAAAARecord(host, req.Servers("ipv6"))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if ssrf.IsPrivateIP(ip) {
|
||||
slog.Warn("Blocked connection to private IP", "host", host)
|
||||
return nil, fmt.Errorf("request to private/internal address is not allowed")
|
||||
}
|
||||
} else {
|
||||
var dnsResult webtest.DNSResult
|
||||
if network == "tcp4" {
|
||||
dnsResult, err = webtest.ResolveARecord(host)
|
||||
} else {
|
||||
dnsResult, err = webtest.ResolveAAAARecord(host)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ssrf.Enabled() {
|
||||
for _, ipStr := range dnsResult.Record {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip != nil && ssrf.IsPrivateIP(ip) {
|
||||
@@ -229,9 +245,9 @@ func initHttpClient() {
|
||||
return nil, fmt.Errorf("request to private/internal address is not allowed")
|
||||
}
|
||||
}
|
||||
if len(dnsResult.Record) > 0 {
|
||||
addr = net.JoinHostPort(dnsResult.Record[0], port)
|
||||
}
|
||||
}
|
||||
if len(dnsResult.Record) > 0 {
|
||||
addr = net.JoinHostPort(dnsResult.Record[0], port)
|
||||
}
|
||||
}
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
@@ -301,13 +317,16 @@ func (b *zstdReader) Close() error {
|
||||
return b.closeErr
|
||||
}
|
||||
|
||||
func checkWebsite(url string, version string) (*WebsiteCheckDetail, error) {
|
||||
// checkWebsite 对单个栈做站点拨测;req 为请求级解析配置(dns*/resolve*,零值 = 用配置 DNS)。
|
||||
func checkWebsite(url string, version string, req webtest.ReqResolve) (*WebsiteCheckDetail, error) {
|
||||
ctx := context.Background()
|
||||
var err error
|
||||
ctx, err = ssrf.ValidateOutboundTarget(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 请求级解析配置进 ctx:DialContext 与 SSRF 预检同源读取(dns=/dns4=/dns6=、resolve=)
|
||||
ctx = webtest.WithReqResolve(ctx, req)
|
||||
|
||||
client := V4Client
|
||||
if version == "v6" {
|
||||
@@ -338,7 +357,7 @@ func checkWebsite(url string, version string) (*WebsiteCheckDetail, error) {
|
||||
|
||||
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
|
||||
if dnsLookupTime == 0 {
|
||||
dnsLookupTime = measureDNSTime(url, version)
|
||||
dnsLookupTime = measureDNSTime(url, version, req)
|
||||
}
|
||||
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
|
||||
httpConnectTime := trace.ConnTime.Seconds() * 1000
|
||||
@@ -373,13 +392,16 @@ func checkWebsite(url string, version string) (*WebsiteCheckDetail, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func websiteSpeed(url string, version string) (*WebsiteSpeedTestResult, error) {
|
||||
// websiteSpeed 对单个栈做测速拨测;req 为请求级解析配置(零值 = 用配置 DNS)。
|
||||
func websiteSpeed(url string, version string, req webtest.ReqResolve) (*WebsiteSpeedTestResult, error) {
|
||||
ctx := context.Background()
|
||||
var err error
|
||||
ctx, err = ssrf.ValidateOutboundTarget(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 请求级解析配置进 ctx:DialContext 与 SSRF 预检同源读取(dns=/dns4=/dns6=、resolve=)
|
||||
ctx = webtest.WithReqResolve(ctx, req)
|
||||
|
||||
client := V4Client
|
||||
if version == "v6" {
|
||||
@@ -409,7 +431,7 @@ func websiteSpeed(url string, version string) (*WebsiteSpeedTestResult, error) {
|
||||
|
||||
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
|
||||
if dnsLookupTime == 0 {
|
||||
dnsLookupTime = measureDNSTime(url, version)
|
||||
dnsLookupTime = measureDNSTime(url, version, req)
|
||||
}
|
||||
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
|
||||
httpConnectTime := trace.ConnTime.Seconds() * 1000
|
||||
@@ -445,7 +467,7 @@ func websiteSpeed(url string, version string) (*WebsiteSpeedTestResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func measureDNSTime(urlStr string, version string) float64 {
|
||||
func measureDNSTime(urlStr string, version string, req webtest.ReqResolve) float64 {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return 0
|
||||
@@ -454,12 +476,15 @@ func measureDNSTime(urlStr string, version string) float64 {
|
||||
if host == "" {
|
||||
return 0
|
||||
}
|
||||
// 与拨测同源:走请求级 DNS 覆盖(dns4/dns6),无覆盖用配置 dns-server
|
||||
servers := req.Servers("ipv4")
|
||||
start := time.Now()
|
||||
var dnsErr error
|
||||
if version == "v6" {
|
||||
_, dnsErr = webtest.ResolveAAAARecord(host)
|
||||
servers = req.Servers("ipv6")
|
||||
_, dnsErr = webtest.ResolveAAAARecord(host, servers)
|
||||
} else {
|
||||
_, dnsErr = webtest.ResolveARecord(host)
|
||||
_, dnsErr = webtest.ResolveARecord(host, servers)
|
||||
}
|
||||
if dnsErr != nil {
|
||||
return 0
|
||||
@@ -467,13 +492,15 @@ func measureDNSTime(urlStr string, version string) float64 {
|
||||
return time.Since(start).Seconds() * 1000
|
||||
}
|
||||
|
||||
func checkSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
func checkSSL(url string, version string, req webtest.ReqResolve) (*SSLCheckDetail, error) {
|
||||
ssrfCtx := context.Background()
|
||||
var err error
|
||||
ssrfCtx, err = ssrf.ValidateOutboundTarget(ssrfCtx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 请求级解析配置进 ctx:DialContext 与 SSRF 预检同源读取(dns=/dns4=/dns6=、resolve=)
|
||||
ssrfCtx = webtest.WithReqResolve(ssrfCtx, req)
|
||||
|
||||
client := V4Client
|
||||
if version == "v6" {
|
||||
@@ -504,6 +531,7 @@ func checkSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
var certStartTime, certEndTime time.Time
|
||||
var issuerOrganization []string
|
||||
var issuerCommonName, subjectCommonName, domain string
|
||||
var san []string
|
||||
|
||||
if rawResp.TLS != nil && len(rawResp.TLS.PeerCertificates) > 0 {
|
||||
cert = rawResp.TLS.PeerCertificates[0]
|
||||
@@ -516,6 +544,12 @@ func checkSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
issuerCommonName = cert.Issuer.CommonName
|
||||
subjectCommonName = cert.Subject.CommonName
|
||||
domain = cleanHostRecord(cert.Subject.CommonName)
|
||||
// SAN:DNS 名在前、IP 在后(IP 站点证书可能只有 IP SAN)
|
||||
san = make([]string, 0, len(cert.DNSNames)+len(cert.IPAddresses))
|
||||
san = append(san, cert.DNSNames...)
|
||||
for _, ip := range cert.IPAddresses {
|
||||
san = append(san, ip.String())
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("no SSL certificate found")
|
||||
}
|
||||
@@ -534,6 +568,7 @@ func checkSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
IssuerOrganization: issuerOrganization,
|
||||
IssuerCommonName: issuerCommonName,
|
||||
SubjectCommonName: subjectCommonName,
|
||||
SAN: san,
|
||||
IsReachable: true,
|
||||
}
|
||||
|
||||
@@ -600,19 +635,28 @@ func checkWebsiteHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if cached, ok := websiteCache.Load(testUrl); ok {
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 缓存键带上请求级 DNS 覆盖:结果取决于所用服务器,参数不同必须各占一条,
|
||||
// 否则互相串味;无覆盖时 req.CacheKey() 为空串,键与旧版完全一致。
|
||||
cacheKey := testUrl + req.CacheKey()
|
||||
|
||||
if cached, ok := websiteCache.Load(cacheKey); ok {
|
||||
entry := cached.(websiteCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
c.JSON(200, entry.result)
|
||||
return
|
||||
}
|
||||
websiteCache.Delete(testUrl)
|
||||
websiteCache.Delete(cacheKey)
|
||||
}
|
||||
|
||||
result := &WebsiteCheckResult{}
|
||||
switch SINGLE_STACK {
|
||||
case "ipv4":
|
||||
ipv4, errV4 := checkWebsite(testUrl, "v4")
|
||||
ipv4, errV4 := checkWebsite(testUrl, "v4", req)
|
||||
if errV4 != nil {
|
||||
ipv4 = &WebsiteCheckDetail{
|
||||
HostRecord: "Error: " + errV4.Error(),
|
||||
@@ -625,7 +669,7 @@ func checkWebsiteHandler(c *gin.Context) {
|
||||
IsReachable: false,
|
||||
}
|
||||
case "ipv6":
|
||||
ipv6, errV6 := checkWebsite(testUrl, "v6")
|
||||
ipv6, errV6 := checkWebsite(testUrl, "v6", req)
|
||||
if errV6 != nil {
|
||||
ipv6 = &WebsiteCheckDetail{
|
||||
HostRecord: "Error: " + errV6.Error(),
|
||||
@@ -643,7 +687,7 @@ func checkWebsiteHandler(c *gin.Context) {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv6, errV6 := checkWebsite(testUrl, "v6")
|
||||
ipv6, errV6 := checkWebsite(testUrl, "v6", req)
|
||||
if errV6 != nil {
|
||||
ipv6 = &WebsiteCheckDetail{
|
||||
HostRecord: "Error: " + errV6.Error(),
|
||||
@@ -655,7 +699,7 @@ func checkWebsiteHandler(c *gin.Context) {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv4, errV4 := checkWebsite(testUrl, "v4")
|
||||
ipv4, errV4 := checkWebsite(testUrl, "v4", req)
|
||||
if errV4 != nil {
|
||||
ipv4 = &WebsiteCheckDetail{
|
||||
HostRecord: "Error: " + errV4.Error(),
|
||||
@@ -667,14 +711,14 @@ func checkWebsiteHandler(c *gin.Context) {
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
websiteCache.Store(testUrl, websiteCacheEntry{result: result, timestamp: time.Now()})
|
||||
websiteCache.Store(cacheKey, websiteCacheEntry{result: result, timestamp: time.Now()})
|
||||
|
||||
// If both IPv4 and IPv6 fail, only cache for 30 seconds
|
||||
// 如果 IPv4 和 IPv6 都失败,只缓存30秒
|
||||
if (result.IPv4 != nil && !result.IPv4.IsReachable) && (result.IPv6 != nil && !result.IPv6.IsReachable) {
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
websiteCache.Delete(testUrl)
|
||||
websiteCache.Delete(cacheKey)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -701,19 +745,27 @@ func sslCheckHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if cached, ok := sslCache.Load(testUrl); ok {
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 缓存键带上请求级 DNS 覆盖(结果取决于所用服务器,参数不同不能共用)
|
||||
cacheKey := testUrl + req.CacheKey()
|
||||
|
||||
if cached, ok := sslCache.Load(cacheKey); ok {
|
||||
entry := cached.(sslCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
c.JSON(200, entry.result)
|
||||
return
|
||||
}
|
||||
sslCache.Delete(testUrl)
|
||||
sslCache.Delete(cacheKey)
|
||||
}
|
||||
|
||||
result := &SSLCheckResult{}
|
||||
switch SINGLE_STACK {
|
||||
case "ipv4":
|
||||
ipv4, errV4 := checkSSL(testUrl, "v4")
|
||||
ipv4, errV4 := checkSSL(testUrl, "v4", req)
|
||||
if errV4 != nil {
|
||||
ipv4 = &SSLCheckDetail{
|
||||
HostRecord: "Error: " + errV4.Error(),
|
||||
@@ -726,7 +778,7 @@ func sslCheckHandler(c *gin.Context) {
|
||||
IsExpired: true,
|
||||
}
|
||||
case "ipv6":
|
||||
ipv6, errV6 := checkSSL(testUrl, "v6")
|
||||
ipv6, errV6 := checkSSL(testUrl, "v6", req)
|
||||
if errV6 != nil {
|
||||
ipv6 = &SSLCheckDetail{
|
||||
HostRecord: "Error: " + errV6.Error(),
|
||||
@@ -745,7 +797,7 @@ func sslCheckHandler(c *gin.Context) {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv6, errV6 := checkSSL(testUrl, "v6")
|
||||
ipv6, errV6 := checkSSL(testUrl, "v6", req)
|
||||
if errV6 != nil {
|
||||
ipv6 = &SSLCheckDetail{
|
||||
HostRecord: "Error: " + errV6.Error(),
|
||||
@@ -757,7 +809,7 @@ func sslCheckHandler(c *gin.Context) {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv4, errV4 := checkSSL(testUrl, "v4")
|
||||
ipv4, errV4 := checkSSL(testUrl, "v4", req)
|
||||
if errV4 != nil {
|
||||
ipv4 = &SSLCheckDetail{
|
||||
HostRecord: "Error: " + errV4.Error(),
|
||||
@@ -770,13 +822,13 @@ func sslCheckHandler(c *gin.Context) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
sslCache.Store(testUrl, sslCacheEntry{result: result, timestamp: time.Now()})
|
||||
sslCache.Store(cacheKey, sslCacheEntry{result: result, timestamp: time.Now()})
|
||||
|
||||
// 如果 IPv4 和 IPv6 都失败,只缓存30秒
|
||||
if (result.IPv4 != nil && !result.IPv4.IsReachable) && (result.IPv6 != nil && !result.IPv6.IsReachable) {
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
sslCache.Delete(testUrl)
|
||||
sslCache.Delete(cacheKey)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -796,6 +848,13 @@ func websiteSpeedTestHandler(c *gin.Context) {
|
||||
}
|
||||
url := normalizeURL(testUrl)
|
||||
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查请求版本是否与 SINGLE_STACK 配置匹配
|
||||
switch SINGLE_STACK {
|
||||
case "ipv4":
|
||||
if version != "v4" {
|
||||
@@ -815,7 +874,8 @@ func websiteSpeedTestHandler(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s:%s", url, version)
|
||||
// 缓存键:URL + 版本 + 请求级 DNS 覆盖(结果取决于所用服务器,参数不同不能共用)
|
||||
cacheKey := fmt.Sprintf("%s:%s%s", url, version, req.CacheKey())
|
||||
|
||||
if cached, ok := speedCache.Load(cacheKey); ok {
|
||||
entry := cached.(speedCacheEntry)
|
||||
@@ -828,9 +888,9 @@ func websiteSpeedTestHandler(c *gin.Context) {
|
||||
|
||||
switch version {
|
||||
case "v6":
|
||||
result, err = websiteSpeed(url, "v6")
|
||||
result, err = websiteSpeed(url, "v6", req)
|
||||
case "v4":
|
||||
result, err = websiteSpeed(url, "v4")
|
||||
result, err = websiteSpeed(url, "v4", req)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid version",
|
||||
@@ -868,93 +928,35 @@ func dnsQueryHandler(c *gin.Context) {
|
||||
domain = parsedURL.Host
|
||||
recodeType := c.Param("type")
|
||||
switch recodeType {
|
||||
case "a":
|
||||
result, err := webtest.ResolveARecord(domain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
case "aaaa":
|
||||
result, err := webtest.ResolveAAAARecord(domain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
case "cname":
|
||||
result, err := webtest.ResolveCNAMERecord(domain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
case "mx":
|
||||
result, err := webtest.ResolveMXRecord(domain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
case "ns":
|
||||
result, err := webtest.ResolveNSRecord(domain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
case "ptr":
|
||||
result, err := webtest.ResolvePTRRecord(domain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
case "srv":
|
||||
result, err := webtest.ResolveSRVRecord(domain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
case "txt":
|
||||
result, err := webtest.ResolveTXTRecord(domain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
case "caa":
|
||||
result, err := webtest.ResolveCAARecord(domain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
case "a", "aaaa", "cname", "mx", "ns", "ptr", "srv", "txt", "caa":
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid record type",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 按 qtype 选栈:a→dns4|dns,aaaa→dns6|dns,其余→dns
|
||||
stack := ""
|
||||
if recodeType == "a" {
|
||||
stack = "ipv4"
|
||||
} else if recodeType == "aaaa" {
|
||||
stack = "ipv6"
|
||||
}
|
||||
|
||||
result, err := webtest.ResolveByType(domain, recodeType, req.Servers(stack))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func pingHandler(c *gin.Context) {
|
||||
@@ -982,7 +984,15 @@ func pingHandler(c *gin.Context) {
|
||||
count = n
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d", host, port, count)
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 缓存键带上请求级 DNS 覆盖:不同 dns=/resolve= 的结果不能互相污染;
|
||||
// 无覆盖时 req.CacheKey() 为空串,键与旧版完全一致。
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d%s", host, port, count, req.CacheKey())
|
||||
if cached, ok := pingCache.Load(cacheKey); ok {
|
||||
entry := cached.(pingCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
@@ -996,7 +1006,7 @@ func pingHandler(c *gin.Context) {
|
||||
|
||||
switch SINGLE_STACK {
|
||||
case "ipv4":
|
||||
ipv4, errV4 := webtest.TCPingRun(host, port, count, "v4", 10*time.Second, 100*time.Millisecond)
|
||||
ipv4, errV4 := webtest.TCPingRunWith(host, port, count, "v4", 10*time.Second, 100*time.Millisecond, req)
|
||||
if errV4 != nil {
|
||||
ipv4 = &webtest.TCPingStats{
|
||||
IP: "Error: " + errV4.Error(),
|
||||
@@ -1007,7 +1017,7 @@ func pingHandler(c *gin.Context) {
|
||||
IP: "Skipped due to SINGLE_STACK=ipv4",
|
||||
}
|
||||
case "ipv6":
|
||||
ipv6, errV6 := webtest.TCPingRun(host, port, count, "v6", 10*time.Second, 100*time.Millisecond)
|
||||
ipv6, errV6 := webtest.TCPingRunWith(host, port, count, "v6", 10*time.Second, 100*time.Millisecond, req)
|
||||
if errV6 != nil {
|
||||
ipv6 = &webtest.TCPingStats{
|
||||
IP: "Error: " + errV6.Error(),
|
||||
@@ -1023,7 +1033,7 @@ func pingHandler(c *gin.Context) {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv6, errV6 := webtest.TCPingRun(host, port, count, "v6", 10*time.Second, 100*time.Millisecond)
|
||||
ipv6, errV6 := webtest.TCPingRunWith(host, port, count, "v6", 10*time.Second, 100*time.Millisecond, req)
|
||||
if errV6 != nil {
|
||||
ipv6 = &webtest.TCPingStats{
|
||||
IP: "Error: " + errV6.Error(),
|
||||
@@ -1034,7 +1044,7 @@ func pingHandler(c *gin.Context) {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv4, errV4 := webtest.TCPingRun(host, port, count, "v4", 10*time.Second, 100*time.Millisecond)
|
||||
ipv4, errV4 := webtest.TCPingRunWith(host, port, count, "v4", 10*time.Second, 100*time.Millisecond, req)
|
||||
if errV4 != nil {
|
||||
ipv4 = &webtest.TCPingStats{
|
||||
IP: "Error: " + errV4.Error(),
|
||||
@@ -1315,6 +1325,9 @@ func main() {
|
||||
initHttpClient()
|
||||
webtest.SetDNSServer(DNS_SERVER)
|
||||
webtest.SetDNSSecServer(DNSSEC_DNS_SERVER)
|
||||
// SSRF 预检改走 webtest 的自定义 DNS(含请求级 dns= 覆盖),
|
||||
// 避免「预检用系统解析器、拨号用自定义 DNS」两套解析器口径不一
|
||||
webtest.InstallSSRFResolver()
|
||||
slog.Info("Starting server", "port", PORTS, "single_stack", SINGLE_STACK)
|
||||
|
||||
// 数据上报:周期把本节点的统计与拨测明细上报到收集中心(详见 report.go)
|
||||
|
||||
@@ -45,6 +45,23 @@ func SetEnabled(v bool) {
|
||||
blockPrivateIPs = v
|
||||
}
|
||||
|
||||
// ResolveFunc 把主机名解析为 IP 列表。默认 net.LookupIP(系统解析器)。
|
||||
// 可由上层(webtest)通过 SetResolveFunc 注入,使出站校验与真正的拨号走同一个 DNS
|
||||
// ——包括请求级 DNS 服务器覆盖。否则预检(系统解析器)与拨号(自定义 DNS)会用两套解析器,
|
||||
// 既多一次查询,结果也可能不一致。
|
||||
type ResolveFunc func(ctx context.Context, host string) ([]net.IP, error)
|
||||
|
||||
var resolveHostFn ResolveFunc = func(_ context.Context, host string) ([]net.IP, error) {
|
||||
return net.LookupIP(host)
|
||||
}
|
||||
|
||||
// SetResolveFunc 注入自定义解析器(传 nil 忽略,保持默认 net.LookupIP)。
|
||||
func SetResolveFunc(f ResolveFunc) {
|
||||
if f != nil {
|
||||
resolveHostFn = f
|
||||
}
|
||||
}
|
||||
|
||||
// Go 标准库 IsPrivate 只覆盖 RFC1918 与 fd00::/8,这里补齐其余不应出网的内部/特殊网段
|
||||
var extraPrivateNets = mustParseCIDRs(
|
||||
"100.64.0.0/10", // CGNAT:运营商级 NAT、云内网、Tailscale 等
|
||||
@@ -99,7 +116,7 @@ func IsPrivateIP(ip net.IP) bool {
|
||||
// Used in handlers to short-circuit requests to internal hosts before making outbound connections.
|
||||
// 用于在发起出站连接前,在处理器中短路对内部主机的请求。
|
||||
func HasLocalOrPrivateIP(host string) bool {
|
||||
ips, err := net.LookupIP(host)
|
||||
ips, err := resolveHostFn(context.Background(), host)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -142,7 +159,7 @@ func ValidateOutboundTarget(ctx context.Context, targetURL string) (context.Cont
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return ctx, fmt.Errorf("invalid scheme: %s", parsed.Scheme)
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
ips, err := resolveHostFn(ctx, host)
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
@@ -179,7 +196,7 @@ func SecureCheckRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("stopped after 10 redirects")
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
ips, err := resolveHostFn(req.Context(), host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,17 +3,21 @@ package webtest
|
||||
// EO(EdgeOne)特供版的 DNS 模块:与主线 ipw-cn/src/webtest/dns.go 同源,
|
||||
// 但保留 EO 部署形态的两点差异:
|
||||
// 1. DoH / UDP 双通道互备(单地址配置下主通道失败自动回退另一通道);
|
||||
// 2. 查询耗时沿调用链透出(float64 毫秒),ResolveIP 供 tcping / speed 复用。
|
||||
// 主线对 dns.go 的行为修正(Rcode 透出、服务器地址格式校验)已同步至此。
|
||||
// 2. 查询耗时沿调用链透出(float64 毫秒),供上层(tcping 的 ResolveHostWith 等)复用。
|
||||
// 主线对 dns.go 的行为修正与新增能力已同步至此:Rcode 透出、服务器地址格式校验、
|
||||
// 请求级解析配置(dns=/dns4=/dns6=、resolve=/resolve4=/resolve6=,经 context 透传)、
|
||||
// ResolveByType 统一分发、各 Resolve*Record 的 servers 参数化。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -34,25 +38,14 @@ var dnsMode = "doh"
|
||||
// dnsServers 普通 DNS 查询服务器列表(主从:第一个为主,主失败自动切换后续从服务器)
|
||||
var dnsServers []string
|
||||
|
||||
// dnssecServers DNSSEC 专用服务器列表(未配置时沿用 dnsServers)
|
||||
var dnssecServers []string
|
||||
// dnssecServers DNSSEC 专用服务器列表:默认内置实测支持 DO 位的境外解析器
|
||||
//(国内公共 DNS 带 DO 也不返回 RRSIG,普通解析回退会让验签永远失败,见 dnssec.go)。
|
||||
// 网络屏蔽/劫持这些 UDP 53 时请配置 dnssec-server 指向可达的解析器。
|
||||
var dnssecServers = defaultDOCapableServers
|
||||
|
||||
// defaultUDPServer 兜底 UDP 服务器
|
||||
const defaultUDPServer = "119.28.28.28:53"
|
||||
|
||||
// DNSResult 统一的 DNS 查询结果格式。
|
||||
//
|
||||
// Rcode / RcodeText 与主线及各节点保持一致:解析器明确应答 NXDOMAIN 时,属于「域名不存在」
|
||||
// 这个业务结论,而不是节点故障 —— 前端据此区分两者,别把负响应误显示成拨测失败。
|
||||
type DNSResult struct {
|
||||
Domain string `json:"domain"`
|
||||
Duration float64 `json:"duration"`
|
||||
Record []string `json:"record"`
|
||||
TTL uint32 `json:"ttl"`
|
||||
Rcode int `json:"rcode"` // 解析器应答的 Rcode(0=Success, 3=NXDOMAIN)
|
||||
RcodeText string `json:"rcode_text"` // Rcode 可读名称,如 NXDOMAIN / SERVFAIL
|
||||
}
|
||||
|
||||
// SetDNSServer 设置 DNS 服务器:支持逗号分隔多地址主从 failover(第一个为主,主失败自动切换后续从服务器)。
|
||||
// 每项支持 "ip:port"(UDP)或 DoH URL http(s)://...;单地址时保持原有语义。
|
||||
func SetDNSServer(server string) {
|
||||
@@ -100,7 +93,10 @@ func splitServers(s string) []string {
|
||||
}
|
||||
|
||||
// validServers 过滤掉格式非法的地址(保留原顺序)。
|
||||
// 全部非法时返回空列表,调用方据此保留原有配置。
|
||||
//
|
||||
// dns-server / dnssec-server 既能由本地管理面 PATCH 改,也能由远端托管配置下发 —— 都是外部可控输入。
|
||||
// 在这里先挡掉畸形值,总比留到查询时才以"莫名其妙的超时/报错"暴露好排查。
|
||||
// 全部非法时返回空列表,调用方据此保留原有配置(与"空串不覆盖"的既有语义一致)。
|
||||
func validServers(list []string) []string {
|
||||
out := make([]string, 0, len(list))
|
||||
for _, s := range list {
|
||||
@@ -143,6 +139,227 @@ func validateServerAddr(server string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeServers 解析「逗号分隔的 DNS 服务器」字符串并做格式校验,规则与配置侧一致:
|
||||
// 每项为 "ip:port"(UDP/TCP)或 http(s):// DoH URL;非法项丢弃(记 Warn)。全非法时返回空。
|
||||
// 供请求级参数(dns / dns4 / dns6)复用,避免两套校验漂移。
|
||||
func NormalizeServers(s string) []string {
|
||||
return validServers(splitServers(s))
|
||||
}
|
||||
|
||||
// ---- 请求级解析配置:dns*/resolve* ----
|
||||
//
|
||||
// dns=1.1.1.1:53 两栈都用这个服务器(换服务器去问)
|
||||
// dns4=1.1.1.1:53,dns6=[2400::1]:53 按栈覆盖(优先于 dns=)
|
||||
// resolve=example.com:1.2.3.4 两栈都把 example.com 解析成 1.2.3.4(直接改答案)
|
||||
// resolve4=example.com:1.2.3.4 只 IPv4 侧生效(优先于 resolve=)
|
||||
// resolve6=example.com:[::1] 只 IPv6 侧生效(优先于 resolve=)
|
||||
// resolve=www.example.com:origin.net 目标是域名时把它当作新的 host 再解析
|
||||
//
|
||||
// 只在「域名 → IP」这一步生效:detail/ssl/speed(经 DialContext)、tcping、SSRF 预检;
|
||||
// whois 与 dns 拨测不接 resolve*(前者只做归属查询,后者问的就是真实解析)。
|
||||
//
|
||||
// 为什么走 context:HTTP 拨测的解析发生在 net/http 的 DialContext 里,那里只有 ctx、
|
||||
// 没有直接入参;存进 ctx 后 DialContext 与 SSRF 预检便能同源读到同一份配置。
|
||||
|
||||
// ReqResolve 一次拨测的请求级解析配置。零值 = 没有任何请求级覆盖。
|
||||
type ReqResolve struct {
|
||||
dns []string // dns=
|
||||
dns4 []string // dns4=
|
||||
dns6 []string // dns6=
|
||||
|
||||
resolve map[string]string // resolve=:域名 → IP 或另一域名
|
||||
resolve4 map[string]string // resolve4=
|
||||
resolve6 map[string]string // resolve6=
|
||||
}
|
||||
|
||||
// ParseReqResolve 从 query 解析全部 9 个参数(dns/dns4/dns6、resolve/resolve4/resolve6)。
|
||||
// 参数存在但**全部项非法**时报错(handler 返 400),避免「看起来指定了、实际被静默忽略」;
|
||||
// 缺省/为空则视为不覆盖。
|
||||
func ParseReqResolve(get func(string) string) (ReqResolve, error) {
|
||||
var out ReqResolve
|
||||
// DNS 服务器:逗号分隔的 ip:port 或 DoH URL
|
||||
for _, item := range []struct {
|
||||
key string
|
||||
dst *[]string
|
||||
}{
|
||||
{"dns", &out.dns}, {"dns4", &out.dns4}, {"dns6", &out.dns6},
|
||||
} {
|
||||
if raw := strings.TrimSpace(get(item.key)); raw != "" {
|
||||
if list := NormalizeServers(raw); len(list) > 0 {
|
||||
*item.dst = list
|
||||
} else {
|
||||
return ReqResolve{}, fmt.Errorf("%s must be a comma-separated list of ip:port or DoH URLs", item.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 强制解析:逗号分隔的 域名:IP 或 域名:域名(后者按新 host 再解析)
|
||||
for _, item := range []struct {
|
||||
key string
|
||||
dst *map[string]string
|
||||
}{
|
||||
{"resolve", &out.resolve}, {"resolve4", &out.resolve4}, {"resolve6", &out.resolve6},
|
||||
} {
|
||||
if raw := strings.TrimSpace(get(item.key)); raw != "" {
|
||||
if m := parseResolveMap(raw); len(m) > 0 {
|
||||
*item.dst = m
|
||||
} else {
|
||||
return ReqResolve{}, fmt.Errorf("%s must be a comma-separated list of host:ip or host:cname", item.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseResolveMap 把「域名:目标,域名:目标」切成映射表,非法项丢弃。
|
||||
// 每组按**第一个冒号**切:域名不含冒号,IPv6 目标可带或不带方括号。
|
||||
func parseResolveMap(s string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, item := range strings.Split(s, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
idx := strings.Index(item, ":")
|
||||
if idx <= 0 || idx == len(item)-1 {
|
||||
continue
|
||||
}
|
||||
host, target := canonicalHost(item[:idx]), strings.TrimSpace(item[idx+1:])
|
||||
if host == "" || strings.ContainsAny(host, " /\\") || !validResolveTarget(target) {
|
||||
continue
|
||||
}
|
||||
out[host] = target
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// canonicalHost 归一化主机名:去首尾空白、转小写、去结尾的点,便于与查询域名匹配。
|
||||
func canonicalHost(host string) string {
|
||||
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
}
|
||||
|
||||
// validResolveTarget 目标必须是 IP 或形如域名的字符串(含点、无空格、无端口)。
|
||||
func validResolveTarget(target string) bool {
|
||||
t := strings.Trim(target, "[]")
|
||||
if net.ParseIP(t) != nil {
|
||||
return true
|
||||
}
|
||||
// 非 IP 却含冒号:视为「带端口」的误用,丢弃(映射不需要端口)
|
||||
if strings.Contains(t, ":") || !strings.Contains(t, ".") || strings.ContainsAny(t, " /\\") {
|
||||
return false
|
||||
}
|
||||
for _, r := range t {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' ||
|
||||
r == '.' || r == '-' || r == '_' || r == '*' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Empty 无任何请求级覆盖(此时才允许走共享缓存)。
|
||||
func (r ReqResolve) Empty() bool {
|
||||
return len(r.dns)+len(r.dns4)+len(r.dns6)+len(r.resolve)+len(r.resolve4)+len(r.resolve6) == 0
|
||||
}
|
||||
|
||||
// Servers 按栈名取本次生效的 DNS 服务器:stack 为 "ipv4"/"ipv6" 时优先取对应覆盖,
|
||||
// 其余(含空串)取 dns= 默认;最终为 nil 表示用配置 dns-server。
|
||||
// 供 whois、dns 拨测等按记录类型选栈的调用方使用。
|
||||
func (r ReqResolve) Servers(stack string) []string {
|
||||
switch stack {
|
||||
case "ipv4":
|
||||
if len(r.dns4) > 0 {
|
||||
return r.dns4
|
||||
}
|
||||
case "ipv6":
|
||||
if len(r.dns6) > 0 {
|
||||
return r.dns6
|
||||
}
|
||||
}
|
||||
return r.dns
|
||||
}
|
||||
|
||||
// Override 返回该域名在指定族下的强制解析目标;"" 表示未命中(照常走 DNS)。
|
||||
// 优先级与 dns* 对称:resolve4/resolve6 非空时覆盖 resolve。
|
||||
// 目标是 IP 还是域名由调用方判断——IP 直连、域名当作新 host 再解析。
|
||||
func (r ReqResolve) Override(host string, wantV6 bool) string {
|
||||
m := r.resolve
|
||||
if wantV6 && len(r.resolve6) > 0 {
|
||||
m = r.resolve6
|
||||
} else if !wantV6 && len(r.resolve4) > 0 {
|
||||
m = r.resolve4
|
||||
}
|
||||
return m[canonicalHost(host)]
|
||||
}
|
||||
|
||||
// CacheKey 返回请求级配置的规范化片段,拼在拨测缓存键末尾。
|
||||
// 零值返回空串(缓存键与「没有任何请求级参数」时完全一致);
|
||||
// 否则形如 ";dns=1.1.1.1:53,8.8.8.8:53;resolve6=example.com:[::1]"。
|
||||
//
|
||||
// 逐字段拼接而不是 join 结构体:字段名与顺序由这里固定,避免参数书写顺序
|
||||
// (dns= 在前还是 dns6= 在前)把同一个请求拆成两条缓存。
|
||||
func (r ReqResolve) CacheKey() string {
|
||||
var b strings.Builder
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
vals []string
|
||||
}{
|
||||
{"dns", r.dns}, {"dns4", r.dns4}, {"dns6", r.dns6},
|
||||
} {
|
||||
if len(f.vals) == 0 {
|
||||
continue
|
||||
}
|
||||
b.WriteString(";")
|
||||
b.WriteString(f.name)
|
||||
b.WriteString("=")
|
||||
b.WriteString(strings.Join(f.vals, ","))
|
||||
}
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
vals map[string]string
|
||||
}{
|
||||
{"resolve", r.resolve}, {"resolve4", r.resolve4}, {"resolve6", r.resolve6},
|
||||
} {
|
||||
if len(f.vals) == 0 {
|
||||
continue
|
||||
}
|
||||
// map 迭代顺序随机:必须按键排序,否则同一请求每次拼出的键都不同,缓存永远命不中
|
||||
keys := make([]string, 0, len(f.vals))
|
||||
for k := range f.vals {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
pairs := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
pairs = append(pairs, k+":"+f.vals[k])
|
||||
}
|
||||
b.WriteString(";")
|
||||
b.WriteString(f.name)
|
||||
b.WriteString("=")
|
||||
b.WriteString(strings.Join(pairs, ","))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// reqResolveCtxKey 请求级解析配置在 context 中的键。
|
||||
type reqResolveCtxKey struct{}
|
||||
|
||||
// WithReqResolve 把请求级解析配置写进 context(空配置原样返回,零开销)。
|
||||
func WithReqResolve(ctx context.Context, r ReqResolve) context.Context {
|
||||
if r.Empty() {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, reqResolveCtxKey{}, r)
|
||||
}
|
||||
|
||||
// ReqResolveFromContext 取出请求级解析配置(零值 = 无覆盖)。
|
||||
func ReqResolveFromContext(ctx context.Context) ReqResolve {
|
||||
if ctx == nil {
|
||||
return ReqResolve{}
|
||||
}
|
||||
if v, ok := ctx.Value(reqResolveCtxKey{}).(ReqResolve); ok {
|
||||
return v
|
||||
}
|
||||
return ReqResolve{}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ⭐️ 底层核心:DoH + UDP 双通道 DNS 查询
|
||||
// ==========================================
|
||||
@@ -180,6 +397,15 @@ func queryDNSSECMsg(msg *dns.Msg) (*dns.Msg, float64, error) {
|
||||
return queryDNSMsg(msg)
|
||||
}
|
||||
|
||||
// queryOne 向单个 DNS 服务器查询:配置为 URL(http/https)走 DoH,否则走 UDP
|
||||
//(DNSSEC 的 DO 能力探测按服务器逐台试探,需要单发入口)
|
||||
func queryOne(msg *dns.Msg, server string) (*dns.Msg, float64, error) {
|
||||
if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
|
||||
return queryDoHMsg(msg, server)
|
||||
}
|
||||
return queryUDPMsg(msg, server)
|
||||
}
|
||||
|
||||
// queryWithServers 依次尝试服务器列表,主失败切从;每个服务器按类型走 DoH 或 UDP
|
||||
func queryWithServers(msg *dns.Msg, servers []string) (*dns.Msg, float64, error) {
|
||||
if len(servers) == 0 {
|
||||
@@ -187,14 +413,7 @@ func queryWithServers(msg *dns.Msg, servers []string) (*dns.Msg, float64, error)
|
||||
}
|
||||
var lastErr error
|
||||
for i, srv := range servers {
|
||||
var resp *dns.Msg
|
||||
var dur float64
|
||||
var err error
|
||||
if strings.HasPrefix(srv, "http://") || strings.HasPrefix(srv, "https://") {
|
||||
resp, dur, err = queryDoHMsg(msg, srv)
|
||||
} else {
|
||||
resp, dur, err = queryUDPMsg(msg, srv)
|
||||
}
|
||||
resp, dur, err := queryOne(msg, srv)
|
||||
if err == nil {
|
||||
return resp, dur, nil
|
||||
}
|
||||
@@ -275,13 +494,41 @@ func executeDoHQuery(domain string, qtype uint16) (*dns.Msg, float64, error) {
|
||||
return queryDNSMsg(msg)
|
||||
}
|
||||
|
||||
// DNSResult 统一的 DNS 查询结果格式。
|
||||
//
|
||||
// Rcode / RcodeText 与主线及各节点保持一致:解析器明确应答 NXDOMAIN 时,属于「域名不存在」
|
||||
// 这个业务结论,而不是节点故障 —— 前端据此区分两者,别把负响应误显示成拨测失败。
|
||||
type DNSResult struct {
|
||||
Domain string `json:"domain"`
|
||||
Duration float64 `json:"duration"`
|
||||
Record []string `json:"record"`
|
||||
TTL uint32 `json:"ttl"`
|
||||
Rcode int `json:"rcode"` // 解析器应答的 Rcode(0=Success, 3=NXDOMAIN)
|
||||
RcodeText string `json:"rcode_text"` // Rcode 可读名称,如 NXDOMAIN / SERVFAIL
|
||||
}
|
||||
|
||||
// resolveTyped 通用记录查询:成功收到解析器应答(即便 Rcode 非 Success,如 NXDOMAIN/SERVFAIL)
|
||||
// 即视为一次有效查询,将 Rcode 透出并返回 200;仅在传输/网络层失败(超时、服务器不可达、
|
||||
// DoH 异常)时才返回 error(上层据此返回 500)。避免把合法的负响应误判为节点故障。
|
||||
// extract 从一个 RR 中抽取记录字符串(TXT 可能含多条),返回 (values, ttl, matched)。
|
||||
func resolveTyped(domain, name string, qtype uint16, extract func(dns.RR) ([]string, uint32, bool)) (DNSResult, error) {
|
||||
// servers 为请求级 DNS 覆盖:非空时用它(queryWithServers),为空则用配置 dns-server
|
||||
//(EO:配置列表也为空时回退 DoH/UDP 双通道互备,见 queryDNSMsg)。
|
||||
func resolveTyped(domain, name string, qtype uint16, servers []string, extract func(dns.RR) ([]string, uint32, bool)) (DNSResult, error) {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(name), qtype)
|
||||
// EO 保留:主线不显式设 RD;EO 双通道历史上始终请求递归解析,改动会影响各边缘节点
|
||||
msg.RecursionDesired = true
|
||||
result := DNSResult{Domain: domain, Record: []string{}}
|
||||
response, duration, err := executeDoHQuery(name, qtype)
|
||||
|
||||
// 请求级 servers 优先;未传(nil/空)走配置 dns-server。
|
||||
var response *dns.Msg
|
||||
var duration float64
|
||||
var err error
|
||||
if len(servers) > 0 {
|
||||
response, duration, err = queryWithServers(msg, servers)
|
||||
} else {
|
||||
response, duration, err = queryDNSMsg(msg)
|
||||
}
|
||||
result.Duration = duration
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query DNS", "domain", domain, "qtype", qtype, "error", err)
|
||||
@@ -302,8 +549,13 @@ func resolveTyped(domain, name string, qtype uint16, extract func(dns.RR) ([]str
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ResolveARecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeA, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
// ---- 记录解析:每类一个函数,末个参数 servers 为「请求级 DNS 覆盖」 ----
|
||||
//
|
||||
// servers 为空 → 走 resolveTyped 的 queryDNSMsg(配置 dns-server,EO 未配置时双通道互备);
|
||||
// 非空 → queryWithServers(请求级覆盖)。
|
||||
|
||||
func ResolveARecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeA, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if a, ok := rr.(*dns.A); ok {
|
||||
return []string{a.A.String()}, a.Header().Ttl, true
|
||||
}
|
||||
@@ -311,8 +563,8 @@ func ResolveARecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveAAAARecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeAAAA, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveAAAARecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeAAAA, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if a, ok := rr.(*dns.AAAA); ok {
|
||||
return []string{a.AAAA.String()}, a.Header().Ttl, true
|
||||
}
|
||||
@@ -320,8 +572,8 @@ func ResolveAAAARecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveTXTRecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeTXT, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveTXTRecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeTXT, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if txt, ok := rr.(*dns.TXT); ok {
|
||||
// TXT 一条 RR 可能含多段字符串,保持原有「逐段单独成项」的行为
|
||||
return txt.Txt, txt.Header().Ttl, true
|
||||
@@ -330,8 +582,8 @@ func ResolveTXTRecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveNSRecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeNS, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveNSRecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeNS, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if ns, ok := rr.(*dns.NS); ok {
|
||||
return []string{ns.Ns}, ns.Header().Ttl, true
|
||||
}
|
||||
@@ -339,8 +591,8 @@ func ResolveNSRecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveCNAMERecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeCNAME, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveCNAMERecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeCNAME, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if cname, ok := rr.(*dns.CNAME); ok {
|
||||
return []string{cname.Target}, cname.Header().Ttl, true
|
||||
}
|
||||
@@ -348,8 +600,8 @@ func ResolveCNAMERecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveMXRecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeMX, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveMXRecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeMX, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if mx, ok := rr.(*dns.MX); ok {
|
||||
return []string{mx.Mx}, mx.Header().Ttl, true
|
||||
}
|
||||
@@ -357,8 +609,8 @@ func ResolveMXRecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveSRVRecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeSRV, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveSRVRecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeSRV, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if srv, ok := rr.(*dns.SRV); ok {
|
||||
return []string{srv.Target}, srv.Header().Ttl, true
|
||||
}
|
||||
@@ -366,8 +618,8 @@ func ResolveSRVRecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveCAARecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeCAA, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveCAARecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeCAA, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if caa, ok := rr.(*dns.CAA); ok {
|
||||
return []string{caa.Value}, caa.Header().Ttl, true
|
||||
}
|
||||
@@ -376,13 +628,13 @@ func ResolveCAARecord(domain string) (DNSResult, error) {
|
||||
}
|
||||
|
||||
// ResolvePTRRecord 反查 IP 的 PTR 记录(返回的 Domain 保持为原始 IP,便于前端直接展示)
|
||||
func ResolvePTRRecord(ip string) (DNSResult, error) {
|
||||
func ResolvePTRRecord(ip string, servers []string) (DNSResult, error) {
|
||||
ptrName, err := dns.ReverseAddr(ip)
|
||||
if err != nil {
|
||||
slog.Warn("Invalid IP address for PTR query", "ip", ip, "error", err)
|
||||
return DNSResult{Domain: ip, Record: []string{}}, fmt.Errorf("invalid IP address: %v", err)
|
||||
}
|
||||
return resolveTyped(ip, ptrName, dns.TypePTR, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
return resolveTyped(ip, ptrName, dns.TypePTR, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if ptr, ok := rr.(*dns.PTR); ok {
|
||||
return []string{ptr.Ptr}, ptr.Header().Ttl, true
|
||||
}
|
||||
@@ -390,46 +642,30 @@ func ResolvePTRRecord(ip string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// ResolveIP 通过双通道解析域名,返回指定版本(v4/v6)的 IP 地址字符串。
|
||||
// EO 特供:供 tcping / speed 等需要"先把 host 归一化成 IP"的拨测复用。
|
||||
func ResolveIP(host string, version string) (string, error) {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if version == "v4" && ip.To4() != nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
if version == "v6" && ip.To4() == nil && ip.To16() != nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("no %s address found for %s", version, host)
|
||||
// ResolveByType 按记录类型名(a/aaaa/cname/mx/ns/txt/srv/ptr/caa)解析,servers 为请求级覆盖。
|
||||
// DNS 拨测的 HTTP 路由统一走这里,与主线保持同构,避免 handler 各维护一套 switch。
|
||||
func ResolveByType(domain, recodeType string, servers []string) (DNSResult, error) {
|
||||
switch recodeType {
|
||||
case "a":
|
||||
return ResolveARecord(domain, servers)
|
||||
case "aaaa":
|
||||
return ResolveAAAARecord(domain, servers)
|
||||
case "cname":
|
||||
return ResolveCNAMERecord(domain, servers)
|
||||
case "mx":
|
||||
return ResolveMXRecord(domain, servers)
|
||||
case "ns":
|
||||
return ResolveNSRecord(domain, servers)
|
||||
case "srv":
|
||||
return ResolveSRVRecord(domain, servers)
|
||||
case "txt":
|
||||
return ResolveTXTRecord(domain, servers)
|
||||
case "ptr":
|
||||
return ResolvePTRRecord(domain, servers)
|
||||
case "caa":
|
||||
return ResolveCAARecord(domain, servers)
|
||||
}
|
||||
|
||||
var qtype uint16
|
||||
switch version {
|
||||
case "v6":
|
||||
qtype = dns.TypeAAAA
|
||||
default:
|
||||
qtype = dns.TypeA
|
||||
}
|
||||
|
||||
responseMsg, _, err := executeDoHQuery(host, qtype)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, ans := range responseMsg.Answer {
|
||||
switch v := ans.(type) {
|
||||
case *dns.A:
|
||||
if qtype == dns.TypeA {
|
||||
return v.A.String(), nil
|
||||
}
|
||||
case *dns.AAAA:
|
||||
if qtype == dns.TypeAAAA {
|
||||
return v.AAAA.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no %s record found for %s", version, host)
|
||||
return DNSResult{}, fmt.Errorf("unsupported record type: %s", recodeType)
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@@ -448,19 +684,21 @@ type DNSFullResult struct {
|
||||
CAA DNSResult `json:"caa"`
|
||||
}
|
||||
|
||||
func ResolveARecordllDNSRecords(domain string) DNSFullResult {
|
||||
// ResolveARecordllDNSRecords 并行查询所有主流DNS记录类型(不包含PTR)。
|
||||
// servers 为请求级 DNS 覆盖(空则用配置 dns-server)。
|
||||
func ResolveARecordllDNSRecords(domain string, servers []string) DNSFullResult {
|
||||
result := DNSFullResult{Domain: domain}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(8)
|
||||
|
||||
go func() { defer wg.Done(); result.A, _ = ResolveARecord(domain) }()
|
||||
go func() { defer wg.Done(); result.AAAA, _ = ResolveAAAARecord(domain) }()
|
||||
go func() { defer wg.Done(); result.CNAME, _ = ResolveCNAMERecord(domain) }()
|
||||
go func() { defer wg.Done(); result.MX, _ = ResolveMXRecord(domain) }()
|
||||
go func() { defer wg.Done(); result.NS, _ = ResolveNSRecord(domain) }()
|
||||
go func() { defer wg.Done(); result.TXT, _ = ResolveTXTRecord(domain) }()
|
||||
go func() { defer wg.Done(); result.SRV, _ = ResolveSRVRecord(domain) }()
|
||||
go func() { defer wg.Done(); result.CAA, _ = ResolveCAARecord(domain) }()
|
||||
go func() { defer wg.Done(); result.A, _ = ResolveARecord(domain, servers) }()
|
||||
go func() { defer wg.Done(); result.AAAA, _ = ResolveAAAARecord(domain, servers) }()
|
||||
go func() { defer wg.Done(); result.CNAME, _ = ResolveCNAMERecord(domain, servers) }()
|
||||
go func() { defer wg.Done(); result.MX, _ = ResolveMXRecord(domain, servers) }()
|
||||
go func() { defer wg.Done(); result.NS, _ = ResolveNSRecord(domain, servers) }()
|
||||
go func() { defer wg.Done(); result.TXT, _ = ResolveTXTRecord(domain, servers) }()
|
||||
go func() { defer wg.Done(); result.SRV, _ = ResolveSRVRecord(domain, servers) }()
|
||||
go func() { defer wg.Done(); result.CAA, _ = ResolveCAARecord(domain, servers) }()
|
||||
|
||||
wg.Wait()
|
||||
return result
|
||||
|
||||
@@ -3,24 +3,54 @@ package webtest
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// —— DNSSEC 默认解析服务器(与主线 ipw-cn/src/webtest/dnssec.go 同源)——
|
||||
//
|
||||
// 根因(实测):国内公共解析器(DNSPod 119.28.28.28、doh.pub、AliDNS 223.5.5.5、114DNS)
|
||||
// 即使查询带 DO 位也不在应答里返回 RRSIG——DNSKEY 能查到、RRSIG 永远缺席。而
|
||||
// dnssec 查询若回退到 dns-server(默认 doh.pub 同族,国内递归),验签流程永远失败。
|
||||
// 所以 DNSSEC 的默认服务器直接内置这份实测支持 DO 位的境外列表,不再回退到国内
|
||||
// 解析器;境内网络若屏蔽/劫持了这些 UDP 53,请在节点配置 dnssec-server。
|
||||
//
|
||||
// 顺序即主从顺序,v4/v6 交错(同厂相邻):单栈节点上错误协议族的拨号是
|
||||
// "network unreachable" 秒级失败,failover 到下一台几乎零成本,双栈节点则
|
||||
// 自动绕过被劫持/丢包的那一族。国内家宽实测(江苏移动 v4)8.8.8.8 主 IP 存在
|
||||
// 随机丢包,1.1.1.1 更稳故放首位。注意境外 DoH(443)在国内普遍被阻断,全部走 53。
|
||||
var defaultDOCapableServers = []string{
|
||||
"1.1.1.1:53", // Cloudflare v4
|
||||
"[2606:4700:4700::1111]:53", // Cloudflare v6
|
||||
"8.8.8.8:53", // Google v4
|
||||
"[2001:4860:4860::8888]:53", // Google v6
|
||||
"8.8.4.4:53", // Google 2 v4
|
||||
"[2001:4860:4860::8844]:53", // Google 2 v6
|
||||
"9.9.9.9:53", // Quad9 v4
|
||||
"[2620:fe::fe]:53", // Quad9 v6
|
||||
"208.67.222.222:53", // OpenDNS v4
|
||||
"[2620:119:35::35]:53", // OpenDNS v6
|
||||
"208.67.220.220:53", // OpenDNS 2 v4
|
||||
"149.112.112.112:53", // Quad9 2 v4
|
||||
"[2620:fe::9]:53", // Quad9 2 v6
|
||||
"[2620:119:53::53]:53", // OpenDNS 2 v6
|
||||
}
|
||||
|
||||
// DNSSECResult 包含 DNSSEC 验证的结果
|
||||
type DNSSECResult struct {
|
||||
Domain string `json:"domain"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Valid bool `json:"valid"`
|
||||
HasRRSIG bool `json:"has_rrsig"`
|
||||
HasDNSKEY bool `json:"has_dnskey"`
|
||||
HasDS bool `json:"has_ds"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
KeyTag uint16 `json:"key_tag"`
|
||||
SignerName string `json:"signer_name"`
|
||||
Validation string `json:"validation"`
|
||||
Duration float64 `json:"duration"`
|
||||
Domain string `json:"domain"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Valid bool `json:"valid"`
|
||||
HasRRSIG bool `json:"has_rrsig"`
|
||||
HasDNSKEY bool `json:"has_dnskey"`
|
||||
HasDS bool `json:"has_ds"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
KeyTag uint16 `json:"key_tag"`
|
||||
SignerName string `json:"signer_name"`
|
||||
Validation string `json:"validation"`
|
||||
Duration float64 `json:"duration"`
|
||||
}
|
||||
|
||||
// ResolveDNSSEC 执行 DNSSEC 验证
|
||||
@@ -56,26 +86,65 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
|
||||
var dnskeyRRset []dns.RR
|
||||
var dnskeyList []*dns.DNSKEY
|
||||
// DO 位生效时,DNSKEY 应答自带 RRSIG(DNSKEY)——已签名域名的确定信号,
|
||||
// 也是 TLD / 无 A 记录域名唯一的验签素材(它们没有 A 应答可拿 RRSIG(A))
|
||||
var rrsigDNSKEYList []*dns.RRSIG
|
||||
for _, ans := range responseDNSKEY.Answer {
|
||||
if dnskey, ok := ans.(*dns.DNSKEY); ok {
|
||||
dnskeyRRset = append(dnskeyRRset, dnskey)
|
||||
dnskeyList = append(dnskeyList, dnskey)
|
||||
switch v := ans.(type) {
|
||||
case *dns.DNSKEY:
|
||||
dnskeyRRset = append(dnskeyRRset, v)
|
||||
dnskeyList = append(dnskeyList, v)
|
||||
result.HasDNSKEY = true
|
||||
case *dns.RRSIG:
|
||||
rrsigDNSKEYList = append(rrsigDNSKEYList, v)
|
||||
}
|
||||
}
|
||||
if len(rrsigDNSKEYList) > 0 {
|
||||
result.HasRRSIG = true
|
||||
}
|
||||
|
||||
if len(dnskeyRRset) > 0 {
|
||||
result.KeyTag = dnskeyList[0].KeyTag()
|
||||
result.Algorithm = dnskeyList[0].Algorithm
|
||||
}
|
||||
|
||||
// 2. 查询 A 记录(带 DO 位)
|
||||
// 2. A 与 DS 并行查询。DS 记录在父区(如 .com),**任何**验签结论(通过或失败)
|
||||
// 都应带上它——原实现把 DS 查询放在函数最后,验签成功时提前 return,
|
||||
// has_ds 永远是 false(只有验证失败的路径才查得到 DS)。
|
||||
msgA := new(dns.Msg)
|
||||
msgA.SetQuestion(dns.Fqdn(domain), dns.TypeA)
|
||||
msgA.SetEdns0(4096, true)
|
||||
msgDS := new(dns.Msg)
|
||||
msgDS.SetQuestion(dns.Fqdn(domain), dns.TypeDS)
|
||||
msgDS.SetEdns0(4096, true)
|
||||
|
||||
responseA, _, err := executeDoHQueryMsg(msgA)
|
||||
if err == nil && responseA.Rcode == dns.RcodeSuccess {
|
||||
// rrsigList 提升到函数作用域:A 应答里的 RRSIG(A) 在「A 验签失败」的最终判定里也要用
|
||||
var rrsigList []*dns.RRSIG
|
||||
var responseA *dns.Msg
|
||||
var responseDS *dns.Msg
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
responseA, _, err = executeDoHQueryMsg(msgA)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
responseDS, _, _ = executeDoHQueryMsg(msgDS)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
// DS 结论先于一切 early return 就绪
|
||||
if responseDS != nil && responseDS.Rcode == dns.RcodeSuccess {
|
||||
for _, ans := range responseDS.Answer {
|
||||
if _, ok := ans.(*dns.DS); ok {
|
||||
result.HasDS = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil && responseA != nil && responseA.Rcode == dns.RcodeSuccess {
|
||||
var aRRset []dns.RR
|
||||
for _, ans := range responseA.Answer {
|
||||
if aRecord, ok := ans.(*dns.A); ok {
|
||||
@@ -83,7 +152,6 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
var rrsigList []*dns.RRSIG
|
||||
for _, ans := range responseA.Answer {
|
||||
if rrsig, ok := ans.(*dns.RRSIG); ok {
|
||||
rrsigList = append(rrsigList, rrsig)
|
||||
@@ -91,7 +159,8 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 用 DNSKEY 逐条验证 RRSIG
|
||||
// 3. 用 DNSKEY 逐条验证 A 的 RRSIG。不在验证失败时提前返回:
|
||||
// 下面还有 DNSKEY 自验签兜底
|
||||
for _, rrsig := range rrsigList {
|
||||
for _, dnskey := range dnskeyList {
|
||||
if keyErr := rrsig.Verify(dnskey, aRRset); keyErr == nil {
|
||||
@@ -105,29 +174,37 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(rrsigList) > 0 && len(dnskeyRRset) > 0 {
|
||||
result.Enabled = true
|
||||
result.Valid = false
|
||||
result.Validation = fmt.Sprintf("RRSIG 验证失败: %d 条 RRSIG, %d 个 DNSKEY,无匹配签名", len(rrsigList), len(dnskeyRRset))
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 查询 DS 记录
|
||||
msgDS := new(dns.Msg)
|
||||
msgDS.SetQuestion(dns.Fqdn(domain), dns.TypeDS)
|
||||
|
||||
responseDS, _, _ := executeDoHQueryMsg(msgDS)
|
||||
if responseDS != nil && responseDS.Rcode == dns.RcodeSuccess {
|
||||
for _, ans := range responseDS.Answer {
|
||||
if _, ok := ans.(*dns.DS); ok {
|
||||
result.HasDS = true
|
||||
break
|
||||
// 3.5 DNSKEY 自验签:用 DNSKEY 验证 DNSKEY 自己的 RRSIG(RFC 4035 §5.3.3 的
|
||||
// 自签检查)。TLD(如 cn)与无 A 记录的已签名域拿不到 RRSIG(A),这是它们的
|
||||
// 唯一验签路径;有 A 记录但 A 验签失败的域走到这里仍失败才算真失败。
|
||||
if len(rrsigDNSKEYList) > 0 && len(dnskeyRRset) > 0 {
|
||||
for _, rrsig := range rrsigDNSKEYList {
|
||||
for _, dnskey := range dnskeyList {
|
||||
if keyErr := rrsig.Verify(dnskey, dnskeyRRset); keyErr == nil {
|
||||
result.Enabled = true
|
||||
result.Valid = true
|
||||
result.Algorithm = dnskey.Algorithm
|
||||
result.KeyTag = dnskey.KeyTag()
|
||||
result.SignerName = rrsig.SignerName
|
||||
result.Validation = fmt.Sprintf("DNSSEC 验证通过 (DNSKEY 自验签, 算法: %d, KeyTag: %d)", dnskey.Algorithm, dnskey.KeyTag())
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A 验签与 DNSKEY 自验签都未通过:只要任一应答里有 RRSIG 就说明签名存在但无效
|
||||
//(真失败,如数据被污染/密钥轮换不完整),返回明确的失败结论
|
||||
if (len(rrsigList) > 0 || len(rrsigDNSKEYList) > 0) && len(dnskeyRRset) > 0 {
|
||||
result.Enabled = true
|
||||
result.Valid = false
|
||||
result.Validation = fmt.Sprintf("RRSIG 验证失败: %d 条 RRSIG(A), %d 条 RRSIG(DNSKEY), %d 个 DNSKEY,无匹配签名", len(rrsigList), len(rrsigDNSKEYList), len(dnskeyRRset))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 4. 结论分支(DS 已在并行阶段查完并计入 HasDS)
|
||||
if result.HasRRSIG && result.HasDNSKEY {
|
||||
result.Enabled = true
|
||||
result.Valid = false
|
||||
@@ -139,7 +216,7 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
} else if result.HasDNSKEY {
|
||||
result.Enabled = false
|
||||
result.Valid = false
|
||||
result.Validation = "存在 DNSKEY,但缺少 RRSIG"
|
||||
result.Validation = "存在 DNSKEY,但缺少 RRSIG(若确认该域名已签名,说明默认境外解析器在本网络不可达,请在节点配置 dnssec-server 指向可达的 DNSSEC 解析器,如 8.8.8.8:53)"
|
||||
} else {
|
||||
result.Enabled = false
|
||||
result.Valid = false
|
||||
@@ -255,7 +332,7 @@ func ResolveDNSSECForRecord(domain string, recordType uint16) (*DNSSECResult, er
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// executeDoHQueryMsg 通过 queryDNSSECMsg(DNSSEC 专用 DNS,主从 failover;未配置专用则沿用 dns-server)发送 DNS 查询并返回解析后的响应消息
|
||||
// executeDoHQueryMsg 通过 queryDNSSECMsg(内置 DO 友好列表 / dnssec-server 配置,主从 failover)发送 DNS 查询并返回解析后的响应消息
|
||||
func executeDoHQueryMsg(msg *dns.Msg) (*dns.Msg, float64, error) {
|
||||
responseMsg, duration, err := queryDNSSECMsg(msg)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package webtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"edgeone-cloud-functions/ssrf"
|
||||
)
|
||||
|
||||
// InstallSSRFResolver 让 SSRF 出站校验走本包的自定义 DNS(含请求级覆盖)。
|
||||
// 必须在启动阶段调用一次(index.go 的 main)。
|
||||
//
|
||||
// 动机:不注入时 ssrf 用 net.LookupIP(系统解析器)做预检,而真正拨号走自定义 DNS —— 两套解析器,
|
||||
// 既多一次查询,结果也可能不一致;更关键的是,当调用方用请求级 dns= 指定了服务器、且目标域名只能
|
||||
// 靠该服务器解析时,系统解析器预检会先行失败,把请求直接毙掉,覆盖形同失效。
|
||||
//
|
||||
// 校验只需判定「是否私网」,故 A / AAAA 都查(任一命中私网即拦,口径与原先 net.LookupIP 一致)。
|
||||
// 请求级配置(DNS 服务器)取自 context,与真正拨号同源。
|
||||
func InstallSSRFResolver() {
|
||||
ssrf.SetResolveFunc(func(ctx context.Context, host string) ([]net.IP, error) {
|
||||
// 字面 IP 直接返回,无需解析
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return []net.IP{ip}, nil
|
||||
}
|
||||
|
||||
req := ReqResolveFromContext(ctx)
|
||||
|
||||
var ips []net.IP
|
||||
var firstErr error
|
||||
for _, wantV6 := range []bool{false, true} {
|
||||
// 强制解析命中时,预检要验「拨号真正会连的地址」,否则会拿原域名的
|
||||
// 真实解析去判定,预检与实际连接两个口径对不上(resolve= 也就白设了)。
|
||||
name := host
|
||||
if target := req.Override(name, wantV6); target != "" {
|
||||
name = strings.Trim(target, "[]")
|
||||
}
|
||||
if ip := net.ParseIP(name); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
continue
|
||||
}
|
||||
var res DNSResult
|
||||
var err error
|
||||
if wantV6 {
|
||||
res, err = ResolveAAAARecord(name, req.Servers("ipv6"))
|
||||
} else {
|
||||
res, err = ResolveARecord(name, req.Servers("ipv4"))
|
||||
}
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
for _, s := range res.Record {
|
||||
if ip := net.ParseIP(s); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 两栈都解析失败才报错;只要有一栈拿到 IP 就交给上层做私网判定
|
||||
if len(ips) == 0 && firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
return ips, nil
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edgeone-cloud-functions/ssrf"
|
||||
@@ -30,28 +31,89 @@ type TCPingStats struct {
|
||||
Results []TCPingResult `json:"results"`
|
||||
}
|
||||
|
||||
func resolveHost(host string, version string) (string, error) {
|
||||
return ResolveIP(host, version)
|
||||
// ResolveHost 解析 host 为指定版本(v4/v6)的 IP 地址字符串。
|
||||
// 字面 IP 不查 DNS,但仍要过私网校验(与 DNS 解析结果同口径)。
|
||||
func ResolveHost(host string, version string) (string, error) {
|
||||
return ResolveHostWith(host, version, ReqResolve{})
|
||||
}
|
||||
|
||||
// ResolveHostWith 同 ResolveHost,但带请求级解析配置(req 零值 = 用配置 DNS)。
|
||||
// 强制解析(resolve=)命中就把目标当作新的 host——IP 交给下面的字面 IP 分支,域名继续走 DNS。
|
||||
func ResolveHostWith(host string, version string, req ReqResolve) (string, error) {
|
||||
// 强制解析:命中就把目标当作新的 host——IP 交给下面的字面 IP 分支,域名继续走 DNS。
|
||||
if target := req.Override(host, version == "v6"); target != "" {
|
||||
host = strings.Trim(target, "[]")
|
||||
}
|
||||
cleanHost := strings.Trim(host, "[]")
|
||||
if ip := net.ParseIP(cleanHost); ip != nil {
|
||||
// SSRF:字面 IP 与 DNS 解析结果同口径,私网地址一律拒绝(tcping 是裸 TCP,
|
||||
// 不经过 DialContext,这里不做就完全没人拦)。放在族校验之前:安全判定
|
||||
// 优先于参数语义,也与 DialContext 里「先查私网、再拨号」的顺序一致。
|
||||
if ssrf.Enabled() && ssrf.IsPrivateIP(ip) {
|
||||
return "", fmt.Errorf("connection to private/internal address %s is not allowed", cleanHost)
|
||||
}
|
||||
switch version {
|
||||
case "v4":
|
||||
if ip.To4() != nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
case "v6":
|
||||
if ip.To4() == nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("host %s is not a %s address", host, version)
|
||||
}
|
||||
|
||||
var result DNSResult
|
||||
var err error
|
||||
if version == "v6" {
|
||||
result, err = ResolveAAAARecord(host, req.Servers("ipv6"))
|
||||
} else {
|
||||
result, err = ResolveARecord(host, req.Servers("ipv4"))
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("DNS lookup failed: %w", err)
|
||||
}
|
||||
if len(result.Record) == 0 {
|
||||
return "", fmt.Errorf("no %s address found for %s", version, host)
|
||||
}
|
||||
ipStr := result.Record[0]
|
||||
|
||||
if ssrf.Enabled() {
|
||||
if ip := net.ParseIP(ipStr); ip != nil && ssrf.IsPrivateIP(ip) {
|
||||
return "", fmt.Errorf("connection to private/internal address %s is not allowed", ipStr)
|
||||
}
|
||||
}
|
||||
return ipStr, nil
|
||||
}
|
||||
|
||||
// TCPing 执行单次 TCP 连接测试
|
||||
// 参数:
|
||||
// - host: 目标主机名或 IP 地址
|
||||
// - port: 目标端口号
|
||||
// - version: 协议版本,"v4" 或 "v6"
|
||||
// - timeout: 连接超时时间
|
||||
//
|
||||
// 返回 TCPingResult 包含连接结果和响应时间
|
||||
func TCPing(host string, port string, version string, timeout time.Duration) (*TCPingResult, error) {
|
||||
ip, err := resolveHost(host, version)
|
||||
ip, err := ResolveHost(host, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// SSRF 防护:内网/云元数据段(127.0.0.1、10.x、169.254.169.254 等)直接拒绝,
|
||||
// 否则 tcping 接口会被当作内网端口扫描器
|
||||
if ssrf.Enabled() {
|
||||
if pip := net.ParseIP(ip); pip != nil && ssrf.IsPrivateIP(pip) {
|
||||
return nil, fmt.Errorf("request to private/internal address is not allowed")
|
||||
}
|
||||
}
|
||||
return tcpingDial(ip, port, version, timeout), nil
|
||||
}
|
||||
|
||||
// tcpingDial 对已解析好的 IP 做一次 TCP 拨号,不再做 DNS 解析与 SSRF 校验。
|
||||
// 拆出来是为了让 TCPingRun 的多次探测复用同一个解析结果:原实现每轮都走 TCPing→
|
||||
// ResolveHost,既拖慢,又会因多 A 记录轮询导致 results 里每次连到不同 IP(与顶层 ip 不符)。
|
||||
func tcpingDial(ip string, port string, version string, timeout time.Duration) *TCPingResult {
|
||||
addr := ""
|
||||
switch version {
|
||||
case "v4":
|
||||
addr = fmt.Sprintf("%s:%s", ip, port)
|
||||
case "v6":
|
||||
addr = fmt.Sprintf("[%s]:%s", ip, port)
|
||||
default:
|
||||
addr = fmt.Sprintf("%s:%s", ip, port)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
@@ -67,7 +129,6 @@ func TCPing(host string, port string, version string, timeout time.Duration) (*T
|
||||
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
|
||||
result.RTT = -1
|
||||
result.Error = err.Error()
|
||||
} else {
|
||||
@@ -76,11 +137,26 @@ func TCPing(host string, port string, version string, timeout time.Duration) (*T
|
||||
result.RTT = float64(rtt.Microseconds()) / 1000.0
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return result
|
||||
}
|
||||
|
||||
// TCPingRun 执行多次 TCP 连接测试并返回统计结果
|
||||
// 参数:
|
||||
// - host: 目标主机名或 IP 地址
|
||||
// - port: 目标端口号
|
||||
// - count: 测试次数
|
||||
// - version: 协议版本,"v4" 或 "v6"
|
||||
// - timeout: 每次连接的超时时间
|
||||
// - interval: 两次测试之间的间隔时间
|
||||
//
|
||||
// 返回 TCPingStats 包含统计信息
|
||||
func TCPingRun(host string, port string, count int, version string, timeout time.Duration, interval time.Duration) (*TCPingStats, error) {
|
||||
ip, err := resolveHost(host, version)
|
||||
return TCPingRunWith(host, port, count, version, timeout, interval, ReqResolve{})
|
||||
}
|
||||
|
||||
// TCPingRunWith 同 TCPingRun,但带请求级解析配置(req 零值 = 用配置 DNS)。
|
||||
func TCPingRunWith(host string, port string, count int, version string, timeout time.Duration, interval time.Duration, req ReqResolve) (*TCPingStats, error) {
|
||||
ip, err := ResolveHostWith(host, version, req)
|
||||
if err != nil {
|
||||
return &TCPingStats{
|
||||
IP: "Error: " + err.Error(),
|
||||
@@ -107,10 +183,8 @@ func TCPingRun(host string, port string, count int, version string, timeout time
|
||||
successCount := 0
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
result, err := TCPing(host, port, version, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 复用开头解析出的 ip,不再每轮重新 ResolveHost
|
||||
result := tcpingDial(ip, port, version, timeout)
|
||||
|
||||
stats.Results = append(stats.Results, *result)
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"edgeone-cloud-functions/ssrf"
|
||||
@@ -147,33 +146,29 @@ func filterPublicIPs(ips []string) []string {
|
||||
return filtered
|
||||
}
|
||||
|
||||
// resolveWhoisServerIPs 使用 webtest/dns 的统一解析接口
|
||||
// 并行查询 A + AAAA(走自定义 DNS 服务器,不受系统 resolver 策略影响)
|
||||
// resolveWhoisServerIPs 用系统解析器(net.LookupIP)取 whois 服务器的 v4/v6 地址。
|
||||
// whois 不走自定义 DNS(dns-server / 请求级 dns=):whois 服务器是稳定的公共基础设施,
|
||||
// 解析结果只用于建立连接、不作为拨测结论,没必要让 DNS 配置的故障面波及 whois。
|
||||
func resolveWhoisServerIPs(server string) (v4IPs, v6IPs []string, err error) {
|
||||
var (
|
||||
aResult DNSResult
|
||||
aaaaResult DNSResult
|
||||
wg sync.WaitGroup
|
||||
aErr error
|
||||
aaaaErr error
|
||||
)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
aResult, aErr = ResolveARecord(server)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
aaaaResult, aaaaErr = ResolveAAAARecord(server)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
if aErr != nil && aaaaErr != nil {
|
||||
return nil, nil, fmt.Errorf("both A and AAAA DNS lookup failed for %s: A=%w, AAAA=%w", server, aErr, aaaaErr)
|
||||
ips, err := net.LookupIP(server)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
v4IPs = filterPublicIPs(aResult.Record)
|
||||
v6IPs = filterPublicIPs(aaaaResult.Record)
|
||||
var v4, v6 []string
|
||||
for _, ip := range ips {
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if ip.To4() != nil {
|
||||
v4 = append(v4, ip.String())
|
||||
} else {
|
||||
v6 = append(v6, ip.String())
|
||||
}
|
||||
}
|
||||
|
||||
v4IPs = filterPublicIPs(v4)
|
||||
v6IPs = filterPublicIPs(v6)
|
||||
|
||||
if len(v4IPs) == 0 && len(v6IPs) == 0 {
|
||||
return nil, nil, fmt.Errorf("no IPs resolved for %s", server)
|
||||
|
||||
Generated
+4
-4
@@ -8,7 +8,7 @@
|
||||
"name": "lemon-getip",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"hono": "^4.13.5"
|
||||
"hono": "^4.13.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
||||
@@ -1878,9 +1878,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.5",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz",
|
||||
"integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==",
|
||||
"version": "4.13.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
|
||||
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
"wrangler": "^4.124.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.13.5"
|
||||
"hono": "^4.13.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"110101":[116.416334,39.928359],"110102":[116.36585,39.9126],"110105":[116.443136,39.921444],"110106":[116.286726,39.858538],"110107":[116.223015,39.906304],"110108":[116.2977,39.959893],"110109":[116.101668,39.940842],"110111":[116.143426,39.748889],"110112":[116.72923,39.916403],"110113":[116.661474,40.149891],"110114":[116.231034,40.220952],"110115":[116.341483,39.726917],"110116":[116.631974,40.317003],"110117":[117.121589,40.140805],"110118":[116.843351,40.377058],"110119":[115.974609,40.457033],"120101":[117.214713,39.116884],"120102":[117.251584,39.128294],"120103":[117.223379,39.109679],"120104":[117.150638,39.138551],"120105":[117.196874,39.148018],"120106":[117.151566,39.167349],"120110":[117.313567,39.086789],"120111":[117.008994,39.141811],"120112":[117.356683,38.936971],"120113":[117.135614,39.224638],"120114":[117.04456,39.384108],"120115":[117.309748,39.717054],"120116":[117.69641,39.017809],"120117":[117.826674,39.329749],"120118":[116.975474,38.947772],"120119":[117.408432,40.046544],"130102":[114.538955,38.03682],"130104":[114.45004,38.025245],"130105":[114.463904,38.050749],"130107":[114.062258,38.065446],"130108":[114.531599,38.007002],"130109":[114.846562,38.022177],"130110":[114.313559,38.086536],"130111":[114.647922,37.900915],"130121":[114.145669,38.032366],"130123":[114.57043,38.14699],"130125":[114.553044,38.437535],"130126":[114.383013,38.307908],"130127":[114.611659,37.615905],"130128":[115.20089,38.184572],"130129":[114.386114,37.666549],"130130":[114.976256,38.178852],"130131":[114.186007,38.260288],"130132":[114.525508,37.767332],"130133":[114.775914,37.756935],"130181":[115.217626,37.943239],"130183":[115.044141,38.033937],"130184":[114.683745,38.343952],"130202":[118.154348,39.624988],"130203":[118.201085,39.625079],"130204":[118.447134,39.73392],"130205":[118.262246,39.671634],"130207":[118.085169,39.576031],"130208":[118.162426,39.832919],"130209":[118.460197,39.273528],"130224":[118.68363,39.520272],"130225":[118.91245,39.425748],"130227":[118.31467,40.141486],"130229":[117.738196,39.901827],"130281":[117.965878,40.189119],"130283":[118.701021,39.998861],"130284":[118.70301,39.740963],"130302":[119.564962,39.94756],"130303":[119.775187,39.978882],"130304":[119.48449,39.834912],"130306":[119.244847,39.876253],"130321":[118.94985,40.407473],"130322":[119.199846,39.699677],"130324":[118.891931,39.892564],"130402":[114.531062,36.59457],"130403":[114.492875,36.636434],"130404":[114.462581,36.638879],"130406":[114.212571,36.419298],"130407":[114.800199,36.548545],"130408":[114.536626,36.74188],"130423":[114.585514,36.322504],"130424":[114.670094,36.445331],"130425":[115.147985,36.286406],"130426":[113.692157,36.586073],"130427":[114.373965,36.37507],"130430":[115.200049,36.811783],"130431":[114.889951,36.911525],"130432":[114.921185,36.476727],"130433":[115.281818,36.548295],"130434":[114.939217,36.35926],"130435":[114.957549,36.766347],"130481":[114.203685,36.69759],"130502":[114.507443,37.071314],"130503":[114.468229,37.093798],"130505":[114.671339,37.121958],"130506":[114.683683,37.005626],"130522":[114.498651,37.444512],"130523":[114.512226,37.287612],"130524":[114.693447,37.48288],"130525":[114.770509,37.351232],"130528":[114.940006,37.624524],"130529":[115.037884,37.221293],"130530":[115.251005,37.520891],"130531":[115.142766,37.074795],"130532":[115.03008,37.063771],"130533":[115.266829,36.975164],"130534":[115.665081,37.0451],"130535":[115.501258,36.871312],"130581":[115.40866,37.358907],"130582":[114.503023,36.855548],"130602":[115.458671,38.877318],"130606":[115.497153,38.883528],"130607":[115.322246,38.949732],"130608":[115.48988,38.76527],"130609":[115.655772,39.018781],"130623":[115.713651,39.394305],"130624":[114.195118,38.849221],"130626":[115.808183,39.263219],"130627":[114.982968,38.748477],"130628":[115.779149,38.700846],"130629":[115.861635,39.043321],"130630":[114.694416,39.360622],"130631":[115.15542,38.696221],"130632":[115.935688,38.936102],"130633":[115.497487,39.350219],"130634":[114.740476,38.614409],"130635":[115.583701,38.488064],"130636":[115.135133,38.837988],"130637":[115.464295,38.458048],"130638":[116.108624,38.994825],"130681":[115.97444,39.485684],"130682":[114.990321,38.516746],"130683":[115.327088,38.418985],"130684":[115.873612,39.327233],"130702":[114.894114,40.788472],"130703":[114.868604,40.819553],"130705":[115.099515,40.609444],"130706":[115.287127,40.502628],"130708":[114.740584,40.767377],"130709":[115.282345,40.974741],"130722":[114.719927,41.159039],"130723":[114.60018,41.853016],"130724":[115.688544,41.670497],"130725":[113.968763,41.076588],"130726":[114.589136,39.840154],"130727":[114.150267,40.104303],"130728":[114.386488,40.67527],"130730":[115.517868,40.415625],"130731":[115.196835,40.382681],"130732":[115.831256,40.913348],"130802":[117.943121,40.974679],"130803":[117.799588,40.959426],"130804":[117.659341,40.546424],"130821":[118.174166,40.768082],"130822":[117.500558,40.417358],"130824":[117.332652,40.941644],"130825":[1Line truncated
|
||||
@@ -0,0 +1,16 @@
|
||||
package ipdb
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDivisionCoords(t *testing.T) {
|
||||
if len(divisionCoords) < 2800 {
|
||||
t.Fatalf("divisionCoords 区划数不足: %d", len(divisionCoords))
|
||||
}
|
||||
c, ok := divisionCoords["110101"]
|
||||
if !ok {
|
||||
t.Fatal("缺少 110101(东城区)")
|
||||
}
|
||||
if c[0] < 73 || c[0] > 136 || c[1] < 3 || c[1] > 54 {
|
||||
t.Fatalf("110101 坐标超出中国范围: %v", c)
|
||||
}
|
||||
}
|
||||
+176
-40
@@ -3,9 +3,11 @@ package ipdb
|
||||
import (
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -16,24 +18,103 @@ import (
|
||||
)
|
||||
|
||||
type downloadTask struct {
|
||||
url string
|
||||
// urls 镜像列表,按优先级依次尝试。full.txt/short.txt 原来只走
|
||||
// cdn.jsdelivr.net——国内移动网络下 v4/v6 都被 RST(其余走 ghproxy 的
|
||||
// 文件全通),GeoCN 的行政区划数据随之永久缺失,故补齐镜像。
|
||||
urls []string
|
||||
name string
|
||||
}
|
||||
|
||||
// dbValidator 条件请求凭证(ETag / Last-Modified):日更同步时只有真变了的文件
|
||||
// 才会重新下载,没变的 304 直接跳过——原实现每 24h 无条件全量重拉 450MB。
|
||||
// 凭证与「已解压到工作目录的最终文件」绑定,持久化在 ipdb-etags.json。
|
||||
type dbValidator struct {
|
||||
ETag string `json:"etag,omitempty"`
|
||||
LastModified string `json:"last_modified,omitempty"`
|
||||
}
|
||||
|
||||
const validatorsFile = "ipdb-etags.json"
|
||||
|
||||
// pullMu 串行化下载(后台首次补齐与每日同步理论上不重叠,但配置面手动触发
|
||||
// 之类的新入口将来出现时,这里保证不会两个 PullDatabase 互踩同一个 tmp 目录)
|
||||
var pullMu sync.Mutex
|
||||
|
||||
func loadValidators() map[string]dbValidator {
|
||||
m := make(map[string]dbValidator)
|
||||
data, err := os.ReadFile(validatorsFile)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
_ = json.Unmarshal(data, &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func saveValidators(m map[string]dbValidator) {
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
slog.Warn("failed to marshal db validators", "error", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(validatorsFile, data, 0o644); err != nil {
|
||||
slog.Warn("failed to write db validators", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// finalFileName 计算下载产物的最终文件名(.gz / .zip 会被解掉,后缀大小写不敏感——
|
||||
// IP2LOCATION 的资产名是大写 .ZIP)
|
||||
func finalFileName(name string) string {
|
||||
lower := strings.ToLower(name)
|
||||
if strings.HasSuffix(lower, ".gz") {
|
||||
return name[:len(name)-3]
|
||||
}
|
||||
if strings.HasSuffix(lower, ".zip") {
|
||||
return name[:len(name)-4]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func PullDatabase(ghproxy string) error {
|
||||
pullMu.Lock()
|
||||
defer pullMu.Unlock()
|
||||
|
||||
tasks := []downloadTask{
|
||||
{"https://cdn.jsdelivr.net/gh/ljxi/GeoCN@main/data/full.txt", "full.txt"},
|
||||
{"https://cdn.jsdelivr.net/gh/ljxi/GeoCN@main/data/short.txt", "short.txt"},
|
||||
{ghproxy + "https://raw.githubusercontent.com/lionsoul2014/ip2region/master/data/ip2region_v4.xdb", "ip2region_v4.xdb"},
|
||||
{ghproxy + "https://raw.githubusercontent.com/lionsoul2014/ip2region/master/data/ip2region_v6.xdb", "ip2region_v6.xdb"},
|
||||
{ghproxy + "https://raw.githubusercontent.com/nmgliangwei/qqwry.ipdb/main/qqwry.ipdb", "qqwry.ipdb"},
|
||||
{ghproxy + "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb", "GeoLite2-City.mmdb"},
|
||||
{ghproxy + "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-ASN.mmdb", "GeoLite2-ASN.mmdb"},
|
||||
{ghproxy + "https://github.com/ljxi/GeoCN/releases/latest/download/GeoCN.mmdb", "GeoCN.mmdb"},
|
||||
{ghproxy + "https://raw.githubusercontent.com/wp-statistics/DbIP-City-lite/master/dbip-city-lite.mmdb.gz", "dbip-city-lite.mmdb.gz"},
|
||||
{ghproxy + "https://github.com/jcjc-dev/mmdb-latest/releases/download/dbip-latest/dbip-asn-lite.mmdb", "dbip-asn-lite.mmdb"},
|
||||
{ghproxy + "https://github.com/nomdn/ip2location/releases/latest/download/IP2LOCATION-LITE-DB11.IPV6.BIN.ZIP", "IP2LOCATION-LITE-DB11.IPV6.BIN.ZIP"},
|
||||
{ghproxy + "https://github.com/nomdn/ip2location/releases/latest/download/IP2LOCATION-LITE-ASN.IPV6.BIN.zip", "IP2LOCATION-LITE-ASN.IPV6.BIN.zip"},
|
||||
// full/short.txt(GeoCN 行政区划):主通道与其他文件统一走 ghproxy+raw
|
||||
// 加速(注意 raw 的分支是路径段,不带 @;jsdelivr 的 @main 写法在 raw 上是 404)。
|
||||
// jsdelivr 系(含 fastly/gcore 镜像)降为兜底——国内移动网络下会被 RST,
|
||||
// 但海外节点可达。实测主通道 65KB 秒回,兜底仅在主通道故障时启用。
|
||||
{[]string{
|
||||
ghproxy + "https://raw.githubusercontent.com/ljxi/GeoCN/main/data/full.txt",
|
||||
"https://cdn.jsdelivr.net/gh/ljxi/GeoCN@main/data/full.txt",
|
||||
"https://fastly.jsdelivr.net/gh/ljxi/GeoCN@main/data/full.txt",
|
||||
"https://gcore.jsdelivr.net/gh/ljxi/GeoCN@main/data/full.txt",
|
||||
}, "full.txt"},
|
||||
{[]string{
|
||||
ghproxy + "https://raw.githubusercontent.com/ljxi/GeoCN/main/data/short.txt",
|
||||
"https://cdn.jsdelivr.net/gh/ljxi/GeoCN@main/data/short.txt",
|
||||
"https://fastly.jsdelivr.net/gh/ljxi/GeoCN@main/data/short.txt",
|
||||
"https://gcore.jsdelivr.net/gh/ljxi/GeoCN@main/data/short.txt",
|
||||
}, "short.txt"},
|
||||
{[]string{ghproxy + "https://raw.githubusercontent.com/lionsoul2014/ip2region/master/data/ip2region_v4.xdb"}, "ip2region_v4.xdb"},
|
||||
{[]string{ghproxy + "https://raw.githubusercontent.com/lionsoul2014/ip2region/master/data/ip2region_v6.xdb"}, "ip2region_v6.xdb"},
|
||||
{[]string{ghproxy + "https://raw.githubusercontent.com/nmgliangwei/qqwry.ipdb/main/qqwry.ipdb"}, "qqwry.ipdb"},
|
||||
{[]string{ghproxy + "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb"}, "GeoLite2-City.mmdb"},
|
||||
{[]string{ghproxy + "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-ASN.mmdb"}, "GeoLite2-ASN.mmdb"},
|
||||
{[]string{ghproxy + "https://github.com/ljxi/GeoCN/releases/latest/download/GeoCN.mmdb"}, "GeoCN.mmdb"},
|
||||
{[]string{ghproxy + "https://raw.githubusercontent.com/wp-statistics/DbIP-City-lite/master/dbip-city-lite.mmdb.gz"}, "dbip-city-lite.mmdb.gz"},
|
||||
{[]string{ghproxy + "https://github.com/jcjc-dev/mmdb-latest/releases/download/dbip-latest/dbip-asn-lite.mmdb"}, "dbip-asn-lite.mmdb"},
|
||||
{[]string{ghproxy + "https://github.com/nomdn/ip2location/releases/latest/download/IP2LOCATION-LITE-DB11.IPV6.BIN.ZIP"}, "IP2LOCATION-LITE-DB11.IPV6.BIN.ZIP"},
|
||||
{[]string{ghproxy + "https://github.com/nomdn/ip2location/releases/latest/download/IP2LOCATION-LITE-ASN.IPV6.BIN.zip"}, "IP2LOCATION-LITE-ASN.IPV6.BIN.zip"},
|
||||
// 自家的 LemonDB(nomdn/LemonDB 月度构建):全球打底 + 中国省市县高精度层,
|
||||
// CN 段精度远高于上面所有免费源,放在最后当压轴。
|
||||
// 拉取节奏与其余库一致:启动缺文件即拉 + 每 24h 条件请求检查一次——
|
||||
// release 月更,ETag 变了才真正下载,节点无论中断多久,恢复后一天内追平最新版。
|
||||
{[]string{ghproxy + "https://github.com/nomdn/LemonDB/releases/latest/download/LEMON-LOCATION.mmdb"}, "LEMON-LOCATION.mmdb"},
|
||||
{[]string{ghproxy + "https://github.com/nomdn/LemonDB/releases/latest/download/LEMON-ASN.mmdb"}, "LEMON-ASN.mmdb"},
|
||||
}
|
||||
|
||||
tmpDir := "./tmp"
|
||||
@@ -43,15 +124,34 @@ func PullDatabase(ghproxy string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 条件请求凭证:只在「最终文件还在本地」时才有意义——304 的语义是
|
||||
// 「服务器上和你手里这份一样」,本地文件没了就必须整份重下。
|
||||
validators := loadValidators()
|
||||
newValidators := make(map[string]dbValidator, len(tasks))
|
||||
for k, v := range validators {
|
||||
newValidators[k] = v
|
||||
}
|
||||
|
||||
slog.Info("Starting parallel downloads...", "count", len(tasks))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(tasks))
|
||||
notModified := make([]bool, len(tasks))
|
||||
for i, t := range tasks {
|
||||
wg.Add(1)
|
||||
go func(i int, t downloadTask) {
|
||||
defer wg.Done()
|
||||
errs[i] = downloadWithRetry(t.url, t.name)
|
||||
val := validators[t.name]
|
||||
if !fileExists("./" + finalFileName(t.name)) {
|
||||
val = dbValidator{}
|
||||
}
|
||||
nv, skip, err := downloadWithRetry(t, val)
|
||||
errs[i] = err
|
||||
notModified[i] = skip
|
||||
if err == nil {
|
||||
newValidators[t.name] = nv
|
||||
}
|
||||
// 失败的文件保留旧凭证:本地旧版本文件仍在,下次可继续走条件请求
|
||||
}(i, t)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -67,8 +167,18 @@ func PullDatabase(ghproxy string) error {
|
||||
slog.Info("Copying downloaded files to working directory...")
|
||||
for i, t := range tasks {
|
||||
if errs[i] != nil {
|
||||
// 下载失败(含传输中断留下的残缺文件)不覆盖现有数据库
|
||||
slog.Warn("Skipping failed download, keep existing file", "file", t.name)
|
||||
// 下载失败(含传输中断留下的残缺文件)不覆盖现有数据库。
|
||||
// 本地确实有旧文件 → 继续用它;连旧文件都没有 → 明确说数据库会降级
|
||||
if fileExists("./" + finalFileName(t.name)) {
|
||||
slog.Warn("Download failed, keep existing local copy", "file", t.name)
|
||||
} else {
|
||||
slog.Warn("Download failed and no local copy exists, related DB stays degraded until next sync", "file", t.name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if notModified[i] {
|
||||
// 304:本地文件就是最新版,无需覆盖也不必解压
|
||||
slog.Info("Unchanged since last sync, skipped", "file", t.name)
|
||||
continue
|
||||
}
|
||||
src := filepath.Join(tmpDir, t.name)
|
||||
@@ -103,6 +213,7 @@ func PullDatabase(ghproxy string) error {
|
||||
}
|
||||
|
||||
os.RemoveAll(tmpDir)
|
||||
saveValidators(newValidators)
|
||||
|
||||
if failCount > 0 {
|
||||
slog.Error("Some downloads failed", "failed", failCount)
|
||||
@@ -113,36 +224,61 @@ func PullDatabase(ghproxy string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadWithRetry(url, name string) error {
|
||||
const maxRetries = 3
|
||||
client := resty.New().SetTimeout(60 * time.Second).SetOutputDirectory("./tmp")
|
||||
// downloadWithRetry 带镜像轮试与重试的条件下载。返回 (新凭证, 是否 304 跳过, 错误)。
|
||||
//
|
||||
// 镜像轮试:urls 按优先级依次尝试,每个镜像最多 2 次(首次 + 一次快速重试),
|
||||
// 全部镜像失败后整轮再来一轮——单个镜像被墙/抖动不再拖死整个文件。
|
||||
// 超时 10 分钟:这是**整个请求**(含响应体下载)的超时,GeoLite2-City 这类几十 MB
|
||||
// 的大文件需要持续 ~1Mbps 以上才能在 10 分钟内完成;原实现的 60s 在慢链路上
|
||||
// 会让重试全部超时失败。
|
||||
func downloadWithRetry(t downloadTask, val dbValidator) (dbValidator, bool, error) {
|
||||
const attemptsPerMirror = 2
|
||||
client := resty.New().SetTimeout(10 * time.Minute).SetOutputDirectory("./tmp")
|
||||
defer client.Close()
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
slog.Info("Downloading...", "file", name, "attempt", attempt)
|
||||
for _, u := range t.urls {
|
||||
for attempt := 1; attempt <= attemptsPerMirror; attempt++ {
|
||||
slog.Info("Downloading...", "file", t.name, "url", u, "attempt", attempt)
|
||||
|
||||
resp, err := client.R().SetOutputFileName(name).SetSaveResponse(true).Get(url)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("request failed: %w", err)
|
||||
slog.Warn("Download attempt failed", "file", name, "attempt", attempt, "error", err)
|
||||
// 传输中断会在输出文件里留下残缺内容,删掉避免被当作下载成功覆盖现有数据库
|
||||
os.Remove(filepath.Join("./tmp", name))
|
||||
time.Sleep(time.Duration(attempt) * time.Second)
|
||||
continue
|
||||
}
|
||||
if resp.IsError() {
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode())
|
||||
slog.Warn("Download attempt failed", "file", name, "attempt", attempt, "status", resp.StatusCode())
|
||||
os.Remove(filepath.Join("./tmp", name))
|
||||
time.Sleep(time.Duration(attempt) * time.Second)
|
||||
continue
|
||||
}
|
||||
req := client.R().SetOutputFileName(t.name).SetSaveResponse(true)
|
||||
if val.ETag != "" {
|
||||
req.SetHeader("If-None-Match", val.ETag)
|
||||
}
|
||||
if val.LastModified != "" {
|
||||
req.SetHeader("If-Modified-Since", val.LastModified)
|
||||
}
|
||||
|
||||
slog.Info("Downloaded", "file", name)
|
||||
return nil
|
||||
resp, err := req.Get(u)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("request failed: %w", err)
|
||||
slog.Warn("Download attempt failed", "file", t.name, "url", u, "attempt", attempt, "error", err)
|
||||
// 传输中断会在输出文件里留下残缺内容,删掉避免被当作下载成功覆盖现有数据库
|
||||
os.Remove(filepath.Join("./tmp", t.name))
|
||||
time.Sleep(time.Duration(attempt) * time.Second)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode() == http.StatusNotModified {
|
||||
slog.Info("Not modified since last sync", "file", t.name)
|
||||
return val, true, nil
|
||||
}
|
||||
if resp.IsError() {
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode())
|
||||
slog.Warn("Download attempt failed", "file", t.name, "url", u, "attempt", attempt, "status", resp.StatusCode())
|
||||
os.Remove(filepath.Join("./tmp", t.name))
|
||||
time.Sleep(time.Duration(attempt) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
nv := dbValidator{
|
||||
ETag: resp.Header().Get("ETag"),
|
||||
LastModified: resp.Header().Get("Last-Modified"),
|
||||
}
|
||||
slog.Info("Downloaded", "file", t.name, "url", u)
|
||||
return nv, false, nil
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
return val, false, lastErr
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
|
||||
+378
-286
@@ -2,11 +2,12 @@ package ipdb
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"math/rand"
|
||||
"net"
|
||||
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -18,9 +19,27 @@ import (
|
||||
"github.com/ip2location/ip2location-go/v9"
|
||||
"github.com/lionsoul2014/ip2region/binding/golang/service"
|
||||
maxminddb "github.com/oschwald/maxminddb-golang/v2"
|
||||
"golang.org/x/sync/singleflight"
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
// division-coords.json:GB/T 2260 六位区划代码 → 政府驻地 [lng, lat](GCJ-02),
|
||||
// 数据来自 AreaCity-JsSpider-StatsGov(高德采集,随版本更新可重新生成)。
|
||||
// 供 GeoCN 按区划代码打出"政府驻地"坐标点。
|
||||
//
|
||||
//go:embed division-coords.json
|
||||
var divisionCoordsRaw string
|
||||
|
||||
// divisionCoords 编译期内建,避免运行时读盘
|
||||
var divisionCoords = func() map[string][2]float64 {
|
||||
m := make(map[string][2]float64)
|
||||
if err := json.Unmarshal([]byte(divisionCoordsRaw), &m); err != nil {
|
||||
slog.Error("failed to parse division-coords.json", "error", err)
|
||||
return make(map[string][2]float64)
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
var (
|
||||
ip2region *service.Ip2Region
|
||||
ip2regionMu sync.RWMutex
|
||||
@@ -36,13 +55,37 @@ var (
|
||||
divisionShort map[int]string
|
||||
divisionMu sync.RWMutex
|
||||
bilibiliCache sync.Map
|
||||
// bilibiliGroup 同一 IP 的并发未命中合并为一次上游调用(SearchIP 之外
|
||||
// 定时任务/多 handler 并发触达同一 IP 时,避免重复打 B 站 API)
|
||||
bilibiliGroup singleflight.Group
|
||||
)
|
||||
|
||||
// bilibiliClient 共享的 B 站归属地客户端:连接池跨查询复用。
|
||||
// 原实现每次 searchBilibili 都 resty.New(),每个未缓存 IP 都付一次完整 TCP+TLS 握手
|
||||
// (实测首查 ~440ms,其中 TLS 209ms;复用后热查询 ~80ms)。进程生命周期内不 Close。
|
||||
var bilibiliClient = resty.New().SetTimeout(10 * time.Second)
|
||||
|
||||
type bilibiliCacheEntry struct {
|
||||
result *BilibiliResult
|
||||
errMsg string // 非空 = 负缓存(上游失败),TTL 远短于正缓存,避免对非法/不可达 IP 反复打上游
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
// ttl:正结果 24h(归属地几乎不变),负结果 1 分钟
|
||||
func (e bilibiliCacheEntry) ttl() time.Duration {
|
||||
if e.errMsg != "" {
|
||||
return time.Minute
|
||||
}
|
||||
return 24 * time.Hour
|
||||
}
|
||||
|
||||
func (e bilibiliCacheEntry) err() error {
|
||||
if e.errMsg == "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", e.errMsg)
|
||||
}
|
||||
|
||||
type MMDBCityResult struct {
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"country_code"`
|
||||
@@ -64,6 +107,30 @@ type GeoCNResult struct {
|
||||
Region string `json:"administrative_area,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
District string `json:"district,omitempty"`
|
||||
// 政府驻地坐标(GCJ-02,按 division_code 查内置对照表);
|
||||
// 缺表/新设区划查不到时不输出坐标字段
|
||||
Latitude float64 `json:"latitude,omitempty"`
|
||||
Longitude float64 `json:"longitude,omitempty"`
|
||||
}
|
||||
|
||||
// LemonDBResult LEMON-LOCATION.mmdb 的查询结果。记录是 GeoLite2 兼容骨架
|
||||
// (country/subdivisions/city/location)加中国层扩展(district / division_code /
|
||||
// isp / type),境外段无中国层字段时输出 omitempty 省略。
|
||||
// 坐标全库统一 WGS-84(国际标准),coord_system 恒为 wgs84;上高德等 GCJ 底图
|
||||
// 由读方自行转换(前端已有 wgs84ToGcj02)。
|
||||
type LemonDBResult struct {
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"country_code,omitempty"`
|
||||
Region string `json:"administrative_area,omitempty"`
|
||||
RegionISO string `json:"administrative_area_iso,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
District string `json:"district,omitempty"`
|
||||
ISP string `json:"isp,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
DivisionCode string `json:"division_code,omitempty"`
|
||||
Latitude float64 `json:"latitude,omitempty"`
|
||||
Longitude float64 `json:"longitude,omitempty"`
|
||||
CoordSystem string `json:"coord_system,omitempty"`
|
||||
}
|
||||
|
||||
type BilibiliIPQueryResponse struct {
|
||||
@@ -160,14 +227,12 @@ func loadDivisionData() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadIp2Region() error {
|
||||
ip2regionMu.Lock()
|
||||
defer ip2regionMu.Unlock()
|
||||
// —— 加载器的统一模式:先在锁外构建新实例,再持锁原子换指针,旧实例放锁外 Close ——
|
||||
// 原实现是「持锁 → 关旧 → 开新」,重载期间该库有几百 ms 到几秒的查询真空窗
|
||||
// (查询拿到 "not loaded");换成换指针模式后窗口降到 μs 级。
|
||||
// 榜样本来就是 reloadQQWry 的写法,这里推广到全部加载器。
|
||||
|
||||
if ip2region != nil {
|
||||
ip2region.Close()
|
||||
slog.Info("Previous ip2region closed")
|
||||
}
|
||||
func loadIp2Region() error {
|
||||
v4Config, err := service.NewV4Config(service.VIndexCache, "ip2region_v4.xdb", 20)
|
||||
if err != nil {
|
||||
slog.Error("failed to create v4 config", "error", err)
|
||||
@@ -180,161 +245,139 @@ func loadIp2Region() error {
|
||||
return err
|
||||
}
|
||||
|
||||
ip2region, err = service.NewIp2Region(v4Config, v6Config)
|
||||
next, err := service.NewIp2Region(v4Config, v6Config)
|
||||
if err != nil {
|
||||
slog.Error("failed to create ip2region service", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
ip2regionMu.Lock()
|
||||
old := ip2region
|
||||
ip2region = next
|
||||
ip2regionMu.Unlock()
|
||||
if old != nil {
|
||||
old.Close()
|
||||
slog.Info("Previous ip2region closed")
|
||||
}
|
||||
|
||||
slog.Info("ip2region loaded successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadIP2Location 加载 IP2Location LITE DB11 数据库(.BIN 格式,官方 ip2location-go 库读取)
|
||||
func loadIP2Location() error {
|
||||
ip2locMu.Lock()
|
||||
defer ip2locMu.Unlock()
|
||||
|
||||
if ip2locDB != nil {
|
||||
ip2locDB.Close()
|
||||
ip2locDB = nil
|
||||
slog.Info("Previous ip2location closed")
|
||||
}
|
||||
|
||||
db, err := ip2location.OpenDB("./IP2LOCATION-LITE-DB11.IPV6.BIN")
|
||||
if err != nil {
|
||||
slog.Error("failed to open ip2location db", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
ip2locMu.Lock()
|
||||
old := ip2locDB
|
||||
ip2locDB = db
|
||||
ip2locMu.Unlock()
|
||||
if old != nil {
|
||||
old.Close()
|
||||
slog.Info("Previous ip2location closed")
|
||||
}
|
||||
|
||||
slog.Info("ip2location loaded successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadIP2LocationASN 加载 IP2Location LITE ASN 数据库(仅 asn / as 字段)
|
||||
func loadIP2LocationASN() error {
|
||||
ip2locASNMu.Lock()
|
||||
defer ip2locASNMu.Unlock()
|
||||
|
||||
if ip2locASNDB != nil {
|
||||
ip2locASNDB.Close()
|
||||
ip2locASNDB = nil
|
||||
slog.Info("Previous ip2location asn closed")
|
||||
}
|
||||
|
||||
db, err := ip2location.OpenDB("./IP2LOCATION-LITE-ASN.IPV6.BIN")
|
||||
if err != nil {
|
||||
slog.Error("failed to open ip2location asn db", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
ip2locASNMu.Lock()
|
||||
old := ip2locASNDB
|
||||
ip2locASNDB = db
|
||||
ip2locASNMu.Unlock()
|
||||
if old != nil {
|
||||
old.Close()
|
||||
slog.Info("Previous ip2location asn closed")
|
||||
}
|
||||
|
||||
slog.Info("ip2location asn loaded successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadQQWry() error {
|
||||
qqwryMu.Lock()
|
||||
defer qqwryMu.Unlock()
|
||||
if qqwryDB != nil {
|
||||
qqwryDB = nil
|
||||
slog.Info("Previous qqwry.ipdb closed")
|
||||
}
|
||||
var err error
|
||||
qqwryDB, err = ipdbgo.NewCity("qqwry.ipdb")
|
||||
if err != nil {
|
||||
slog.Error("failed to load qqwry.ipdb", "error", err)
|
||||
return err
|
||||
}
|
||||
slog.Info("qqwry.ipdb loaded successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func reloadQQWry() error {
|
||||
// 1. 先创建新实例
|
||||
// 先建新实例,再持锁替换(旧实例由 GC 回收,ipdb-go 底层 mmap 会自动释放)
|
||||
newDB, err := ipdbgo.NewCity("qqwry.ipdb")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload qqwry failed: %w", err)
|
||||
slog.Error("failed to load qqwry.ipdb", "error", err)
|
||||
return fmt.Errorf("load qqwry failed: %w", err)
|
||||
}
|
||||
|
||||
// 2. 加锁替换(旧实例由 GC 回收,ipdb-go 底层 mmap 会自动释放)
|
||||
qqwryMu.Lock()
|
||||
qqwryDB = newDB
|
||||
qqwryMu.Unlock()
|
||||
|
||||
slog.Info("qqwry.ipdb reloaded successfully")
|
||||
slog.Info("qqwry.ipdb loaded successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
var mmdbFiles = map[string]string{
|
||||
"geolite2_city": "GeoLite2-City.mmdb",
|
||||
"geolite2_asn": "GeoLite2-ASN.mmdb",
|
||||
"geocn": "GeoCN.mmdb",
|
||||
"dbip_city": "dbip-city-lite.mmdb",
|
||||
"dbip_asn": "dbip-asn-lite.mmdb",
|
||||
// LemonDB:全球定位 + 全球 ASN(月更,来自 nomdn/LemonDB release)
|
||||
"lemondb_location": "LEMON-LOCATION.mmdb",
|
||||
"lemondb_asn": "LEMON-ASN.mmdb",
|
||||
}
|
||||
|
||||
// loadMMDB 重建全部 mmdb 并原子换指针。个别文件加载失败时沿用旧库(数据旧一点
|
||||
// 好过整库消失),只有首次加载(无旧库)才真正缺库。
|
||||
func loadMMDB() error {
|
||||
mmdbMu.Lock()
|
||||
defer mmdbMu.Unlock()
|
||||
|
||||
for name, db := range mmdbDBs {
|
||||
db.Close()
|
||||
slog.Info("Previous mmdb closed", "name", name)
|
||||
}
|
||||
|
||||
mmdbFiles := map[string]string{
|
||||
"geolite2_city": "GeoLite2-City.mmdb",
|
||||
"geolite2_asn": "GeoLite2-ASN.mmdb",
|
||||
"geocn": "GeoCN.mmdb",
|
||||
"dbip_city": "dbip-city-lite.mmdb",
|
||||
"dbip_asn": "dbip-asn-lite.mmdb",
|
||||
}
|
||||
|
||||
mmdbDBs = make(map[string]*maxminddb.Reader)
|
||||
next := make(map[string]*maxminddb.Reader, len(mmdbFiles))
|
||||
for name, path := range mmdbFiles {
|
||||
db, err := maxminddb.Open(path)
|
||||
if err != nil {
|
||||
slog.Error("failed to load mmdb", "name", name, "error", err)
|
||||
continue
|
||||
}
|
||||
mmdbDBs[name] = db
|
||||
next[name] = db
|
||||
slog.Info("mmdb loaded", "name", name)
|
||||
}
|
||||
if len(mmdbDBs) < len(mmdbFiles) {
|
||||
return fmt.Errorf("some mmdb files missing (%d/%d loaded)", len(mmdbDBs), len(mmdbFiles))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reloadMMDB() error {
|
||||
mmdbMu.Lock()
|
||||
defer mmdbMu.Unlock()
|
||||
for name, db := range mmdbDBs {
|
||||
db.Close()
|
||||
slog.Info("Previous mmdb closed", "name", name)
|
||||
}
|
||||
|
||||
mmdbFiles := map[string]string{
|
||||
"geolite2_city": "GeoLite2-City.mmdb",
|
||||
"geolite2_asn": "GeoLite2-ASN.mmdb",
|
||||
"geocn": "GeoCN.mmdb",
|
||||
"dbip_city": "dbip-city-lite.mmdb",
|
||||
"dbip_asn": "dbip-asn-lite.mmdb",
|
||||
}
|
||||
|
||||
mmdbDBs = make(map[string]*maxminddb.Reader)
|
||||
for name, path := range mmdbFiles {
|
||||
db, err := maxminddb.Open(path)
|
||||
if err != nil {
|
||||
slog.Error("failed to reload mmdb", "name", name, "error", err)
|
||||
continue
|
||||
old := mmdbDBs
|
||||
merged := make(map[string]*maxminddb.Reader, len(mmdbFiles))
|
||||
for name := range mmdbFiles {
|
||||
if db, ok := next[name]; ok {
|
||||
merged[name] = db
|
||||
} else if db, ok := old[name]; ok {
|
||||
// 新文件损坏:沿用旧库,不跟着一起下线
|
||||
merged[name] = db
|
||||
delete(next, name)
|
||||
}
|
||||
mmdbDBs[name] = db
|
||||
slog.Info("mmdb reloaded", "name", name)
|
||||
}
|
||||
mmdbDBs = merged
|
||||
mmdbMu.Unlock()
|
||||
|
||||
// 换指针后再关被替换的旧库(合并时沿用的旧库保留,不关)
|
||||
for name, db := range old {
|
||||
if _, keep := merged[name]; !keep {
|
||||
db.Close()
|
||||
slog.Info("Previous mmdb closed", "name", name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(merged) < len(mmdbFiles) {
|
||||
return fmt.Errorf("some mmdb files missing (%d/%d loaded)", len(merged), len(mmdbFiles))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func searchMMDBCity(ip string) (*MMDBCityResult, error) {
|
||||
mmdbMu.RLock()
|
||||
defer mmdbMu.RUnlock()
|
||||
db, ok := mmdbDBs["geolite2_city"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("geolite2_city not loaded")
|
||||
}
|
||||
|
||||
// searchMMDBCityIn 在指定库上查城市(searchMMDBCity / searchDBIPCity 的公共实现)
|
||||
func searchMMDBCityIn(db *maxminddb.Reader, ip string) (*MMDBCityResult, error) {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -385,72 +428,8 @@ func searchMMDBCity(ip string) (*MMDBCityResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func searchDBIPCity(ip string) (*MMDBCityResult, error) {
|
||||
mmdbMu.RLock()
|
||||
defer mmdbMu.RUnlock()
|
||||
db, ok := mmdbDBs["dbip_city"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dbip_city not loaded")
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var record struct {
|
||||
Country struct {
|
||||
ISOCode string `maxminddb:"iso_code"`
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"country"`
|
||||
Subdivisions []struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"subdivisions"`
|
||||
City struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"city"`
|
||||
Location struct {
|
||||
Latitude float64 `maxminddb:"latitude"`
|
||||
Longitude float64 `maxminddb:"longitude"`
|
||||
} `maxminddb:"location"`
|
||||
}
|
||||
if err := db.Lookup(addr).Decode(&record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &MMDBCityResult{
|
||||
CountryCode: record.Country.ISOCode,
|
||||
Latitude: record.Location.Latitude,
|
||||
Longitude: record.Location.Longitude,
|
||||
}
|
||||
if name, ok := record.Country.Names["zh-CN"]; ok {
|
||||
result.Country = name
|
||||
} else if name, ok := record.Country.Names["en"]; ok {
|
||||
result.Country = name
|
||||
}
|
||||
if len(record.Subdivisions) > 0 {
|
||||
if name, ok := record.Subdivisions[0].Names["zh-CN"]; ok {
|
||||
result.Region = name
|
||||
} else if name, ok := record.Subdivisions[0].Names["en"]; ok {
|
||||
result.Region = name
|
||||
}
|
||||
}
|
||||
if name, ok := record.City.Names["zh-CN"]; ok {
|
||||
result.City = name
|
||||
} else if name, ok := record.City.Names["en"]; ok {
|
||||
result.City = name
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func searchMMDBASN(ip string) (*MMDBASNResult, error) {
|
||||
mmdbMu.RLock()
|
||||
defer mmdbMu.RUnlock()
|
||||
db, ok := mmdbDBs["geolite2_asn"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("geolite2_asn not loaded")
|
||||
}
|
||||
|
||||
// searchMMDBASNIn 在指定库上查 ASN(searchMMDBASN / searchDBIPASN 的公共实现)
|
||||
func searchMMDBASNIn(db *maxminddb.Reader, ip string) (*MMDBASNResult, error) {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -470,37 +449,50 @@ func searchMMDBASN(ip string) (*MMDBASNResult, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func searchMMDBCity(ip string) (*MMDBCityResult, error) {
|
||||
mmdbMu.RLock()
|
||||
db, ok := mmdbDBs["geolite2_city"]
|
||||
mmdbMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("geolite2_city not loaded")
|
||||
}
|
||||
return searchMMDBCityIn(db, ip)
|
||||
}
|
||||
|
||||
func searchDBIPCity(ip string) (*MMDBCityResult, error) {
|
||||
mmdbMu.RLock()
|
||||
db, ok := mmdbDBs["dbip_city"]
|
||||
mmdbMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dbip_city not loaded")
|
||||
}
|
||||
return searchMMDBCityIn(db, ip)
|
||||
}
|
||||
|
||||
func searchMMDBASN(ip string) (*MMDBASNResult, error) {
|
||||
mmdbMu.RLock()
|
||||
db, ok := mmdbDBs["geolite2_asn"]
|
||||
mmdbMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("geolite2_asn not loaded")
|
||||
}
|
||||
return searchMMDBASNIn(db, ip)
|
||||
}
|
||||
|
||||
func searchDBIPASN(ip string) (*MMDBASNResult, error) {
|
||||
mmdbMu.RLock()
|
||||
defer mmdbMu.RUnlock()
|
||||
db, ok := mmdbDBs["dbip_asn"]
|
||||
mmdbMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dbip_asn not loaded")
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var record struct {
|
||||
ASN uint32 `maxminddb:"autonomous_system_number"`
|
||||
Org string `maxminddb:"autonomous_system_organization"`
|
||||
}
|
||||
if err := db.Lookup(addr).Decode(&record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MMDBASNResult{
|
||||
ASN: fmt.Sprintf("AS%d", record.ASN),
|
||||
Org: record.Org,
|
||||
}, nil
|
||||
return searchMMDBASNIn(db, ip)
|
||||
}
|
||||
|
||||
func searchGeoCN(ip string) (*GeoCNResult, error) {
|
||||
mmdbMu.RLock()
|
||||
defer mmdbMu.RUnlock()
|
||||
db, ok := mmdbDBs["geocn"]
|
||||
mmdbMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("geocn not loaded")
|
||||
}
|
||||
@@ -531,112 +523,174 @@ func searchGeoCN(ip string) (*GeoCNResult, error) {
|
||||
result.Region = province
|
||||
result.City = city
|
||||
result.District = district
|
||||
// 政府驻地打点:按区划代码查内置对照表(GCJ-02),供前端地图与 GeoCN 打点
|
||||
if c, ok := divisionCoords[strconv.Itoa(code)]; ok {
|
||||
result.Longitude = c[0]
|
||||
result.Latitude = c[1]
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func searchBilibili(ip string) (*BilibiliResult, error) {
|
||||
if net.ParseIP(ip) == nil {
|
||||
return nil, fmt.Errorf("invalid IP address: %s", ip)
|
||||
// searchLemonDB 查 LemonDB 全球定位库(记录结构见 LemonDBResult 注释)。
|
||||
// 坐标统一 WGS-84,coord_system 仍透出(恒为 wgs84),读方按需自转 GCJ。
|
||||
func searchLemonDB(ip string) (*LemonDBResult, error) {
|
||||
mmdbMu.RLock()
|
||||
db, ok := mmdbDBs["lemondb_location"]
|
||||
mmdbMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("lemondb_location not loaded")
|
||||
}
|
||||
|
||||
if cached, ok := bilibiliCache.Load(ip); ok {
|
||||
entry := cached.(bilibiliCacheEntry)
|
||||
if time.Since(entry.timestamp) < 24*time.Hour {
|
||||
return entry.result, nil
|
||||
}
|
||||
bilibiliCache.Delete(ip)
|
||||
}
|
||||
|
||||
client := resty.New().SetTimeout(10 * time.Second)
|
||||
defer client.Close()
|
||||
resp, err := client.R().
|
||||
SetQueryParam("ip", ip).
|
||||
SetResult(&BilibiliIPQueryResponse{}).
|
||||
Get("https://api.live.bilibili.com/ip_service/v1/ip_service/get_ip_addr")
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.IsError() {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode())
|
||||
var record struct {
|
||||
Country struct {
|
||||
ISOCode string `maxminddb:"iso_code"`
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"country"`
|
||||
Subdivisions []struct {
|
||||
ISOCode string `maxminddb:"iso_code"`
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"subdivisions"`
|
||||
City struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"city"`
|
||||
District struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"district"`
|
||||
DivisionCode uint32 `maxminddb:"division_code"`
|
||||
ISP string `maxminddb:"isp"`
|
||||
Type string `maxminddb:"type"`
|
||||
Location struct {
|
||||
Latitude float64 `maxminddb:"latitude"`
|
||||
Longitude float64 `maxminddb:"longitude"`
|
||||
CoordSystem string `maxminddb:"coord_system"`
|
||||
} `maxminddb:"location"`
|
||||
}
|
||||
result := &BilibiliResult{
|
||||
Country: resp.Result().(*BilibiliIPQueryResponse).Data.Country,
|
||||
Region: resp.Result().(*BilibiliIPQueryResponse).Data.Province,
|
||||
City: resp.Result().(*BilibiliIPQueryResponse).Data.City,
|
||||
ISP: resp.Result().(*BilibiliIPQueryResponse).Data.ISP,
|
||||
Latitude: resp.Result().(*BilibiliIPQueryResponse).Data.Latitude,
|
||||
Longitude: resp.Result().(*BilibiliIPQueryResponse).Data.Longitude,
|
||||
if err := db.Lookup(addr).Decode(&record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bilibiliCache.Store(ip, bilibiliCacheEntry{result: result, timestamp: time.Now()})
|
||||
// 取名统一 zh-CN 优先、en 兜底
|
||||
zh := func(m map[string]string) string {
|
||||
if v, ok := m["zh-CN"]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return m["en"]
|
||||
}
|
||||
|
||||
result := &LemonDBResult{
|
||||
CountryCode: record.Country.ISOCode,
|
||||
ISP: record.ISP,
|
||||
Type: record.Type,
|
||||
DivisionCode: fmt.Sprintf("%d", record.DivisionCode),
|
||||
CoordSystem: record.Location.CoordSystem,
|
||||
Latitude: record.Location.Latitude,
|
||||
Longitude: record.Location.Longitude,
|
||||
}
|
||||
result.Country = zh(record.Country.Names)
|
||||
if len(record.Subdivisions) > 0 {
|
||||
result.Region = zh(record.Subdivisions[0].Names)
|
||||
result.RegionISO = record.Subdivisions[0].ISOCode
|
||||
}
|
||||
result.City = zh(record.City.Names)
|
||||
result.District = zh(record.District.Names)
|
||||
if result.DivisionCode == "0" {
|
||||
result.DivisionCode = ""
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// searchLemonDBASN 查 LemonDB 全球 ASN 库(记录键与 GeoLite2-ASN 相同,直接复用解析)
|
||||
func searchLemonDBASN(ip string) (*MMDBASNResult, error) {
|
||||
mmdbMu.RLock()
|
||||
db, ok := mmdbDBs["lemondb_asn"]
|
||||
mmdbMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("lemondb_asn not loaded")
|
||||
}
|
||||
return searchMMDBASNIn(db, ip)
|
||||
}
|
||||
|
||||
// loadBilibiliEntry 查缓存(按正/负各自的 TTL 判新鲜),过期顺手删除
|
||||
func loadBilibiliEntry(ip string) (bilibiliCacheEntry, bool) {
|
||||
if cached, ok := bilibiliCache.Load(ip); ok {
|
||||
e := cached.(bilibiliCacheEntry)
|
||||
if time.Since(e.timestamp) < e.ttl() {
|
||||
return e, true
|
||||
}
|
||||
bilibiliCache.Delete(ip)
|
||||
}
|
||||
return bilibiliCacheEntry{}, false
|
||||
}
|
||||
|
||||
func fetchBilibili(ip string) bilibiliCacheEntry {
|
||||
resp, err := bilibiliClient.R().
|
||||
SetQueryParam("ip", ip).
|
||||
SetResult(&BilibiliIPQueryResponse{}).
|
||||
Get("https://api.live.bilibili.com/ip_service/v1/ip_service/get_ip_addr")
|
||||
if err != nil {
|
||||
return bilibiliCacheEntry{errMsg: err.Error(), timestamp: time.Now()}
|
||||
}
|
||||
if resp.IsError() {
|
||||
return bilibiliCacheEntry{errMsg: fmt.Sprintf("HTTP %d", resp.StatusCode()), timestamp: time.Now()}
|
||||
}
|
||||
r := resp.Result().(*BilibiliIPQueryResponse)
|
||||
return bilibiliCacheEntry{
|
||||
result: &BilibiliResult{
|
||||
Country: r.Data.Country,
|
||||
Region: r.Data.Province,
|
||||
City: r.Data.City,
|
||||
ISP: r.Data.ISP,
|
||||
Latitude: r.Data.Latitude,
|
||||
Longitude: r.Data.Longitude,
|
||||
},
|
||||
timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func searchBilibili(ip string) (*BilibiliResult, error) {
|
||||
if net.ParseIP(ip) == nil {
|
||||
// 非法 IP 是纯内存判断,不进缓存也不打上游
|
||||
return nil, fmt.Errorf("invalid IP address: %s", ip)
|
||||
}
|
||||
|
||||
if e, ok := loadBilibiliEntry(ip); ok {
|
||||
return e.result, e.err()
|
||||
}
|
||||
|
||||
v, _, _ := bilibiliGroup.Do(ip, func() (any, error) {
|
||||
// singleflight 等待期间可能已被同 IP 的前一个请求填进缓存
|
||||
if e, ok := loadBilibiliEntry(ip); ok {
|
||||
return e, nil
|
||||
}
|
||||
e := fetchBilibili(ip)
|
||||
bilibiliCache.Store(ip, e)
|
||||
return e, nil
|
||||
})
|
||||
e := v.(bilibiliCacheEntry)
|
||||
return e.result, e.err()
|
||||
}
|
||||
|
||||
// SweepBilibiliCache 清理过期的 Bilibili 归属地缓存(由外部定时调用,
|
||||
// 防止公网端点被唯一 key 洪打时缓存内存无限增长)
|
||||
// 防止公网端点被唯一 key 洪打时缓存内存无限增长)。
|
||||
// 负缓存的 TTL 只有 1 分钟,但条目若按 24h 清扫会滞留内存——按各自 TTL 清。
|
||||
func SweepBilibiliCache() {
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
now := time.Now()
|
||||
bilibiliCache.Range(func(k, v any) bool {
|
||||
if e, ok := v.(bilibiliCacheEntry); ok && e.timestamp.Before(cutoff) {
|
||||
if e, ok := v.(bilibiliCacheEntry); ok && now.Sub(e.timestamp) >= e.ttl() {
|
||||
bilibiliCache.Delete(k)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func loadAll() {
|
||||
if err := loadIp2Region(); err != nil {
|
||||
slog.Error("Error loading ip2region", "error", err)
|
||||
}
|
||||
if err := loadQQWry(); err != nil {
|
||||
slog.Error("Error loading qqwry.ipdb", "error", err)
|
||||
}
|
||||
if err := loadMMDB(); err != nil {
|
||||
slog.Error("Error loading mmdb", "error", err)
|
||||
}
|
||||
if err := loadDivisionData(); err != nil {
|
||||
slog.Error("Error loading division data", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func closeAllDBs() {
|
||||
ip2regionMu.Lock()
|
||||
if ip2region != nil {
|
||||
ip2region.Close()
|
||||
ip2region = nil
|
||||
}
|
||||
ip2regionMu.Unlock()
|
||||
|
||||
ip2locMu.Lock()
|
||||
if ip2locDB != nil {
|
||||
ip2locDB.Close()
|
||||
ip2locDB = nil
|
||||
}
|
||||
ip2locMu.Unlock()
|
||||
|
||||
ip2locASNMu.Lock()
|
||||
if ip2locASNDB != nil {
|
||||
ip2locASNDB.Close()
|
||||
ip2locASNDB = nil
|
||||
}
|
||||
ip2locASNMu.Unlock()
|
||||
|
||||
qqwryMu.Lock()
|
||||
qqwryDB = nil
|
||||
qqwryMu.Unlock()
|
||||
|
||||
mmdbMu.Lock()
|
||||
for name, db := range mmdbDBs {
|
||||
db.Close()
|
||||
slog.Info("Closed mmdb", "name", name)
|
||||
}
|
||||
mmdbDBs = nil
|
||||
mmdbMu.Unlock()
|
||||
}
|
||||
|
||||
// reloadAll 全量重载(各加载器自身即「先建新再换指针」,重载期间查询不中断)
|
||||
func reloadAll() {
|
||||
if err := loadIp2Region(); err != nil {
|
||||
slog.Error("Error loading ip2region", "error", err)
|
||||
@@ -647,11 +701,11 @@ func reloadAll() {
|
||||
if err := loadIP2LocationASN(); err != nil {
|
||||
slog.Error("Error loading ip2location asn", "error", err)
|
||||
}
|
||||
if err := reloadQQWry(); err != nil {
|
||||
slog.Error("Error reloading qqwry.ipdb", "error", err)
|
||||
if err := loadQQWry(); err != nil {
|
||||
slog.Error("Error loading qqwry.ipdb", "error", err)
|
||||
}
|
||||
if err := reloadMMDB(); err != nil {
|
||||
slog.Error("Error reloading mmdb", "error", err)
|
||||
if err := loadMMDB(); err != nil {
|
||||
slog.Error("Error loading mmdb", "error", err)
|
||||
}
|
||||
if err := loadDivisionData(); err != nil {
|
||||
slog.Error("Error reloading division data", "error", err)
|
||||
@@ -659,7 +713,9 @@ func reloadAll() {
|
||||
}
|
||||
|
||||
func Init(ghproxy string) {
|
||||
|
||||
// 先用本地文件全量加载;有缺失不再同步阻塞下载(原实现会把节点启动拖住几分钟,
|
||||
// 下载 450MB 全程不对外服务)——SearchIP 的结果结构天然支持 "not loaded" 降级,
|
||||
// 立即起服务,缺的库后台补齐后换指针上线。
|
||||
localOK := true
|
||||
if err := loadIp2Region(); err != nil {
|
||||
slog.Warn("Local ip2region not available", "error", err)
|
||||
@@ -689,13 +745,18 @@ func Init(ghproxy string) {
|
||||
if localOK {
|
||||
slog.Info("All databases loaded from local files")
|
||||
} else {
|
||||
slog.Info("Downloading databases...")
|
||||
if pullErr := PullDatabase(ghproxy); pullErr != nil {
|
||||
slog.Error("Some databases failed to download", "error", pullErr)
|
||||
}
|
||||
reloadAll()
|
||||
slog.Info("Some databases missing, downloading in background (serving degraded results meanwhile)")
|
||||
go func() {
|
||||
if err := PullDatabase(ghproxy); err != nil {
|
||||
slog.Error("Background database download failed", "error", err)
|
||||
}
|
||||
reloadAll()
|
||||
slog.Info("Background database initialization completed")
|
||||
}()
|
||||
}
|
||||
|
||||
// 每日同步:PullDatabase 内部带条件请求(ETag/Last-Modified),文件没变的
|
||||
// 节点只花 12 个小请求,不再全量重拉 450MB。
|
||||
go func() {
|
||||
for {
|
||||
jitter := time.Duration(rand.Int63n(int64(time.Hour)))
|
||||
@@ -706,14 +767,13 @@ func Init(ghproxy string) {
|
||||
slog.Error("Database sync failed", "error", err)
|
||||
continue
|
||||
}
|
||||
closeAllDBs()
|
||||
reloadAll()
|
||||
slog.Info("Database sync completed, waiting for next sync...")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var allDatabases = []string{"ip2region", "ip2location", "ip2location_asn", "qqwry", "maxmind_city", "maxmind_asn", "dbip_asn", "geocn", "dbip_city", "bilibili"}
|
||||
var allDatabases = []string{"lemondb", "lemondb_asn", "ip2region", "ip2location", "ip2location_asn", "qqwry", "maxmind_city", "maxmind_asn", "dbip_asn", "geocn", "dbip_city", "bilibili"}
|
||||
|
||||
func SearchIP(ip string, databases ...string) map[string]interface{} {
|
||||
if len(databases) == 0 {
|
||||
@@ -722,8 +782,27 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
|
||||
|
||||
result := map[string]interface{}{"ip": ip}
|
||||
|
||||
// bilibili 是 10 个源里唯一的远程调用(实测 80~440ms,超时 10s),其余 9 个本地
|
||||
// 库合计 <5ms:把它并行出去,不让整个接口陪它等;本地库保持串行
|
||||
// (微秒级操作,并行反而净增调度开销)。结果写进独立变量,Wait 后合并——
|
||||
// Go 的 map 并发写(哪怕 key 不同)是不安全的。
|
||||
var wg sync.WaitGroup
|
||||
var bili any
|
||||
|
||||
for _, name := range databases {
|
||||
switch name {
|
||||
case "bilibili":
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
res, err := searchBilibili(ip)
|
||||
if err != nil {
|
||||
bili = "error: " + err.Error()
|
||||
} else {
|
||||
bili = res
|
||||
}
|
||||
}()
|
||||
|
||||
case "ip2region":
|
||||
ip2regionMu.RLock()
|
||||
if ip2region != nil {
|
||||
@@ -859,12 +938,20 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
|
||||
result["dbip_city"] = dbip
|
||||
}
|
||||
|
||||
case "bilibili":
|
||||
bilibili, err := searchBilibili(ip)
|
||||
case "lemondb":
|
||||
lemon, err := searchLemonDB(ip)
|
||||
if err != nil {
|
||||
result["bilibili"] = "error: " + err.Error()
|
||||
result["lemondb"] = "error: " + err.Error()
|
||||
} else {
|
||||
result["bilibili"] = bilibili
|
||||
result["lemondb"] = lemon
|
||||
}
|
||||
|
||||
case "lemondb_asn":
|
||||
asn, err := searchLemonDBASN(ip)
|
||||
if err != nil {
|
||||
result["lemondb_asn"] = "error: " + err.Error()
|
||||
} else {
|
||||
result["lemondb_asn"] = asn
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -872,6 +959,11 @@ func SearchIP(ip string, databases ...string) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
if bili != nil {
|
||||
result["bilibili"] = bili
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
+221
-133
@@ -38,26 +38,39 @@ func initHTTPClients() {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
return &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) {
|
||||
if ssrf.Enabled() {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
// DNS 接管与 SSRF 解耦:域名一律先经 webtest 解析成 IP 再拨号。
|
||||
// 服务器取请求级覆盖(dns=/dns4=/dns6=,经 ctx 传来),无覆盖则用配置 dns-server;
|
||||
// 不这样时关掉 SSRF 会绕过配置/系统 DNS,detail/ssl/speed 与 tcping/dns/whois 各问各的。
|
||||
// SSRF 只保留「私网校验」职责,不再是 DNS 接管的开关。
|
||||
// 同源:SSRF 预检(ValidateOutboundTarget)经 ssrfresolver 读到的是同一份 ctx 配置。
|
||||
req := webtest.ReqResolveFromContext(ctx)
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 强制解析(resolve=/resolve4=/resolve6=):命中就把目标填回 host。
|
||||
// 目标是 IP → 走下面的字面 IP 分支(私网检测后直连,不再查 DNS);
|
||||
// 目标是域名 → 当作新 host 交给下面的解析分支。
|
||||
if target := req.Override(host, network == "tcp6"); target != "" {
|
||||
host = strings.Trim(target, "[]")
|
||||
addr = net.JoinHostPort(host, port)
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if ssrf.Enabled() && ssrf.IsPrivateIP(ip) {
|
||||
slog.Warn("Blocked connection to private IP", "host", host)
|
||||
return nil, fmt.Errorf("request to private/internal address is not allowed")
|
||||
}
|
||||
} else {
|
||||
var dnsResult webtest.DNSResult
|
||||
if network == "tcp4" {
|
||||
dnsResult, err = webtest.ResolveARecord(host, req.Servers("ipv4"))
|
||||
} else {
|
||||
dnsResult, err = webtest.ResolveAAAARecord(host, req.Servers("ipv6"))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if ssrf.IsPrivateIP(ip) {
|
||||
slog.Warn("Blocked connection to private IP", "host", host)
|
||||
return nil, fmt.Errorf("request to private/internal address is not allowed")
|
||||
}
|
||||
} else {
|
||||
var dnsResult webtest.DNSResult
|
||||
if network == "tcp4" {
|
||||
dnsResult, err = webtest.ResolveARecord(host)
|
||||
} else {
|
||||
dnsResult, err = webtest.ResolveAAAARecord(host)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ssrf.Enabled() {
|
||||
for _, ipStr := range dnsResult.Record {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip != nil && ssrf.IsPrivateIP(ip) {
|
||||
@@ -65,9 +78,9 @@ func initHTTPClients() {
|
||||
return nil, fmt.Errorf("request to private/internal address is not allowed")
|
||||
}
|
||||
}
|
||||
if len(dnsResult.Record) > 0 {
|
||||
addr = net.JoinHostPort(dnsResult.Record[0], port)
|
||||
}
|
||||
}
|
||||
if len(dnsResult.Record) > 0 {
|
||||
addr = net.JoinHostPort(dnsResult.Record[0], port)
|
||||
}
|
||||
}
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
@@ -248,6 +261,7 @@ var (
|
||||
speedCache sync.Map
|
||||
whoisCache sync.Map
|
||||
asnWhoisCache sync.Map
|
||||
locationCache sync.Map // /v1/location 的 IP → 归属地结果缓存(SearchIP 含 bilibili 远程调用)
|
||||
sfGroup singleflight.Group
|
||||
V6Client *resty.Client
|
||||
V4Client *resty.Client
|
||||
@@ -390,19 +404,27 @@ func checkWebsiteHandler(c fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
if cached, ok := websiteCache.Load(testUrl); ok {
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
// 缓存键带上请求级 DNS 覆盖:结果取决于所用服务器,参数不同必须各占一条,
|
||||
// 否则互相串味;无覆盖时 req.CacheKey() 为空串,键与旧版完全一致。
|
||||
cacheKey := testUrl + req.CacheKey()
|
||||
|
||||
if cached, ok := websiteCache.Load(cacheKey); ok {
|
||||
entry := cached.(websiteCacheEntry)
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return c.Status(200).JSON(entry.result)
|
||||
}
|
||||
websiteCache.Delete(testUrl)
|
||||
websiteCache.Delete(cacheKey)
|
||||
}
|
||||
|
||||
rawResult, _, _ := sfGroup.Do(testUrl, func() (interface{}, error) {
|
||||
rawResult, _, _ := sfGroup.Do(cacheKey, func() (interface{}, error) {
|
||||
result := &WebsiteCheckResult{}
|
||||
switch SINGLE_STACK {
|
||||
case "ipv4":
|
||||
ipv4, errV4 := webtest.CheckWebsite(testUrl, "v4")
|
||||
ipv4, errV4 := webtest.CheckWebsite(testUrl, "v4", req)
|
||||
if errV4 != nil {
|
||||
ipv4 = &webtest.WebsiteCheckDetail{
|
||||
HostRecord: "Error: " + errV4.Error(),
|
||||
@@ -415,7 +437,7 @@ func checkWebsiteHandler(c fiber.Ctx) error {
|
||||
IsReachable: false,
|
||||
}
|
||||
case "ipv6":
|
||||
ipv6, errV6 := webtest.CheckWebsite(testUrl, "v6")
|
||||
ipv6, errV6 := webtest.CheckWebsite(testUrl, "v6", req)
|
||||
if errV6 != nil {
|
||||
ipv6 = &webtest.WebsiteCheckDetail{
|
||||
HostRecord: "Error: " + errV6.Error(),
|
||||
@@ -433,7 +455,7 @@ func checkWebsiteHandler(c fiber.Ctx) error {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv6, errV6 := webtest.CheckWebsite(testUrl, "v6")
|
||||
ipv6, errV6 := webtest.CheckWebsite(testUrl, "v6", req)
|
||||
if errV6 != nil {
|
||||
ipv6 = &webtest.WebsiteCheckDetail{
|
||||
HostRecord: "Error: " + errV6.Error(),
|
||||
@@ -445,7 +467,7 @@ func checkWebsiteHandler(c fiber.Ctx) error {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv4, errV4 := webtest.CheckWebsite(testUrl, "v4")
|
||||
ipv4, errV4 := webtest.CheckWebsite(testUrl, "v4", req)
|
||||
if errV4 != nil {
|
||||
ipv4 = &webtest.WebsiteCheckDetail{
|
||||
HostRecord: "Error: " + errV4.Error(),
|
||||
@@ -461,7 +483,7 @@ func checkWebsiteHandler(c fiber.Ctx) error {
|
||||
failed := dualAnyFailed(result.IPv4, result.IPv6, func(d *webtest.WebsiteCheckDetail) bool {
|
||||
return d != nil && !d.IsReachable
|
||||
})
|
||||
websiteCache.Store(testUrl, websiteCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
websiteCache.Store(cacheKey, websiteCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
|
||||
return result, nil
|
||||
})
|
||||
@@ -478,6 +500,11 @@ func websiteSpeedTestHandler(c fiber.Ctx) error {
|
||||
}
|
||||
url := normalizeURL(testUrl)
|
||||
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// 检查请求版本是否与 SINGLE_STACK 配置匹配
|
||||
switch SINGLE_STACK {
|
||||
case "ipv4":
|
||||
@@ -496,10 +523,9 @@ func websiteSpeedTestHandler(c fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存键:URL + 版本
|
||||
cacheKey := fmt.Sprintf("%s:%s", url, version)
|
||||
// 缓存键:URL + 版本 + 请求级 DNS 覆盖(结果取决于所用服务器,参数不同不能共用)
|
||||
cacheKey := fmt.Sprintf("%s:%s%s", url, version, req.CacheKey())
|
||||
|
||||
// 检查缓存
|
||||
if cached, ok := speedCache.Load(cacheKey); ok {
|
||||
entry := cached.(speedCacheEntry)
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
@@ -513,7 +539,7 @@ func websiteSpeedTestHandler(c fiber.Ctx) error {
|
||||
switch version {
|
||||
case "v6", "v4":
|
||||
rawResult, _, _ := sfGroup.Do(cacheKey, func() (interface{}, error) {
|
||||
r, e := webtest.SpeedTest(url, version)
|
||||
r, e := webtest.SpeedTest(url, version, req)
|
||||
if e != nil {
|
||||
errorResult := &webtest.WebsiteSpeedTestResult{
|
||||
HostRecord: "Error: " + e.Error(),
|
||||
@@ -552,19 +578,26 @@ func sslCheckHandler(c fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
if cached, ok := sslCache.Load(testUrl); ok {
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
// 缓存键带上请求级 DNS 覆盖(结果取决于所用服务器,参数不同不能共用)
|
||||
cacheKey := testUrl + req.CacheKey()
|
||||
|
||||
if cached, ok := sslCache.Load(cacheKey); ok {
|
||||
entry := cached.(sslCacheEntry)
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return c.Status(200).JSON(entry.result)
|
||||
}
|
||||
sslCache.Delete(testUrl)
|
||||
sslCache.Delete(cacheKey)
|
||||
}
|
||||
|
||||
rawResult, _, _ := sfGroup.Do(testUrl, func() (interface{}, error) {
|
||||
rawResult, _, _ := sfGroup.Do(cacheKey, func() (interface{}, error) {
|
||||
result := &SSLCheckResult{}
|
||||
switch SINGLE_STACK {
|
||||
case "ipv4":
|
||||
ipv4, errV4 := webtest.CheckSSL(testUrl, "v4")
|
||||
ipv4, errV4 := webtest.CheckSSL(testUrl, "v4", req)
|
||||
if errV4 != nil {
|
||||
ipv4 = &webtest.SSLCheckDetail{
|
||||
HostRecord: "Error: " + errV4.Error(),
|
||||
@@ -579,7 +612,7 @@ func sslCheckHandler(c fiber.Ctx) error {
|
||||
IsReachable: false,
|
||||
}
|
||||
case "ipv6":
|
||||
ipv6, errV6 := webtest.CheckSSL(testUrl, "v6")
|
||||
ipv6, errV6 := webtest.CheckSSL(testUrl, "v6", req)
|
||||
if errV6 != nil {
|
||||
ipv6 = &webtest.SSLCheckDetail{
|
||||
HostRecord: "Error: " + errV6.Error(),
|
||||
@@ -599,7 +632,7 @@ func sslCheckHandler(c fiber.Ctx) error {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv6, errV6 := webtest.CheckSSL(testUrl, "v6")
|
||||
ipv6, errV6 := webtest.CheckSSL(testUrl, "v6", req)
|
||||
if errV6 != nil {
|
||||
ipv6 = &webtest.SSLCheckDetail{
|
||||
HostRecord: "Error: " + errV6.Error(),
|
||||
@@ -612,7 +645,7 @@ func sslCheckHandler(c fiber.Ctx) error {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv4, errV4 := webtest.CheckSSL(testUrl, "v4")
|
||||
ipv4, errV4 := webtest.CheckSSL(testUrl, "v4", req)
|
||||
if errV4 != nil {
|
||||
ipv4 = &webtest.SSLCheckDetail{
|
||||
HostRecord: "Error: " + errV4.Error(),
|
||||
@@ -629,7 +662,7 @@ func sslCheckHandler(c fiber.Ctx) error {
|
||||
failed := dualAnyFailed(result.IPv4, result.IPv6, func(d *webtest.SSLCheckDetail) bool {
|
||||
return d != nil && !d.IsReachable
|
||||
})
|
||||
sslCache.Store(testUrl, sslCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
sslCache.Store(cacheKey, sslCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
|
||||
return result, nil
|
||||
})
|
||||
@@ -637,16 +670,55 @@ func sslCheckHandler(c fiber.Ctx) error {
|
||||
return c.Status(200).JSON(rawResult.(*SSLCheckResult))
|
||||
}
|
||||
|
||||
type locationCacheEntry struct {
|
||||
result map[string]any
|
||||
timestamp time.Time
|
||||
failed bool // 结果里有 "error:"/"not loaded"(源库缺失、bilibili 失败等),按失败 TTL 缓存
|
||||
}
|
||||
|
||||
func locateIP(c fiber.Ctx) error {
|
||||
ip := c.Params("ip")
|
||||
slog.Debug("Locating IP", "ip", ip)
|
||||
return c.Status(http.StatusOK).JSON(ipdb.SearchIP(ip))
|
||||
result, _ := locateIPCached(ip)
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
}
|
||||
func locateUserIP(c fiber.Ctx) error {
|
||||
ip := c.IP()
|
||||
// 可能会有误报,因为某些环境下 IP() 可能返回代理服务器的 IP 地址,而不是用户的真实 IP 地址
|
||||
slog.Debug("Locating user IP", "ip", ip)
|
||||
return c.Status(http.StatusOK).JSON(ipdb.SearchIP(ip))
|
||||
result, _ := locateIPCached(ip)
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
}
|
||||
|
||||
// locateIPCached 带缓存与 singleflight 的归属地查询。
|
||||
// 原实现裸调 ipdb.SearchIP:10 个源里有 9 个本地内存查找(合计 <5ms),
|
||||
// 唯一的远程源 bilibili 实测 80~440ms、超时 10s——同 IP 洪峰会原样穿透到 B 站 API。
|
||||
// 这里用与其他 handler 相同的「缓存 + sfGroup」组合吸收;结果里带 error/not loaded
|
||||
// 视为失败,按失败 TTL 缩短缓存(IPDB 降级期、上游抖动时不至于把坏结果钉满 5 分钟)。
|
||||
func locateIPCached(ip string) (map[string]any, bool) {
|
||||
cacheKey := "location:" + ip
|
||||
if cached, ok := locationCache.Load(cacheKey); ok {
|
||||
entry := cached.(locationCacheEntry)
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return entry.result, true
|
||||
}
|
||||
locationCache.Delete(cacheKey)
|
||||
}
|
||||
|
||||
raw, _, _ := sfGroup.Do(cacheKey, func() (any, error) {
|
||||
return ipdb.SearchIP(ip), nil
|
||||
})
|
||||
result := raw.(map[string]any)
|
||||
|
||||
failed := false
|
||||
for _, v := range result {
|
||||
if s, ok := v.(string); ok && (strings.HasPrefix(s, "error:") || s == "not loaded") {
|
||||
failed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
locationCache.Store(cacheKey, locationCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
return result, failed
|
||||
}
|
||||
|
||||
// normalizeASN 把各数据源的 ASN 字符串统一成 "AS"+数字。
|
||||
@@ -666,12 +738,13 @@ func normalizeASN(s string) string {
|
||||
return "AS" + s
|
||||
}
|
||||
|
||||
// resolveASN 从 SearchIP 结果中按优先级提取可用 ASN(maxmind → dbip → ip2location),返回 "ASxxxx"。
|
||||
// resolveASN 从 SearchIP 结果中按优先级提取可用 ASN(lemondb → maxmind → dbip → ip2location),返回 "ASxxxx"。
|
||||
//
|
||||
// 之所以要兜底:此前 whois 段只认 maxmind_asn,某个 IP 一旦未命中该库,整段 ASN WHOIS 就凭空消失,
|
||||
// 哪怕 dbip_asn / ip2location_asn 明明有数据。
|
||||
// 哪怕 dbip_asn / ip2location_asn 明明有数据。lemondb_asn 打头:GeoLite2 底子 + APNIC CN 精修,
|
||||
// 覆盖面与 maxmind 相同、CN 组织名更准。
|
||||
func resolveASN(result map[string]interface{}) string {
|
||||
for _, key := range []string{"maxmind_asn", "dbip_asn"} {
|
||||
for _, key := range []string{"lemondb_asn", "maxmind_asn", "dbip_asn"} {
|
||||
if r, ok := result[key].(*ipdb.MMDBASNResult); ok {
|
||||
if asn := normalizeASN(r.ASN); asn != "" {
|
||||
return asn
|
||||
@@ -729,7 +802,7 @@ func asnLookupHandler(c fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
result := ipdb.SearchIP(ip, "maxmind_asn", "dbip_asn", "ip2location_asn")
|
||||
result := ipdb.SearchIP(ip, "lemondb_asn", "maxmind_asn", "dbip_asn", "ip2location_asn")
|
||||
|
||||
asnResult := map[string]interface{}{
|
||||
"ip": ip,
|
||||
@@ -746,6 +819,17 @@ func asnLookupHandler(c fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
if lemonASN, ok := result["lemondb_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
asnResult["lemondb_asn"] = map[string]string{
|
||||
"asn": lemonASN.ASN,
|
||||
"org": lemonASN.Org,
|
||||
}
|
||||
} else if errStr, ok := result["lemondb_asn"].(string); ok {
|
||||
asnResult["lemondb_asn"] = map[string]string{
|
||||
"error": errStr,
|
||||
}
|
||||
}
|
||||
|
||||
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
asnResult["geolite2_asn"] = map[string]string{
|
||||
"asn": maxmindASN.ASN,
|
||||
@@ -786,12 +870,15 @@ func whoisHandler(c fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
if cached, ok := whoisCache.Load(domain); ok {
|
||||
// whois 不走请求级 DNS 覆盖(见 webtest.QueryWhois 注释),缓存键就是域名本身
|
||||
cacheKey := domain
|
||||
|
||||
if cached, ok := whoisCache.Load(cacheKey); ok {
|
||||
entry := cached.(whoisCacheEntry)
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return c.Status(200).JSON(entry.result)
|
||||
}
|
||||
whoisCache.Delete(domain)
|
||||
whoisCache.Delete(cacheKey)
|
||||
}
|
||||
|
||||
result, err := webtest.QueryWhois(domain)
|
||||
@@ -801,7 +888,7 @@ func whoisHandler(c fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
whoisCache.Store(domain, whoisCacheEntry{result: result, timestamp: time.Now()})
|
||||
whoisCache.Store(cacheKey, whoisCacheEntry{result: result, timestamp: time.Now()})
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
}
|
||||
|
||||
@@ -835,84 +922,62 @@ func dnsQueryHandler(c fiber.Ctx) error {
|
||||
domain = parsedURL.Host
|
||||
recodeType := c.Params("type")
|
||||
switch recodeType {
|
||||
case "a":
|
||||
result, err := webtest.ResolveARecord(domain)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
case "aaaa":
|
||||
result, err := webtest.ResolveAAAARecord(domain)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
case "cname":
|
||||
result, err := webtest.ResolveCNAMERecord(domain)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
case "mx":
|
||||
result, err := webtest.ResolveMXRecord(domain)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
case "ns":
|
||||
result, err := webtest.ResolveNSRecord(domain)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
case "ptr":
|
||||
result, err := webtest.ResolvePTRRecord(domain)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
case "srv":
|
||||
result, err := webtest.ResolveSRVRecord(domain)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
case "txt":
|
||||
result, err := webtest.ResolveTXTRecord(domain)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
case "caa":
|
||||
result, err := webtest.ResolveCAARecord(domain)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
case "a", "aaaa", "cname", "mx", "ns", "ptr", "srv", "txt", "caa":
|
||||
default:
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Invalid record type",
|
||||
})
|
||||
}
|
||||
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
// 按 qtype 选栈:a→dns4|dns,aaaa→dns6|dns,其余→dns
|
||||
stack := ""
|
||||
if recodeType == "a" {
|
||||
stack = "ipv4"
|
||||
} else if recodeType == "aaaa" {
|
||||
stack = "ipv6"
|
||||
}
|
||||
|
||||
result, err := webtest.ResolveByType(domain, recodeType, req.Servers(stack))
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(result)
|
||||
}
|
||||
|
||||
// normalizeStack 把请求里的栈标识归一化为 "ipv4" / "ipv6"。
|
||||
// 同时接受 v4/v6 与 ipv4/ipv6 两种写法;无法识别时返回空串。
|
||||
func normalizeStack(version string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(version)) {
|
||||
case "v4", "ipv4":
|
||||
return "ipv4"
|
||||
case "v6", "ipv6":
|
||||
return "ipv6"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// probeStackFor 计算一次拨测实际生效的单栈,并给出跳过文案的来源标签。
|
||||
//
|
||||
// stack == "" 双栈并行
|
||||
// stack == "ipv4"/"ipv6" 只跑该栈,另一栈标记跳过
|
||||
//
|
||||
// 规则:SINGLE_STACK(节点配置)优先于请求参数 version —— 节点本身是单栈机时,
|
||||
// 指定另一栈也跑不起来,统一按配置处理;只有配置为空(双栈机)时 version 才生效,
|
||||
// 此时跳过文案记为 version=xxx,与配置造成的跳过区分开。
|
||||
func probeStackFor(version string) (stack, skipLabel string) {
|
||||
want := normalizeStack(version)
|
||||
if want == "" || SINGLE_STACK != "" {
|
||||
return SINGLE_STACK, "SINGLE_STACK"
|
||||
}
|
||||
return want, "version"
|
||||
}
|
||||
|
||||
func pingHandler(c fiber.Ctx) error {
|
||||
host := c.Params("ip")
|
||||
port := c.Query("port")
|
||||
@@ -944,7 +1009,24 @@ func pingHandler(c fiber.Ctx) error {
|
||||
count = n
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d", host, port, count)
|
||||
// version=ipv4/ipv6:请求级指定单栈,行为等价于临时把 SINGLE_STACK 设成该值。
|
||||
// 节点本身是单栈机(SINGLE_STACK 非空)时配置优先,请求不越权到另一栈。
|
||||
version := c.Query("version")
|
||||
if version != "" && normalizeStack(version) == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "version must be ipv4 or ipv6",
|
||||
})
|
||||
}
|
||||
stack, skipLabel := probeStackFor(version)
|
||||
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return c.Query(k) })
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// 缓存键带上生效栈与请求级 DNS 覆盖:version=ipv6 与默认双栈、
|
||||
// 以及不同 dns= 的结果都不能互相污染。
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d:%s%s", host, port, count, stack, req.CacheKey())
|
||||
if cached, ok := pingCache.Load(cacheKey); ok {
|
||||
entry := cached.(pingCacheEntry)
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
@@ -956,9 +1038,9 @@ func pingHandler(c fiber.Ctx) error {
|
||||
rawResult, _, _ := sfGroup.Do(cacheKey, func() (interface{}, error) {
|
||||
result := &TCPingResult{}
|
||||
|
||||
switch SINGLE_STACK {
|
||||
switch stack {
|
||||
case "ipv4":
|
||||
ipv4, errV4 := webtest.TCPingRun(host, port, count, "v4", 10*time.Second, 100*time.Millisecond)
|
||||
ipv4, errV4 := webtest.TCPingRunWith(host, port, count, "v4", 10*time.Second, 100*time.Millisecond, req)
|
||||
if errV4 != nil {
|
||||
ipv4 = &webtest.TCPingStats{
|
||||
IP: "Error: " + errV4.Error(),
|
||||
@@ -966,10 +1048,10 @@ func pingHandler(c fiber.Ctx) error {
|
||||
}
|
||||
result.IPv4 = ipv4
|
||||
result.IPv6 = &webtest.TCPingStats{
|
||||
IP: "Skipped due to SINGLE_STACK=ipv4",
|
||||
IP: "Skipped due to " + skipLabel + "=ipv4",
|
||||
}
|
||||
case "ipv6":
|
||||
ipv6, errV6 := webtest.TCPingRun(host, port, count, "v6", 10*time.Second, 100*time.Millisecond)
|
||||
ipv6, errV6 := webtest.TCPingRunWith(host, port, count, "v6", 10*time.Second, 100*time.Millisecond, req)
|
||||
if errV6 != nil {
|
||||
ipv6 = &webtest.TCPingStats{
|
||||
IP: "Error: " + errV6.Error(),
|
||||
@@ -977,7 +1059,7 @@ func pingHandler(c fiber.Ctx) error {
|
||||
}
|
||||
result.IPv6 = ipv6
|
||||
result.IPv4 = &webtest.TCPingStats{
|
||||
IP: "Skipped due to SINGLE_STACK=ipv6",
|
||||
IP: "Skipped due to " + skipLabel + "=ipv6",
|
||||
}
|
||||
default:
|
||||
var wg sync.WaitGroup
|
||||
@@ -985,7 +1067,7 @@ func pingHandler(c fiber.Ctx) error {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv6, errV6 := webtest.TCPingRun(host, port, count, "v6", 10*time.Second, 100*time.Millisecond)
|
||||
ipv6, errV6 := webtest.TCPingRunWith(host, port, count, "v6", 10*time.Second, 100*time.Millisecond, req)
|
||||
if errV6 != nil {
|
||||
ipv6 = &webtest.TCPingStats{
|
||||
IP: "Error: " + errV6.Error(),
|
||||
@@ -996,7 +1078,7 @@ func pingHandler(c fiber.Ctx) error {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ipv4, errV4 := webtest.TCPingRun(host, port, count, "v4", 10*time.Second, 100*time.Millisecond)
|
||||
ipv4, errV4 := webtest.TCPingRunWith(host, port, count, "v4", 10*time.Second, 100*time.Millisecond, req)
|
||||
if errV4 != nil {
|
||||
ipv4 = &webtest.TCPingStats{
|
||||
IP: "Error: " + errV4.Error(),
|
||||
@@ -1479,7 +1561,10 @@ func readConfig() {
|
||||
// 响应体上限不在这里应用 —— 出站客户端还没创建,交 initHTTPClients(main 里紧随其后)。
|
||||
applyMemoryLimit()
|
||||
var SYSTEM_DNS string
|
||||
if DNS_SERVER == "" || DNSSEC_DNS_SERVER == "" {
|
||||
if DNS_SERVER == "" {
|
||||
// 只为普通 dns-server 自动补系统 DNS;dnssec-server 不补——系统/运营商 DNS
|
||||
// 普遍不支持 DNSSEC(DO=1 也不返回 RRSIG),补进来会让验签永远失败,
|
||||
// dnssec 缺省走 webtest 内置的境外 DO 友好列表(见 dnssec.go)。
|
||||
sysDNS, err := sysresolv.NewSystemResolvers(nil, 53)
|
||||
if err != nil {
|
||||
// 失败时 sysDNS 为 nil,绝不能再调用 Refresh(),否则启动即 panic;
|
||||
@@ -1490,9 +1575,6 @@ func readConfig() {
|
||||
SYSTEM_DNS = webtest.AddrPortsToCSV(sysDNS.Addrs())
|
||||
}
|
||||
}
|
||||
if DNSSEC_DNS_SERVER == "" {
|
||||
DNSSEC_DNS_SERVER = SYSTEM_DNS
|
||||
}
|
||||
if DNS_SERVER == "" {
|
||||
DNS_SERVER = SYSTEM_DNS
|
||||
}
|
||||
@@ -1533,6 +1615,8 @@ func sweepExpiredCaches() {
|
||||
ts = e.timestamp
|
||||
case asnWhoisCacheEntry:
|
||||
ts = e.timestamp
|
||||
case locationCacheEntry:
|
||||
ts = e.timestamp
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -1548,6 +1632,7 @@ func sweepExpiredCaches() {
|
||||
sweep(&speedCache)
|
||||
sweep(&whoisCache)
|
||||
sweep(&asnWhoisCache)
|
||||
sweep(&locationCache)
|
||||
ipdb.SweepBilibiliCache()
|
||||
}
|
||||
|
||||
@@ -1696,6 +1781,9 @@ func main() {
|
||||
readConfig()
|
||||
webtest.SetDNSServer(DNS_SERVER)
|
||||
webtest.SetDNSSecServer(DNSSEC_DNS_SERVER)
|
||||
// 让 ssrf 出站校验走 webtest 的自定义 DNS(含请求级 dns=/dns4=/dns6= 覆盖),
|
||||
// 避免"预检用系统解析器、拨号用自定义 DNS"两套解析器。
|
||||
webtest.InstallSSRFResolver()
|
||||
initHTTPClients()
|
||||
// 注入出站 HTTP 客户端到 webtest(探针函数内部按版本取用)
|
||||
webtest.SetHTTPClient(V4Client, V6Client)
|
||||
|
||||
+20
-3
@@ -37,6 +37,23 @@ func Enabled() bool {
|
||||
return blockPrivateIPs
|
||||
}
|
||||
|
||||
// ResolveFunc 把主机名解析为 IP 列表。默认 net.LookupIP(系统解析器)。
|
||||
// 可由上层(webtest)通过 SetResolveFunc 注入,使出站校验与真正的拨号走同一个 DNS
|
||||
// ——包括请求级 DNS 服务器覆盖。否则预检(系统解析器)与拨号(自定义 DNS)会用两套解析器,
|
||||
// 既多一次查询,结果也可能不一致。
|
||||
type ResolveFunc func(ctx context.Context, host string) ([]net.IP, error)
|
||||
|
||||
var resolveHostFn ResolveFunc = func(_ context.Context, host string) ([]net.IP, error) {
|
||||
return net.LookupIP(host)
|
||||
}
|
||||
|
||||
// SetResolveFunc 注入自定义解析器(传 nil 忽略,保持默认 net.LookupIP)。
|
||||
func SetResolveFunc(f ResolveFunc) {
|
||||
if f != nil {
|
||||
resolveHostFn = f
|
||||
}
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables SSRF protection globally.
|
||||
// SetEnabled 全局启用或禁用 SSRF 防护。
|
||||
// Typically called during configuration loading (readConfig).
|
||||
@@ -99,7 +116,7 @@ func IsPrivateIP(ip net.IP) bool {
|
||||
// Used in handlers to short-circuit requests to internal hosts before making outbound connections.
|
||||
// 用于在发起出站连接前,在处理器中短路对内部主机的请求。
|
||||
func HasLocalOrPrivateIP(host string) bool {
|
||||
ips, err := net.LookupIP(host)
|
||||
ips, err := resolveHostFn(context.Background(), host)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -142,7 +159,7 @@ func ValidateOutboundTarget(ctx context.Context, targetURL string) (context.Cont
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return ctx, fmt.Errorf("invalid scheme: %s", parsed.Scheme)
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
ips, err := resolveHostFn(ctx, host)
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
@@ -179,7 +196,7 @@ func SecureCheckRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("stopped after 10 redirects")
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
ips, err := resolveHostFn(req.Context(), host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+296
-40
@@ -2,12 +2,14 @@ package webtest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -19,8 +21,10 @@ import (
|
||||
var (
|
||||
// dnsServers 普通 DNS 查询服务器列表(主从:第一个为主,主失败自动切换后续从服务器)
|
||||
dnsServers = []string{"119.28.28.28:53"}
|
||||
// dnssecServers DNSSEC 专用服务器列表(未配置时沿用 dnsServers)
|
||||
dnssecServers []string
|
||||
// dnssecServers DNSSEC 专用服务器列表:默认内置实测支持 DO 位的境外解析器
|
||||
//(国内公共 DNS 带 DO 也不返回 RRSIG,普通解析回退会让验签永远失败,见 dnssec.go)。
|
||||
// 网络屏蔽/劫持这些 UDP 53 时请配置 dnssec-server 指向可达的解析器。
|
||||
dnssecServers = defaultDOCapableServers
|
||||
)
|
||||
|
||||
// SetDNSServer 设置DNS服务器地址(逗号分隔多地址:第一个为主,主失败自动切换后续从服务器)。
|
||||
@@ -102,17 +106,230 @@ const (
|
||||
defaultDoHEndpoint = "https://doh.pub/dns-query"
|
||||
)
|
||||
|
||||
// queryDNS 普通 DNS 查询:主从 failover——按配置顺序依次尝试每个服务器,
|
||||
// 当前服务器查询失败(网络错误/超时)自动切换下一个;全部失败返回最后一个错误。
|
||||
func queryDNS(msg *dns.Msg) (*dns.Msg, error) {
|
||||
return queryWithServers(msg, dnsServers)
|
||||
// NormalizeServers 解析「逗号分隔的 DNS 服务器」字符串并做格式校验,规则与配置侧一致:
|
||||
// 每项为 "ip:port"(UDP/TCP)或 http(s):// DoH URL;非法项丢弃(记 Warn)。全非法时返回空。
|
||||
// 供请求级参数(dns / dns4 / dns6)复用,避免两套校验漂移。
|
||||
func NormalizeServers(s string) []string {
|
||||
return validServers(splitServers(s))
|
||||
}
|
||||
|
||||
// queryDNSSEC DNSSEC 查询:优先使用专用服务器(dnssec-server),未配置则沿用普通 dns-server
|
||||
func queryDNSSEC(msg *dns.Msg) (*dns.Msg, error) {
|
||||
if len(dnssecServers) > 0 {
|
||||
return queryWithServers(msg, dnssecServers)
|
||||
// ---- 请求级解析配置:dns*/resolve* ----
|
||||
//
|
||||
// dns=1.1.1.1:53 两栈都用这个服务器(换服务器去问)
|
||||
// dns4=1.1.1.1:53,dns6=[2400::1]:53 按栈覆盖(优先于 dns=)
|
||||
// resolve=example.com:1.2.3.4 两栈都把 example.com 解析成 1.2.3.4(直接改答案)
|
||||
// resolve4=example.com:1.2.3.4 只 IPv4 侧生效(优先于 resolve=)
|
||||
// resolve6=example.com:[::1] 只 IPv6 侧生效(优先于 resolve=)
|
||||
// resolve=www.example.com:origin.net 目标是域名时把它当作新的 host 再解析
|
||||
//
|
||||
// 只在「域名 → IP」这一步生效:detail/ssl/speed(经 DialContext)、tcping、SSRF 预检;
|
||||
// whois 与 dns 拨测不接 resolve*(前者只做归属查询,后者问的就是真实解析)。
|
||||
//
|
||||
// 为什么走 context:HTTP 拨测的解析发生在 net/http 的 DialContext 里,那里只有 ctx、
|
||||
// 没有直接入参;存进 ctx 后 DialContext 与 SSRF 预检便能同源读到同一份配置。
|
||||
|
||||
// ReqResolve 一次拨测的请求级解析配置。零值 = 没有任何请求级覆盖。
|
||||
type ReqResolve struct {
|
||||
dns []string // dns=
|
||||
dns4 []string // dns4=
|
||||
dns6 []string // dns6=
|
||||
|
||||
resolve map[string]string // resolve=:域名 → IP 或另一域名
|
||||
resolve4 map[string]string // resolve4=
|
||||
resolve6 map[string]string // resolve6=
|
||||
}
|
||||
|
||||
// ParseReqResolve 从 query 解析全部 9 个参数(dns/dns4/dns6、resolve/resolve4/resolve6)。
|
||||
// 参数存在但**全部项非法**时报错(handler 返 400),避免「看起来指定了、实际被静默忽略」;
|
||||
// 缺省/为空则视为不覆盖。
|
||||
func ParseReqResolve(get func(string) string) (ReqResolve, error) {
|
||||
var out ReqResolve
|
||||
// DNS 服务器:逗号分隔的 ip:port 或 DoH URL
|
||||
for _, item := range []struct {
|
||||
key string
|
||||
dst *[]string
|
||||
}{
|
||||
{"dns", &out.dns}, {"dns4", &out.dns4}, {"dns6", &out.dns6},
|
||||
} {
|
||||
if raw := strings.TrimSpace(get(item.key)); raw != "" {
|
||||
if list := NormalizeServers(raw); len(list) > 0 {
|
||||
*item.dst = list
|
||||
} else {
|
||||
return ReqResolve{}, fmt.Errorf("%s must be a comma-separated list of ip:port or DoH URLs", item.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 强制解析:逗号分隔的 域名:IP 或 域名:域名(后者按新 host 再解析)
|
||||
for _, item := range []struct {
|
||||
key string
|
||||
dst *map[string]string
|
||||
}{
|
||||
{"resolve", &out.resolve}, {"resolve4", &out.resolve4}, {"resolve6", &out.resolve6},
|
||||
} {
|
||||
if raw := strings.TrimSpace(get(item.key)); raw != "" {
|
||||
if m := parseResolveMap(raw); len(m) > 0 {
|
||||
*item.dst = m
|
||||
} else {
|
||||
return ReqResolve{}, fmt.Errorf("%s must be a comma-separated list of host:ip or host:cname", item.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseResolveMap 把「域名:目标,域名:目标」切成映射表,非法项丢弃。
|
||||
// 每组按**第一个冒号**切:域名不含冒号,IPv6 目标可带或不带方括号。
|
||||
func parseResolveMap(s string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, item := range strings.Split(s, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
idx := strings.Index(item, ":")
|
||||
if idx <= 0 || idx == len(item)-1 {
|
||||
continue
|
||||
}
|
||||
host, target := canonicalHost(item[:idx]), strings.TrimSpace(item[idx+1:])
|
||||
if host == "" || strings.ContainsAny(host, " /\\") || !validResolveTarget(target) {
|
||||
continue
|
||||
}
|
||||
out[host] = target
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// canonicalHost 归一化主机名:去首尾空白、转小写、去结尾的点,便于与查询域名匹配。
|
||||
func canonicalHost(host string) string {
|
||||
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
}
|
||||
|
||||
// validResolveTarget 目标必须是 IP 或形如域名的字符串(含点、无空格、无端口)。
|
||||
func validResolveTarget(target string) bool {
|
||||
t := strings.Trim(target, "[]")
|
||||
if net.ParseIP(t) != nil {
|
||||
return true
|
||||
}
|
||||
// 非 IP 却含冒号:视为「带端口」的误用,丢弃(映射不需要端口)
|
||||
if strings.Contains(t, ":") || !strings.Contains(t, ".") || strings.ContainsAny(t, " /\\") {
|
||||
return false
|
||||
}
|
||||
for _, r := range t {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' ||
|
||||
r == '.' || r == '-' || r == '_' || r == '*' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Empty 无任何请求级覆盖(此时才允许走共享缓存)。
|
||||
func (r ReqResolve) Empty() bool {
|
||||
return len(r.dns)+len(r.dns4)+len(r.dns6)+len(r.resolve)+len(r.resolve4)+len(r.resolve6) == 0
|
||||
}
|
||||
|
||||
// Servers 按栈名取本次生效的 DNS 服务器:stack 为 "ipv4"/"ipv6" 时优先取对应覆盖,
|
||||
// 其余(含空串)取 dns= 默认;最终为 nil 表示用配置 dns-server。
|
||||
// 供 whois、dns 拨测等按记录类型选栈的调用方使用。
|
||||
func (r ReqResolve) Servers(stack string) []string {
|
||||
switch stack {
|
||||
case "ipv4":
|
||||
if len(r.dns4) > 0 {
|
||||
return r.dns4
|
||||
}
|
||||
case "ipv6":
|
||||
if len(r.dns6) > 0 {
|
||||
return r.dns6
|
||||
}
|
||||
}
|
||||
return r.dns
|
||||
}
|
||||
|
||||
// Override 返回该域名在指定族下的强制解析目标;"" 表示未命中(照常走 DNS)。
|
||||
// 优先级与 dns* 对称:resolve4/resolve6 非空时覆盖 resolve。
|
||||
// 目标是 IP 还是域名由调用方判断——IP 直连、域名当作新 host 再解析。
|
||||
func (r ReqResolve) Override(host string, wantV6 bool) string {
|
||||
m := r.resolve
|
||||
if wantV6 && len(r.resolve6) > 0 {
|
||||
m = r.resolve6
|
||||
} else if !wantV6 && len(r.resolve4) > 0 {
|
||||
m = r.resolve4
|
||||
}
|
||||
return m[canonicalHost(host)]
|
||||
}
|
||||
|
||||
// CacheKey 返回请求级配置的规范化片段,拼在拨测缓存键末尾。
|
||||
// 零值返回空串(缓存键与「没有任何请求级参数」时完全一致);
|
||||
// 否则形如 ";dns=1.1.1.1:53,8.8.8.8:53;resolve6=example.com:[::1]"。
|
||||
//
|
||||
// 逐字段拼接而不是 join 结构体:字段名与顺序由这里固定,避免参数书写顺序
|
||||
// (dns= 在前还是 dns6= 在前)把同一个请求拆成两条缓存。
|
||||
func (r ReqResolve) CacheKey() string {
|
||||
var b strings.Builder
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
vals []string
|
||||
}{
|
||||
{"dns", r.dns}, {"dns4", r.dns4}, {"dns6", r.dns6},
|
||||
} {
|
||||
if len(f.vals) == 0 {
|
||||
continue
|
||||
}
|
||||
b.WriteString(";")
|
||||
b.WriteString(f.name)
|
||||
b.WriteString("=")
|
||||
b.WriteString(strings.Join(f.vals, ","))
|
||||
}
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
vals map[string]string
|
||||
}{
|
||||
{"resolve", r.resolve}, {"resolve4", r.resolve4}, {"resolve6", r.resolve6},
|
||||
} {
|
||||
if len(f.vals) == 0 {
|
||||
continue
|
||||
}
|
||||
// map 迭代顺序随机:必须按键排序,否则同一请求每次拼出的键都不同,缓存永远命不中
|
||||
keys := make([]string, 0, len(f.vals))
|
||||
for k := range f.vals {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
pairs := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
pairs = append(pairs, k+":"+f.vals[k])
|
||||
}
|
||||
b.WriteString(";")
|
||||
b.WriteString(f.name)
|
||||
b.WriteString("=")
|
||||
b.WriteString(strings.Join(pairs, ","))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// reqResolveCtxKey 请求级解析配置在 context 中的键。
|
||||
type reqResolveCtxKey struct{}
|
||||
|
||||
// WithReqResolve 把请求级解析配置写进 context(空配置原样返回,零开销)。
|
||||
func WithReqResolve(ctx context.Context, r ReqResolve) context.Context {
|
||||
if r.Empty() {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, reqResolveCtxKey{}, r)
|
||||
}
|
||||
|
||||
// ReqResolveFromContext 取出请求级解析配置(零值 = 无覆盖)。
|
||||
func ReqResolveFromContext(ctx context.Context) ReqResolve {
|
||||
if ctx == nil {
|
||||
return ReqResolve{}
|
||||
}
|
||||
if v, ok := ctx.Value(reqResolveCtxKey{}).(ReqResolve); ok {
|
||||
return v
|
||||
}
|
||||
return ReqResolve{}
|
||||
}
|
||||
|
||||
// queryDNS 普通 DNS 查询:使用配置的 dns-server(主从 failover,见 queryWithServers)。
|
||||
// resolveTyped 未收到请求级 servers 时走这里。
|
||||
func queryDNS(msg *dns.Msg) (*dns.Msg, error) {
|
||||
return queryWithServers(msg, dnsServers)
|
||||
}
|
||||
|
||||
@@ -206,13 +423,21 @@ type DNSResult struct {
|
||||
// 即视为一次有效查询,将 Rcode 透出并返回 200;仅在传输/网络层失败(超时、服务器不可达、
|
||||
// DoH 异常)时才返回 error(上层据此返回 500)。避免把合法的负响应误判为节点故障。
|
||||
// extract 从一个 RR 中抽取记录字符串(TXT 可能含多条),返回 (values, ttl, matched)。
|
||||
func resolveTyped(domain, name string, qtype uint16, extract func(dns.RR) ([]string, uint32, bool)) (DNSResult, error) {
|
||||
// servers 为请求级 DNS 覆盖:非空时用它(queryWithServers),为空则用配置 dns-server(queryDNS)。
|
||||
func resolveTyped(domain, name string, qtype uint16, servers []string, extract func(dns.RR) ([]string, uint32, bool)) (DNSResult, error) {
|
||||
start := time.Now()
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(name), qtype)
|
||||
result := DNSResult{Domain: domain}
|
||||
|
||||
response, err := queryDNS(msg)
|
||||
// 请求级 servers 优先;未传(nil/空)走配置 dns-server。
|
||||
var response *dns.Msg
|
||||
var err error
|
||||
if len(servers) > 0 {
|
||||
response, err = queryWithServers(msg, servers)
|
||||
} else {
|
||||
response, err = queryDNS(msg)
|
||||
}
|
||||
result.Duration = time.Since(start).Seconds() * 1000
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query DNS", "domain", domain, "qtype", qtype, "error", err)
|
||||
@@ -235,8 +460,12 @@ func resolveTyped(domain, name string, qtype uint16, extract func(dns.RR) ([]str
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ResolveARecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeA, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
// ---- 记录解析:每类一个函数,第三个参数 servers 为「请求级 DNS 覆盖」 ----
|
||||
//
|
||||
// servers 为空 → 走 resolveTyped 的 queryDNS(配置 dns-server);非空 → queryWithServers(请求级覆盖)。
|
||||
|
||||
func ResolveARecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeA, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if a, ok := rr.(*dns.A); ok {
|
||||
return []string{a.A.String()}, a.Header().Ttl, true
|
||||
}
|
||||
@@ -244,8 +473,8 @@ func ResolveARecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveAAAARecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeAAAA, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveAAAARecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeAAAA, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if a, ok := rr.(*dns.AAAA); ok {
|
||||
return []string{a.AAAA.String()}, a.Header().Ttl, true
|
||||
}
|
||||
@@ -253,8 +482,8 @@ func ResolveAAAARecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveTXTRecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeTXT, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveTXTRecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeTXT, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if txt, ok := rr.(*dns.TXT); ok {
|
||||
// TXT 一条 RR 可能含多段字符串,保持原有「逐段单独成项」的行为
|
||||
return txt.Txt, txt.Header().Ttl, true
|
||||
@@ -263,8 +492,8 @@ func ResolveTXTRecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveNSRecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeNS, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveNSRecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeNS, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if ns, ok := rr.(*dns.NS); ok {
|
||||
return []string{ns.Ns}, ns.Header().Ttl, true
|
||||
}
|
||||
@@ -272,8 +501,8 @@ func ResolveNSRecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveCNAMERecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeCNAME, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveCNAMERecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeCNAME, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if cname, ok := rr.(*dns.CNAME); ok {
|
||||
return []string{cname.Target}, cname.Header().Ttl, true
|
||||
}
|
||||
@@ -281,8 +510,8 @@ func ResolveCNAMERecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveMXRecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeMX, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveMXRecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeMX, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if mx, ok := rr.(*dns.MX); ok {
|
||||
return []string{mx.Mx}, mx.Header().Ttl, true
|
||||
}
|
||||
@@ -290,8 +519,8 @@ func ResolveMXRecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveSRVRecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeSRV, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveSRVRecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeSRV, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if srv, ok := rr.(*dns.SRV); ok {
|
||||
return []string{srv.Target}, srv.Header().Ttl, true
|
||||
}
|
||||
@@ -299,8 +528,8 @@ func ResolveSRVRecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveCAARecord(domain string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeCAA, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
func ResolveCAARecord(domain string, servers []string) (DNSResult, error) {
|
||||
return resolveTyped(domain, domain, dns.TypeCAA, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if caa, ok := rr.(*dns.CAA); ok {
|
||||
return []string{caa.Value}, caa.Header().Ttl, true
|
||||
}
|
||||
@@ -308,13 +537,13 @@ func ResolveCAARecord(domain string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResolvePTRRecord(ip string) (DNSResult, error) {
|
||||
func ResolvePTRRecord(ip string, servers []string) (DNSResult, error) {
|
||||
ptrName, err := dns.ReverseAddr(ip)
|
||||
if err != nil {
|
||||
slog.Warn("Invalid IP address for PTR query", "ip", ip, "error", err)
|
||||
return DNSResult{Domain: ip, Record: []string{}}, fmt.Errorf("invalid IP address: %v", err)
|
||||
}
|
||||
return resolveTyped(ip, ptrName, dns.TypePTR, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
return resolveTyped(ip, ptrName, dns.TypePTR, servers, func(rr dns.RR) ([]string, uint32, bool) {
|
||||
if ptr, ok := rr.(*dns.PTR); ok {
|
||||
return []string{ptr.Ptr}, ptr.Header().Ttl, true
|
||||
}
|
||||
@@ -322,6 +551,32 @@ func ResolvePTRRecord(ip string) (DNSResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// ResolveByType 按记录类型名(a/aaaa/cname/mx/ns/txt/srv/ptr/caa)解析,servers 为请求级覆盖。
|
||||
// DNS 拨测的 HTTP 路由与 WS 报文统一走这里,避免两处各维护一套 switch。
|
||||
func ResolveByType(domain, recodeType string, servers []string) (DNSResult, error) {
|
||||
switch recodeType {
|
||||
case "a":
|
||||
return ResolveARecord(domain, servers)
|
||||
case "aaaa":
|
||||
return ResolveAAAARecord(domain, servers)
|
||||
case "cname":
|
||||
return ResolveCNAMERecord(domain, servers)
|
||||
case "mx":
|
||||
return ResolveMXRecord(domain, servers)
|
||||
case "ns":
|
||||
return ResolveNSRecord(domain, servers)
|
||||
case "srv":
|
||||
return ResolveSRVRecord(domain, servers)
|
||||
case "txt":
|
||||
return ResolveTXTRecord(domain, servers)
|
||||
case "ptr":
|
||||
return ResolvePTRRecord(domain, servers)
|
||||
case "caa":
|
||||
return ResolveCAARecord(domain, servers)
|
||||
}
|
||||
return DNSResult{}, fmt.Errorf("unsupported record type: %s", recodeType)
|
||||
}
|
||||
|
||||
// DNSFullResult 完整的DNS查询结果(不包含PTR,PTR需单独查询)
|
||||
type DNSFullResult struct {
|
||||
Domain string `json:"domain"`
|
||||
@@ -335,8 +590,9 @@ type DNSFullResult struct {
|
||||
CAA DNSResult `json:"caa"`
|
||||
}
|
||||
|
||||
// ResolveARecordllDNSRecords 并行查询所有主流DNS记录类型(不包含PTR)
|
||||
func ResolveARecordllDNSRecords(domain string) DNSFullResult {
|
||||
// ResolveARecordllDNSRecords 并行查询所有主流DNS记录类型(不包含PTR)。
|
||||
// servers 为请求级 DNS 覆盖(空则用配置 dns-server)。
|
||||
func ResolveARecordllDNSRecords(domain string, servers []string) DNSFullResult {
|
||||
result := DNSFullResult{Domain: domain}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@@ -344,42 +600,42 @@ func ResolveARecordllDNSRecords(domain string) DNSFullResult {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result.A, _ = ResolveARecord(domain)
|
||||
result.A, _ = ResolveARecord(domain, servers)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result.AAAA, _ = ResolveAAAARecord(domain)
|
||||
result.AAAA, _ = ResolveAAAARecord(domain, servers)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result.CNAME, _ = ResolveCNAMERecord(domain)
|
||||
result.CNAME, _ = ResolveCNAMERecord(domain, servers)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result.MX, _ = ResolveMXRecord(domain)
|
||||
result.MX, _ = ResolveMXRecord(domain, servers)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result.NS, _ = ResolveNSRecord(domain)
|
||||
result.NS, _ = ResolveNSRecord(domain, servers)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result.TXT, _ = ResolveTXTRecord(domain)
|
||||
result.TXT, _ = ResolveTXTRecord(domain, servers)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result.SRV, _ = ResolveSRVRecord(domain)
|
||||
result.SRV, _ = ResolveSRVRecord(domain, servers)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result.CAA, _ = ResolveCAARecord(domain)
|
||||
result.CAA, _ = ResolveCAARecord(domain, servers)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
+119
-36
@@ -3,24 +3,60 @@ package webtest
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// —— DNSSEC 默认解析服务器 ——
|
||||
//
|
||||
// 根因(实测):国内公共解析器(DNSPod 119.28.28.28、doh.pub、AliDNS 223.5.5.5、114DNS)
|
||||
// 即使查询带 DO 位也不在应答里返回 RRSIG——DNSKEY 能查到、RRSIG 永远缺席。而
|
||||
// dnssec 查询若回退到 dns-server(默认 119.28.28.28 或系统 DNS,都是国内递归),
|
||||
// 验签流程永远失败。所以 DNSSEC 的默认服务器直接内置这份实测支持 DO 位的境外列表,
|
||||
// 不再回退到国内解析器;境内网络若屏蔽/劫持了这些 UDP 53,请在节点配置 dnssec-server。
|
||||
//
|
||||
// 顺序即主从顺序,v4/v6 交错(同厂相邻):单栈节点上错误协议族的拨号是
|
||||
// "network unreachable" 秒级失败,failover 到下一台几乎零成本,双栈节点则
|
||||
// 自动绕过被劫持/丢包的那一族。国内家宽实测(江苏移动 v4)8.8.8.8 主 IP 存在
|
||||
// 随机丢包,1.1.1.1 更稳故放首位。注意境外 DoH(443)在国内普遍被阻断,全部走 53。
|
||||
var defaultDOCapableServers = []string{
|
||||
"1.1.1.1:53", // Cloudflare v4
|
||||
"[2606:4700:4700::1111]:53", // Cloudflare v6
|
||||
"8.8.8.8:53", // Google v4
|
||||
"[2001:4860:4860::8888]:53", // Google v6
|
||||
"8.8.4.4:53", // Google 2 v4
|
||||
"[2001:4860:4860::8844]:53", // Google 2 v6
|
||||
"9.9.9.9:53", // Quad9 v4
|
||||
"[2620:fe::fe]:53", // Quad9 v6
|
||||
"208.67.222.222:53", // OpenDNS v4
|
||||
"[2620:119:35::35]:53", // OpenDNS v6
|
||||
"208.67.220.220:53", // OpenDNS 2 v4
|
||||
"149.112.112.112:53", // Quad9 2 v4
|
||||
"[2620:fe::9]:53", // Quad9 2 v6
|
||||
"[2620:119:53::53]:53", // OpenDNS 2 v6
|
||||
}
|
||||
|
||||
// queryDNSSEC DNSSEC 查询:走内置 DO 友好列表(或 dnssec-server 配置,配置优先),
|
||||
// 由 queryWithServers 的主从 failover 保证可用性。
|
||||
func queryDNSSEC(msg *dns.Msg) (*dns.Msg, error) {
|
||||
return queryWithServers(msg, dnssecServers)
|
||||
}
|
||||
|
||||
// DNSSECResult 包含 DNSSEC 验证的结果
|
||||
type DNSSECResult struct {
|
||||
Domain string `json:"domain"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Valid bool `json:"valid"`
|
||||
HasRRSIG bool `json:"has_rrsig"`
|
||||
HasDNSKEY bool `json:"has_dnskey"`
|
||||
HasDS bool `json:"has_ds"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
KeyTag uint16 `json:"key_tag"`
|
||||
SignerName string `json:"signer_name"`
|
||||
Validation string `json:"validation"`
|
||||
Duration float64 `json:"duration"`
|
||||
Domain string `json:"domain"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Valid bool `json:"valid"`
|
||||
HasRRSIG bool `json:"has_rrsig"`
|
||||
HasDNSKEY bool `json:"has_dnskey"`
|
||||
HasDS bool `json:"has_ds"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
KeyTag uint16 `json:"key_tag"`
|
||||
SignerName string `json:"signer_name"`
|
||||
Validation string `json:"validation"`
|
||||
Duration float64 `json:"duration"`
|
||||
}
|
||||
|
||||
// ResolveDNSSEC 执行 DNSSEC 验证
|
||||
@@ -35,7 +71,6 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
Domain: domain,
|
||||
}
|
||||
|
||||
|
||||
// 1. 查询 DNSKEY 记录
|
||||
msgDNSKEY := new(dns.Msg)
|
||||
msgDNSKEY.SetQuestion(dns.Fqdn(domain), dns.TypeDNSKEY)
|
||||
@@ -56,25 +91,64 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
|
||||
var dnskeyRRset []dns.RR
|
||||
var dnskeyList []*dns.DNSKEY
|
||||
// DO 位生效时,DNSKEY 应答自带 RRSIG(DNSKEY)——已签名域名的确定信号,
|
||||
// 也是 TLD / 无 A 记录域名唯一的验签素材(它们没有 A 应答可拿 RRSIG(A))
|
||||
var rrsigDNSKEYList []*dns.RRSIG
|
||||
for _, ans := range responseDNSKEY.Answer {
|
||||
if dnskey, ok := ans.(*dns.DNSKEY); ok {
|
||||
dnskeyRRset = append(dnskeyRRset, dnskey)
|
||||
dnskeyList = append(dnskeyList, dnskey)
|
||||
switch v := ans.(type) {
|
||||
case *dns.DNSKEY:
|
||||
dnskeyRRset = append(dnskeyRRset, v)
|
||||
dnskeyList = append(dnskeyList, v)
|
||||
result.HasDNSKEY = true
|
||||
case *dns.RRSIG:
|
||||
rrsigDNSKEYList = append(rrsigDNSKEYList, v)
|
||||
}
|
||||
}
|
||||
if len(rrsigDNSKEYList) > 0 {
|
||||
result.HasRRSIG = true
|
||||
}
|
||||
|
||||
if len(dnskeyRRset) > 0 {
|
||||
result.KeyTag = dnskeyList[0].KeyTag()
|
||||
result.Algorithm = dnskeyList[0].Algorithm
|
||||
}
|
||||
|
||||
// 2. 查询 A 记录(带 DO 位)
|
||||
// 2. A 与 DS 并行查询。DS 记录在父区(如 .com),**任何**验签结论(通过或失败)
|
||||
// 都应带上它——原实现把 DS 查询放在函数最后,验签成功时提前 return,
|
||||
// has_ds 永远是 false(只有验证失败的路径才查得到 DS)。
|
||||
msgA := new(dns.Msg)
|
||||
msgA.SetQuestion(dns.Fqdn(domain), dns.TypeA)
|
||||
msgA.SetEdns0(4096, true)
|
||||
msgDS := new(dns.Msg)
|
||||
msgDS.SetQuestion(dns.Fqdn(domain), dns.TypeDS)
|
||||
msgDS.SetEdns0(4096, true)
|
||||
|
||||
// rrsigList 提升到函数作用域:A 应答里的 RRSIG(A) 在「A 验签失败」的最终判定里也要用
|
||||
var rrsigList []*dns.RRSIG
|
||||
var responseA *dns.Msg
|
||||
var responseDS *dns.Msg
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
responseA, err = queryDNSSEC(msgA)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
responseDS, _ = queryDNSSEC(msgDS)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
// DS 结论先于一切 early return 就绪
|
||||
if responseDS != nil && responseDS.Rcode == dns.RcodeSuccess {
|
||||
for _, ans := range responseDS.Answer {
|
||||
if _, ok := ans.(*dns.DS); ok {
|
||||
result.HasDS = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
responseA, err := queryDNSSEC(msgA)
|
||||
if err == nil && responseA.Rcode == dns.RcodeSuccess {
|
||||
var aRRset []dns.RR
|
||||
for _, ans := range responseA.Answer {
|
||||
@@ -83,7 +157,6 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
var rrsigList []*dns.RRSIG
|
||||
for _, ans := range responseA.Answer {
|
||||
if rrsig, ok := ans.(*dns.RRSIG); ok {
|
||||
rrsigList = append(rrsigList, rrsig)
|
||||
@@ -91,7 +164,8 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 用 DNSKEY 逐条验证 RRSIG
|
||||
// 3. 用 DNSKEY 逐条验证 A 的 RRSIG。不在验证失败时提前返回:
|
||||
// 下面还有 DNSKEY 自验签兜底
|
||||
for _, rrsig := range rrsigList {
|
||||
for _, dnskey := range dnskeyList {
|
||||
if keyErr := rrsig.Verify(dnskey, aRRset); keyErr == nil {
|
||||
@@ -105,28 +179,37 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(rrsigList) > 0 && len(dnskeyRRset) > 0 {
|
||||
result.Enabled = true
|
||||
result.Valid = false
|
||||
result.Validation = fmt.Sprintf("RRSIG 验证失败: %d 条 RRSIG, %d 个 DNSKEY,无匹配签名", len(rrsigList), len(dnskeyRRset))
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 查询 DS 记录
|
||||
msgDS := new(dns.Msg)
|
||||
msgDS.SetQuestion(dns.Fqdn(domain), dns.TypeDS)
|
||||
responseDS, _ := queryDNSSEC(msgDS)
|
||||
if responseDS != nil && responseDS.Rcode == dns.RcodeSuccess {
|
||||
for _, ans := range responseDS.Answer {
|
||||
if _, ok := ans.(*dns.DS); ok {
|
||||
result.HasDS = true
|
||||
break
|
||||
// 3.5 DNSKEY 自验签:用 DNSKEY 验证 DNSKEY 自己的 RRSIG(RFC 4035 §5.3.3 的
|
||||
// 自签检查)。TLD(如 cn)与无 A 记录的已签名域拿不到 RRSIG(A),这是它们的
|
||||
// 唯一验签路径;有 A 记录但 A 验签失败的域走到这里仍失败才算真失败。
|
||||
if len(rrsigDNSKEYList) > 0 && len(dnskeyRRset) > 0 {
|
||||
for _, rrsig := range rrsigDNSKEYList {
|
||||
for _, dnskey := range dnskeyList {
|
||||
if keyErr := rrsig.Verify(dnskey, dnskeyRRset); keyErr == nil {
|
||||
result.Enabled = true
|
||||
result.Valid = true
|
||||
result.Algorithm = dnskey.Algorithm
|
||||
result.KeyTag = dnskey.KeyTag()
|
||||
result.SignerName = rrsig.SignerName
|
||||
result.Validation = fmt.Sprintf("DNSSEC 验证通过 (DNSKEY 自验签, 算法: %d, KeyTag: %d)", dnskey.Algorithm, dnskey.KeyTag())
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A 验签与 DNSKEY 自验签都未通过:只要任一应答里有 RRSIG 就说明签名存在但无效
|
||||
//(真失败,如数据被污染/密钥轮换不完整),返回明确的失败结论
|
||||
if (len(rrsigList) > 0 || len(rrsigDNSKEYList) > 0) && len(dnskeyRRset) > 0 {
|
||||
result.Enabled = true
|
||||
result.Valid = false
|
||||
result.Validation = fmt.Sprintf("RRSIG 验证失败: %d 条 RRSIG(A), %d 条 RRSIG(DNSKEY), %d 个 DNSKEY,无匹配签名", len(rrsigList), len(rrsigDNSKEYList), len(dnskeyRRset))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 4. 结论分支(DS 已在并行阶段查完并计入 HasDS)
|
||||
if result.HasRRSIG && result.HasDNSKEY {
|
||||
result.Enabled = true
|
||||
result.Valid = false
|
||||
@@ -138,7 +221,7 @@ func ResolveDNSSEC(domain string) (*DNSSECResult, error) {
|
||||
} else if result.HasDNSKEY {
|
||||
result.Enabled = false
|
||||
result.Valid = false
|
||||
result.Validation = "存在 DNSKEY,但缺少 RRSIG"
|
||||
result.Validation = "存在 DNSKEY,但缺少 RRSIG(若确认该域名已签名,说明默认境外解析器在本网络不可达,请在节点配置 dnssec-server 指向可达的 DNSSEC 解析器,如 8.8.8.8:53)"
|
||||
} else {
|
||||
result.Enabled = false
|
||||
result.Valid = false
|
||||
|
||||
+13
-4
@@ -1,6 +1,7 @@
|
||||
package webtest
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -21,8 +22,8 @@ func CleanHostRecord(addr string) string {
|
||||
return addr
|
||||
}
|
||||
|
||||
// measureDNSTime 当 trace 未提供 DNS 耗时时的兜底测量
|
||||
func measureDNSTime(urlStr string, version string) float64 {
|
||||
// measureDNSTime 当 trace 未提供 DNS 耗时时的兜底测量。req 为请求级解析配置(dns 服务器)。
|
||||
func measureDNSTime(urlStr string, version string, req ReqResolve) float64 {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return 0
|
||||
@@ -31,12 +32,20 @@ func measureDNSTime(urlStr string, version string) float64 {
|
||||
if host == "" {
|
||||
return 0
|
||||
}
|
||||
// 强制解析:目标是 IP 时原域名根本不查 DNS,不能报一个凭空的耗时;
|
||||
// 目标是域名则改按新 host 计时(真实查询的是它)。
|
||||
if target := req.Override(host, version == "v6"); target != "" {
|
||||
host = strings.Trim(target, "[]")
|
||||
if net.ParseIP(host) != nil {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
start := time.Now()
|
||||
var dnsErr error
|
||||
if version == "v6" {
|
||||
_, dnsErr = ResolveAAAARecord(host)
|
||||
_, dnsErr = ResolveAAAARecord(host, req.Servers("ipv6"))
|
||||
} else {
|
||||
_, dnsErr = ResolveARecord(host)
|
||||
_, dnsErr = ResolveARecord(host, req.Servers("ipv4"))
|
||||
}
|
||||
if dnsErr != nil {
|
||||
return 0
|
||||
|
||||
@@ -10,8 +10,9 @@ import (
|
||||
)
|
||||
|
||||
// SpeedTest 网站测速(speed)。客户端由 main.go 经 SetHTTPClient 注入。
|
||||
func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
|
||||
ctx := context.Background()
|
||||
// req 为请求级解析配置(dns/dns4/dns6),经 ctx 传到 DialContext。
|
||||
func SpeedTest(url string, version string, req ReqResolve) (*WebsiteSpeedTestResult, error) {
|
||||
ctx := WithReqResolve(context.Background(), req)
|
||||
var err error
|
||||
ctx, err = lemon_ssrf.ValidateOutboundTarget(ctx, url)
|
||||
if err != nil {
|
||||
@@ -52,7 +53,7 @@ func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
|
||||
|
||||
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
|
||||
if dnsLookupTime == 0 {
|
||||
dnsLookupTime = measureDNSTime(url, version)
|
||||
dnsLookupTime = measureDNSTime(url, version, req)
|
||||
}
|
||||
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
|
||||
httpConnectTime := trace.ConnTime.Seconds() * 1000
|
||||
|
||||
+11
-2
@@ -10,8 +10,9 @@ import (
|
||||
)
|
||||
|
||||
// CheckSSL SSL 检查(ssl)。客户端由 main.go 经 SetHTTPClient 注入。
|
||||
func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
ctx := context.Background()
|
||||
// req 为请求级解析配置(dns/dns4/dns6),经 ctx 传到 DialContext。
|
||||
func CheckSSL(url string, version string, req ReqResolve) (*SSLCheckDetail, error) {
|
||||
ctx := WithReqResolve(context.Background(), req)
|
||||
var err error
|
||||
ctx, err = lemon_ssrf.ValidateOutboundTarget(ctx, url)
|
||||
if err != nil {
|
||||
@@ -49,6 +50,7 @@ func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
var isExpired bool
|
||||
var issuerOrganization []string
|
||||
var issuerCommonName, subjectCommonName, domain string
|
||||
var san []string
|
||||
|
||||
if rawResp.TLS != nil && len(rawResp.TLS.PeerCertificates) > 0 {
|
||||
cert = rawResp.TLS.PeerCertificates[0]
|
||||
@@ -59,6 +61,12 @@ func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
issuerCommonName = cert.Issuer.CommonName
|
||||
subjectCommonName = cert.Subject.CommonName
|
||||
domain = CleanHostRecord(cert.Subject.CommonName)
|
||||
// SAN:DNS 名在前、IP 在后(IP 站点证书可能只有 IP SAN)
|
||||
san = make([]string, 0, len(cert.DNSNames)+len(cert.IPAddresses))
|
||||
san = append(san, cert.DNSNames...)
|
||||
for _, ip := range cert.IPAddresses {
|
||||
san = append(san, ip.String())
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("no SSL certificate found")
|
||||
}
|
||||
@@ -77,6 +85,7 @@ func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
IssuerOrganization: issuerOrganization,
|
||||
IssuerCommonName: issuerCommonName,
|
||||
SubjectCommonName: subjectCommonName,
|
||||
SAN: san,
|
||||
IsReachable: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package webtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"lemon-ipw/ssrf"
|
||||
)
|
||||
|
||||
// InstallSSRFResolver 让 SSRF 出站校验走本包的自定义 DNS(含请求级覆盖)。
|
||||
// 必须在启动阶段调用一次(main.go)。
|
||||
//
|
||||
// 动机:不注入时 ssrf 用 net.LookupIP(系统解析器)做预检,而真正拨号走自定义 DNS —— 两套解析器,
|
||||
// 既多一次查询,结果也可能不一致;更关键的是,当调用方用请求级 dns= 指定了服务器、且目标域名只能
|
||||
// 靠该服务器解析时,系统解析器预检会先行失败,把请求直接毙掉,覆盖形同失效。
|
||||
//
|
||||
// 校验只需判定「是否私网」,故 A / AAAA 都查(任一命中私网即拦,口径与原先 net.LookupIP 一致)。
|
||||
// 请求级配置(DNS 服务器)取自 context,与真正拨号同源。
|
||||
func InstallSSRFResolver() {
|
||||
ssrf.SetResolveFunc(func(ctx context.Context, host string) ([]net.IP, error) {
|
||||
// 字面 IP 直接返回,无需解析
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return []net.IP{ip}, nil
|
||||
}
|
||||
|
||||
req := ReqResolveFromContext(ctx)
|
||||
|
||||
var ips []net.IP
|
||||
var firstErr error
|
||||
for _, wantV6 := range []bool{false, true} {
|
||||
// 强制解析命中时,预检要验「拨号真正会连的地址」,否则会拿原域名的
|
||||
// 真实解析去判定,预检与实际连接两个口径对不上(resolve= 也就白设了)。
|
||||
name := host
|
||||
if target := req.Override(name, wantV6); target != "" {
|
||||
name = strings.Trim(target, "[]")
|
||||
}
|
||||
if ip := net.ParseIP(name); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
continue
|
||||
}
|
||||
var res DNSResult
|
||||
var err error
|
||||
if wantV6 {
|
||||
res, err = ResolveAAAARecord(name, req.Servers("ipv6"))
|
||||
} else {
|
||||
res, err = ResolveARecord(name, req.Servers("ipv4"))
|
||||
}
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
for _, s := range res.Record {
|
||||
if ip := net.ParseIP(s); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 两栈都解析失败才报错;只要有一栈拿到 IP 就交给上层做私网判定
|
||||
if len(ips) == 0 && firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
return ips, nil
|
||||
})
|
||||
}
|
||||
+56
-40
@@ -34,53 +34,60 @@ type TCPingStats struct {
|
||||
Results []TCPingResult `json:"results"` // 每次测试的详细结果
|
||||
}
|
||||
|
||||
// resolveHost 将主机名解析为指定协议版本的 IP 地址
|
||||
// version 参数支持 "v4"(IPv4)和 "v6"(IPv6)
|
||||
// 如果 host 已经是 IP 地址(可带 []),直接返回,跳过 DNS 解析
|
||||
// ResolveHost 将主机名解析为指定协议版本的 IP 地址。
|
||||
// version 支持 "v4" / "v6";host 已是 IP(可带 [])时直接返回,跳过 DNS 解析。
|
||||
func ResolveHost(host string, version string) (string, error) {
|
||||
return ResolveHostWith(host, version, ReqResolve{})
|
||||
}
|
||||
|
||||
// ResolveHostWith 同 ResolveHost,但带请求级解析配置(req 零值 = 用配置 DNS)。
|
||||
// 字面 IP 不查 DNS,但仍要过私网校验(与 DNS 解析结果同口径)。
|
||||
func ResolveHostWith(host string, version string, req ReqResolve) (string, error) {
|
||||
// 强制解析:命中就把目标当作新的 host——IP 交给下面的字面 IP 分支,域名继续走 DNS。
|
||||
if target := req.Override(host, version == "v6"); target != "" {
|
||||
host = strings.Trim(target, "[]")
|
||||
}
|
||||
cleanHost := strings.Trim(host, "[]")
|
||||
if ip := net.ParseIP(cleanHost); ip != nil {
|
||||
if version == "v4" && ip.To4() != nil {
|
||||
return ip.String(), nil
|
||||
// SSRF:字面 IP 与 DNS 解析结果同口径,私网地址一律拒绝(tcping 是裸 TCP,
|
||||
// 不经过 DialContext,这里不做就完全没人拦)。放在族校验之前:安全判定
|
||||
// 优先于参数语义,也与 DialContext 里「先查私网、再拨号」的顺序一致。
|
||||
if ssrf.Enabled() && ssrf.IsPrivateIP(ip) {
|
||||
return "", fmt.Errorf("connection to private/internal address %s is not allowed", cleanHost)
|
||||
}
|
||||
if version == "v6" && ip.To4() == nil {
|
||||
return ip.String(), nil
|
||||
switch version {
|
||||
case "v4":
|
||||
if ip.To4() != nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
case "v6":
|
||||
if ip.To4() == nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("host %s is not a %s address", host, version)
|
||||
}
|
||||
|
||||
var ipStr string
|
||||
var result DNSResult
|
||||
var err error
|
||||
|
||||
if version == "v4" {
|
||||
var result DNSResult
|
||||
result, err = ResolveARecord(host)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("DNS lookup failed: %w", err)
|
||||
}
|
||||
if len(result.Record) == 0 {
|
||||
return "", fmt.Errorf("no %s address found for %s", version, host)
|
||||
}
|
||||
ipStr = result.Record[0]
|
||||
if version == "v6" {
|
||||
result, err = ResolveAAAARecord(host, req.Servers("ipv6"))
|
||||
} else {
|
||||
var result DNSResult
|
||||
result, err = ResolveAAAARecord(host)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("DNS lookup failed: %w", err)
|
||||
}
|
||||
if len(result.Record) == 0 {
|
||||
return "", fmt.Errorf("no %s address found for %s", version, host)
|
||||
}
|
||||
ipStr = result.Record[0]
|
||||
result, err = ResolveARecord(host, req.Servers("ipv4"))
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("DNS lookup failed: %w", err)
|
||||
}
|
||||
if len(result.Record) == 0 {
|
||||
return "", fmt.Errorf("no %s address found for %s", version, host)
|
||||
}
|
||||
ipStr := result.Record[0]
|
||||
|
||||
if ssrf.Enabled() {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip != nil && ssrf.IsPrivateIP(ip) {
|
||||
if ip := net.ParseIP(ipStr); ip != nil && ssrf.IsPrivateIP(ip) {
|
||||
return "", fmt.Errorf("connection to private/internal address %s is not allowed", ipStr)
|
||||
}
|
||||
}
|
||||
|
||||
return ipStr, nil
|
||||
}
|
||||
|
||||
@@ -97,13 +104,19 @@ func TCPing(host string, port string, version string, timeout time.Duration) (*T
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tcpingDial(ip, port, version, timeout), nil
|
||||
}
|
||||
|
||||
// tcpingDial 对已解析好的 IP 做一次 TCP 拨号,不再做 DNS 解析与 SSRF 校验。
|
||||
// 拆出来是为了让 TCPingRun 的多次探测复用同一个解析结果:原实现每轮都走 TCPing→
|
||||
// ResolveHost,既拖慢,又会因多 A 记录轮询导致 results 里每次连到不同 IP(与顶层 ip 不符)。
|
||||
func tcpingDial(ip string, port string, version string, timeout time.Duration) *TCPingResult {
|
||||
addr := ""
|
||||
switch version {
|
||||
case "v4":
|
||||
addr = fmt.Sprintf("%s:%s", ip, port)
|
||||
case "v6":
|
||||
addr = fmt.Sprintf("[%s]:%s", ip, port)
|
||||
|
||||
default:
|
||||
addr = fmt.Sprintf("%s:%s", ip, port)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
@@ -127,7 +140,7 @@ func TCPing(host string, port string, version string, timeout time.Duration) (*T
|
||||
result.RTT = float64(rtt.Microseconds()) / 1000.0
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return result
|
||||
}
|
||||
|
||||
// TCPingRun 执行多次 TCP 连接测试并返回统计结果
|
||||
@@ -141,7 +154,12 @@ func TCPing(host string, port string, version string, timeout time.Duration) (*T
|
||||
//
|
||||
// 返回 TCPingStats 包含统计信息
|
||||
func TCPingRun(host string, port string, count int, version string, timeout time.Duration, interval time.Duration) (*TCPingStats, error) {
|
||||
ip, err := ResolveHost(host, version)
|
||||
return TCPingRunWith(host, port, count, version, timeout, interval, ReqResolve{})
|
||||
}
|
||||
|
||||
// TCPingRunWith 同 TCPingRun,但带请求级解析配置(req 零值 = 用配置 DNS)。
|
||||
func TCPingRunWith(host string, port string, count int, version string, timeout time.Duration, interval time.Duration, req ReqResolve) (*TCPingStats, error) {
|
||||
ip, err := ResolveHostWith(host, version, req)
|
||||
if err != nil {
|
||||
return &TCPingStats{
|
||||
IP: "Error: " + err.Error(),
|
||||
@@ -168,10 +186,8 @@ func TCPingRun(host string, port string, count int, version string, timeout time
|
||||
successCount := 0
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
result, err := TCPing(host, port, version, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 复用开头解析出的 ip,不再每轮重新 ResolveHost
|
||||
result := tcpingDial(ip, port, version, timeout)
|
||||
|
||||
stats.Results = append(stats.Results, *result)
|
||||
|
||||
|
||||
@@ -31,8 +31,11 @@ type SSLCheckDetail struct {
|
||||
IssuerOrganization []string `json:"issuer_organization"`
|
||||
IssuerCommonName string `json:"issuer_common_name"`
|
||||
SubjectCommonName string `json:"subject_common_name"`
|
||||
IsExpired bool `json:"is_expired"`
|
||||
IsReachable bool `json:"is_reachable"`
|
||||
// SAN 证书主体别名(Subject Alternative Names):DNS 名在前、IP 在后,
|
||||
// 与 CN 相互独立——校验「证书覆盖了哪些域名」以这里为准
|
||||
SAN []string `json:"san"`
|
||||
IsExpired bool `json:"is_expired"`
|
||||
IsReachable bool `json:"is_reachable"`
|
||||
}
|
||||
|
||||
type WebsiteSpeedTestResult struct {
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
)
|
||||
|
||||
// CheckWebsite 网站检测(detail)。客户端由 main.go 经 SetHTTPClient 注入。
|
||||
func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
|
||||
ctx := context.Background()
|
||||
// req 为请求级解析配置(dns/dns4/dns6),经 ctx 传到 DialContext。
|
||||
func CheckWebsite(url string, version string, req ReqResolve) (*WebsiteCheckDetail, error) {
|
||||
ctx := WithReqResolve(context.Background(), req)
|
||||
var err error
|
||||
ctx, err = lemon_ssrf.ValidateOutboundTarget(ctx, url)
|
||||
if err != nil {
|
||||
@@ -50,7 +51,7 @@ func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
|
||||
|
||||
dnsLookupTime := trace.DNSLookup.Seconds() * 1000
|
||||
if dnsLookupTime == 0 {
|
||||
dnsLookupTime = measureDNSTime(url, version)
|
||||
dnsLookupTime = measureDNSTime(url, version, req)
|
||||
}
|
||||
tcpConnectTime := trace.TCPConnTime.Seconds() * 1000
|
||||
httpConnectTime := trace.ConnTime.Seconds() * 1000
|
||||
|
||||
+27
-30
@@ -7,7 +7,6 @@ import (
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lemon-ipw/ssrf"
|
||||
@@ -102,8 +101,10 @@ const whoisReferralTimeout = 3 * time.Second
|
||||
// QueryWhois 执行 WHOIS 查询并解析结构化数据
|
||||
// 主路径:likexian/whois 走「IANA → 注册局」两跳,**不追注册商转介**(转介由 chaseReferral 单独限时补),
|
||||
// 这样即便转介服务器不可达也只在 ~1s 内拿到注册局记录,不会干等满库的 30s 超时。
|
||||
// 兜底:主路径拿不到内容时才 fallback(内置 TLD 映射 → IANA 查服务器 → 自定义 DNS 解析 IP →
|
||||
// 兜底:主路径拿不到内容时才 fallback(内置 TLD 映射 → IANA 查服务器 → 系统解析器解析 IP →
|
||||
// Happy Eyeballs 双栈竞争拨号)。
|
||||
// whois 不走自定义 DNS(dns-server / 请求级 dns=):whois 服务器是稳定的公共基础设施,
|
||||
// 解析结果只用于建立连接、不作为拨测结论,没必要让 DNS 配置的故障面波及 whois。
|
||||
func QueryWhois(domain string) (*WhoisResult, error) {
|
||||
raw, err := queryWhoisRegistry(domain)
|
||||
|
||||
@@ -130,10 +131,10 @@ func QueryWhois(domain string) (*WhoisResult, error) {
|
||||
// queryWhoisRegistry 走 IANA → 注册局两跳取注册局记录,关闭库的转介链
|
||||
// (转介改由 chaseReferral 限时补查,避免被不可达的转介服务器拖满 30s)
|
||||
func queryWhoisRegistry(domain string) (string, error) {
|
||||
return whois.NewClient().
|
||||
client := whois.NewClient().
|
||||
SetDisableReferral(true).
|
||||
SetTimeout(whoisLibTimeout).
|
||||
Whois(domain)
|
||||
SetTimeout(whoisLibTimeout)
|
||||
return client.Whois(domain)
|
||||
}
|
||||
|
||||
// chaseReferral 从注册局响应里取出注册商转介服务器,限时补查一次
|
||||
@@ -266,33 +267,29 @@ func filterPublicIPs(ips []string) []string {
|
||||
return filtered
|
||||
}
|
||||
|
||||
// resolveWhoisServerIPs 使用 webtest/dns 的统一解析接口
|
||||
// 并行查询 A + AAAA(走自定义 DNS 服务器,不受系统 resolver 策略影响)
|
||||
// resolveWhoisServerIPs 用系统解析器(net.LookupIP)取 whois 服务器的 v4/v6 地址。
|
||||
// whois 不走自定义 DNS(dns-server / 请求级 dns=):whois 服务器是稳定的公共基础设施,
|
||||
// 解析结果只用于建立连接、不作为拨测结论,没必要让 DNS 配置的故障面波及 whois。
|
||||
func resolveWhoisServerIPs(server string) (v4IPs, v6IPs []string, err error) {
|
||||
var (
|
||||
aResult DNSResult
|
||||
aaaaResult DNSResult
|
||||
wg sync.WaitGroup
|
||||
aErr error
|
||||
aaaaErr error
|
||||
)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
aResult, aErr = ResolveARecord(server)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
aaaaResult, aaaaErr = ResolveAAAARecord(server)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
if aErr != nil && aaaaErr != nil {
|
||||
return nil, nil, fmt.Errorf("both A and AAAA DNS lookup failed for %s: A=%w, AAAA=%w", server, aErr, aaaaErr)
|
||||
ips, err := net.LookupIP(server)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
v4IPs = filterPublicIPs(aResult.Record)
|
||||
v6IPs = filterPublicIPs(aaaaResult.Record)
|
||||
var v4, v6 []string
|
||||
for _, ip := range ips {
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if ip.To4() != nil {
|
||||
v4 = append(v4, ip.String())
|
||||
} else {
|
||||
v6 = append(v6, ip.String())
|
||||
}
|
||||
}
|
||||
|
||||
v4IPs = filterPublicIPs(v4)
|
||||
v6IPs = filterPublicIPs(v6)
|
||||
|
||||
if len(v4IPs) == 0 && len(v6IPs) == 0 {
|
||||
return nil, nil, fmt.Errorf("no reachable public IPs for %s after SSRF filter", server)
|
||||
@@ -381,7 +378,7 @@ func whoisRetryWithFallback(domain string, firstErr error) (string, error) {
|
||||
return "", firstErr
|
||||
}
|
||||
|
||||
// 使用 ResolveA/AAAA(自定义 DNS)并行解析 v4/v6
|
||||
// 系统解析器并行拿 v4/v6(whois 不走自定义 DNS)
|
||||
v4IPs, v6IPs, err := resolveWhoisServerIPs(server)
|
||||
if err != nil {
|
||||
return "", firstErr
|
||||
|
||||
@@ -35,15 +35,23 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
switch apiType {
|
||||
case "detail":
|
||||
testURL := normalizeURL(raw)
|
||||
if cached, ok := websiteCache.Load(testURL); ok {
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return query[k] })
|
||||
if err != nil {
|
||||
return 400, map[string]string{"error": err.Error()}
|
||||
}
|
||||
// 缓存键带上请求级 DNS 覆盖(结果取决于所用服务器,参数不同不能共用)
|
||||
cacheKey := testURL + req.CacheKey()
|
||||
if cached, ok := websiteCache.Load(cacheKey); ok {
|
||||
entry := cached.(websiteCacheEntry)
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return 200, entry.result
|
||||
}
|
||||
websiteCache.Delete(testURL)
|
||||
websiteCache.Delete(cacheKey)
|
||||
}
|
||||
ipv4, ipv6 := wsDual[webtest.WebsiteCheckDetail](
|
||||
func(v string) (*webtest.WebsiteCheckDetail, error) { return webtest.CheckWebsite(testURL, v) },
|
||||
func(v string) (*webtest.WebsiteCheckDetail, error) {
|
||||
return webtest.CheckWebsite(testURL, v, req)
|
||||
},
|
||||
func(err error) *webtest.WebsiteCheckDetail {
|
||||
return &webtest.WebsiteCheckDetail{HostRecord: "Error: " + err.Error(), IsReachable: false}
|
||||
},
|
||||
@@ -55,20 +63,28 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
failed := dualAnyFailed(result.IPv4, result.IPv6, func(d *webtest.WebsiteCheckDetail) bool {
|
||||
return d != nil && !d.IsReachable
|
||||
})
|
||||
websiteCache.Store(testURL, websiteCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
websiteCache.Store(cacheKey, websiteCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
return 200, result
|
||||
|
||||
case "ssl":
|
||||
testURL := normalizeURL(raw)
|
||||
if cached, ok := sslCache.Load(testURL); ok {
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return query[k] })
|
||||
if err != nil {
|
||||
return 400, map[string]string{"error": err.Error()}
|
||||
}
|
||||
// 缓存键带上请求级 DNS 覆盖(结果取决于所用服务器,参数不同不能共用)
|
||||
cacheKey := testURL + req.CacheKey()
|
||||
if cached, ok := sslCache.Load(cacheKey); ok {
|
||||
entry := cached.(sslCacheEntry)
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return 200, entry.result
|
||||
}
|
||||
sslCache.Delete(testURL)
|
||||
sslCache.Delete(cacheKey)
|
||||
}
|
||||
ipv4, ipv6 := wsDual[webtest.SSLCheckDetail](
|
||||
func(v string) (*webtest.SSLCheckDetail, error) { return webtest.CheckSSL(testURL, v) },
|
||||
func(v string) (*webtest.SSLCheckDetail, error) {
|
||||
return webtest.CheckSSL(testURL, v, req)
|
||||
},
|
||||
func(err error) *webtest.SSLCheckDetail {
|
||||
return &webtest.SSLCheckDetail{HostRecord: "Error: " + err.Error(), IsExpired: true, IsReachable: false}
|
||||
},
|
||||
@@ -80,7 +96,7 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
failed := dualAnyFailed(result.IPv4, result.IPv6, func(d *webtest.SSLCheckDetail) bool {
|
||||
return d != nil && !d.IsReachable
|
||||
})
|
||||
sslCache.Store(testURL, sslCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
sslCache.Store(cacheKey, sslCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
return 200, result
|
||||
|
||||
case "speed":
|
||||
@@ -90,7 +106,12 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
version = "v4"
|
||||
}
|
||||
testURL = normalizeURL(testURL)
|
||||
cacheKey := fmt.Sprintf("%s:%s", testURL, version)
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return query[k] })
|
||||
if err != nil {
|
||||
return 400, map[string]string{"error": err.Error()}
|
||||
}
|
||||
// 缓存键:URL + 版本 + 请求级 DNS 覆盖(结果取决于所用服务器,参数不同不能共用)
|
||||
cacheKey := fmt.Sprintf("%s:%s%s", testURL, version, req.CacheKey())
|
||||
if cached, ok := speedCache.Load(cacheKey); ok {
|
||||
entry := cached.(speedCacheEntry)
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
@@ -98,7 +119,7 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
}
|
||||
speedCache.Delete(cacheKey)
|
||||
}
|
||||
r, e := webtest.SpeedTest(testURL, version)
|
||||
r, e := webtest.SpeedTest(testURL, version, req)
|
||||
if e != nil {
|
||||
errorResult := &webtest.WebsiteSpeedTestResult{HostRecord: "Error: " + e.Error()}
|
||||
speedCache.Store(cacheKey, speedCacheEntry{result: errorResult, timestamp: time.Now(), failed: true})
|
||||
@@ -126,7 +147,20 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
n = c
|
||||
}
|
||||
}
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d", host, port, n)
|
||||
// version=ipv4/ipv6:与 HTTP handler 同义,请求级指定单栈(配置优先)
|
||||
version := query["version"]
|
||||
if version != "" && normalizeStack(version) == "" {
|
||||
return 400, map[string]string{"error": "version must be ipv4 or ipv6"}
|
||||
}
|
||||
stack, skipLabel := probeStackFor(version)
|
||||
// 请求级解析参数(dns=/dns4=/dns6=):与 HTTP handler 同解析;
|
||||
// 带覆盖时旁路共享缓存(结果取决于所用服务器)
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return query[k] })
|
||||
if err != nil {
|
||||
return 400, map[string]string{"error": err.Error()}
|
||||
}
|
||||
// 缓存键与 HTTP handler 保持一致(含生效栈 + 请求级 DNS 覆盖),两边共用 pingCache
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d:%s%s", host, port, n, stack, req.CacheKey())
|
||||
if cached, ok := pingCache.Load(cacheKey); ok {
|
||||
entry := cached.(pingCacheEntry)
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
@@ -134,13 +168,13 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
}
|
||||
pingCache.Delete(cacheKey)
|
||||
}
|
||||
ipv4, ipv6 := wsDual[webtest.TCPingStats](
|
||||
ipv4, ipv6 := wsDualStack[webtest.TCPingStats](stack,
|
||||
func(v string) (*webtest.TCPingStats, error) {
|
||||
return webtest.TCPingRun(host, port, n, v, 10*time.Second, 100*time.Millisecond)
|
||||
return webtest.TCPingRunWith(host, port, n, v, 10*time.Second, 100*time.Millisecond, req)
|
||||
},
|
||||
func(err error) *webtest.TCPingStats { return &webtest.TCPingStats{IP: "Error: " + err.Error()} },
|
||||
func(v string) *webtest.TCPingStats {
|
||||
return &webtest.TCPingStats{IP: "Skipped due to SINGLE_STACK=" + v}
|
||||
func(skipStack string) *webtest.TCPingStats {
|
||||
return &webtest.TCPingStats{IP: "Skipped due to " + skipLabel + "=" + skipStack}
|
||||
},
|
||||
)
|
||||
result := &TCPingResult{IPv4: ipv4, IPv6: ipv6}
|
||||
@@ -157,29 +191,25 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
return 400, map[string]string{"error": "Invalid domain"}
|
||||
}
|
||||
domain = parsedURL.Host
|
||||
var result any
|
||||
switch recodeType {
|
||||
case "a":
|
||||
result, err = webtest.ResolveARecord(domain)
|
||||
case "aaaa":
|
||||
result, err = webtest.ResolveAAAARecord(domain)
|
||||
case "cname":
|
||||
result, err = webtest.ResolveCNAMERecord(domain)
|
||||
case "mx":
|
||||
result, err = webtest.ResolveMXRecord(domain)
|
||||
case "ns":
|
||||
result, err = webtest.ResolveNSRecord(domain)
|
||||
case "txt":
|
||||
result, err = webtest.ResolveTXTRecord(domain)
|
||||
case "srv":
|
||||
result, err = webtest.ResolveSRVRecord(domain)
|
||||
case "ptr":
|
||||
result, err = webtest.ResolvePTRRecord(domain)
|
||||
case "caa":
|
||||
result, err = webtest.ResolveCAARecord(domain)
|
||||
case "a", "aaaa", "cname", "mx", "ns", "ptr", "srv", "txt", "caa":
|
||||
default:
|
||||
return 400, map[string]string{"error": "Unsupported record type: " + recodeType}
|
||||
}
|
||||
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return query[k] })
|
||||
if err != nil {
|
||||
return 400, map[string]string{"error": err.Error()}
|
||||
}
|
||||
// 按 qtype 选栈:a→dns4|dns,aaaa→dns6|dns,其余→dns
|
||||
stack := ""
|
||||
if recodeType == "a" {
|
||||
stack = "ipv4"
|
||||
} else if recodeType == "aaaa" {
|
||||
stack = "ipv6"
|
||||
}
|
||||
|
||||
result, err := webtest.ResolveByType(domain, recodeType, req.Servers(stack))
|
||||
if err != nil {
|
||||
return 500, map[string]string{"error": err.Error()}
|
||||
}
|
||||
@@ -196,18 +226,20 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
if raw == "" {
|
||||
return 400, map[string]string{"error": "Domain parameter is required"}
|
||||
}
|
||||
if cached, ok := whoisCache.Load(raw); ok {
|
||||
// whois 不走请求级 DNS 覆盖(见 webtest.QueryWhois 注释),缓存键就是域名本身
|
||||
cacheKey := raw
|
||||
if cached, ok := whoisCache.Load(cacheKey); ok {
|
||||
entry := cached.(whoisCacheEntry)
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return 200, entry.result
|
||||
}
|
||||
whoisCache.Delete(raw)
|
||||
whoisCache.Delete(cacheKey)
|
||||
}
|
||||
result, err := webtest.QueryWhois(raw)
|
||||
if err != nil {
|
||||
return 500, map[string]string{"error": err.Error()}
|
||||
}
|
||||
whoisCache.Store(raw, whoisCacheEntry{result: result, timestamp: time.Now()})
|
||||
whoisCache.Store(cacheKey, whoisCacheEntry{result: result, timestamp: time.Now()})
|
||||
return 200, result
|
||||
|
||||
case "location":
|
||||
@@ -217,13 +249,18 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
if raw == "" {
|
||||
return 400, map[string]string{"error": "IP parameter is required"}
|
||||
}
|
||||
result := ipdb.SearchIP(raw, "maxmind_asn", "dbip_asn", "ip2location_asn")
|
||||
result := ipdb.SearchIP(raw, "lemondb_asn", "maxmind_asn", "dbip_asn", "ip2location_asn")
|
||||
asnResult := map[string]any{"ip": raw}
|
||||
if ip2locASN, ok := result["ip2location_asn"].(map[string]string); ok && ip2locASN["asn"] != "" {
|
||||
asnResult["ip2location_asn"] = map[string]string{"asn": ip2locASN["asn"], "as": ip2locASN["as"]}
|
||||
} else if errStr, ok := result["ip2location_asn"].(string); ok {
|
||||
asnResult["ip2location_asn"] = map[string]string{"error": errStr}
|
||||
}
|
||||
if lemonASN, ok := result["lemondb_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
asnResult["lemondb_asn"] = map[string]string{"asn": lemonASN.ASN, "org": lemonASN.Org}
|
||||
} else if errStr, ok := result["lemondb_asn"].(string); ok {
|
||||
asnResult["lemondb_asn"] = map[string]string{"error": errStr}
|
||||
}
|
||||
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
asnResult["geolite2_asn"] = map[string]string{"asn": maxmindASN.ASN, "org": maxmindASN.Org}
|
||||
} else if errStr, ok := result["maxmind_asn"].(string); ok {
|
||||
@@ -258,7 +295,13 @@ func splitRaw(raw string) (string, string) {
|
||||
|
||||
// wsDual 双栈编排(ws.go 内部,学 main.go handler 的 SINGLE_STACK 处理)
|
||||
func wsDual[T any](run func(version string) (*T, error), mkErr func(err error) *T, mkSkip func(version string) *T) (ipv4, ipv6 *T) {
|
||||
switch SINGLE_STACK {
|
||||
return wsDualStack(SINGLE_STACK, run, mkErr, mkSkip)
|
||||
}
|
||||
|
||||
// wsDualStack 是 wsDual 的可指定栈版本:stack 为 "" 双栈并行,"ipv4"/"ipv6" 只跑该栈。
|
||||
// tcping 借此支持请求级 version 参数(probeStackFor 已把配置优先规则算好)。
|
||||
func wsDualStack[T any](stack string, run func(version string) (*T, error), mkErr func(err error) *T, mkSkip func(stack string) *T) (ipv4, ipv6 *T) {
|
||||
switch stack {
|
||||
case "ipv4":
|
||||
v4, err := run("v4")
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user