Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b094bcb36 | ||
|
|
2947ad2125 | ||
|
|
1235ccffbd | ||
|
|
d688e9bb8e | ||
|
|
805b65bc07 | ||
|
|
682983e411 | ||
|
|
4abe6e4d1a | ||
|
|
19fe134762 | ||
|
|
5445b4df64 | ||
|
|
4f6cf7fac7 | ||
|
|
243060a32a | ||
|
|
924b9bcc36 | ||
|
|
611caa30f6 | ||
|
|
165caa9127 |
No files matched your search
@@ -1,11 +1,15 @@
|
||||
name: Build and Deploy to Cloudflare Workers
|
||||
name: (已停用自动部署) frontend-ssr → Cloudflare Workers
|
||||
|
||||
# 前端已迁移到 frontend-svelte(SvelteKit + shadcn-svelte,包管理器 bun),
|
||||
# 自动部署改由 .github/workflows/frontend-svelte.yml 负责。
|
||||
#
|
||||
# 这里**刻意去掉 push 触发**:两个 workflow 都部署同名 Worker(wrangler.jsonc 里的 "ipw-cn"),
|
||||
# 若继续按 push 触发,改 frontend-ssr 会把新前端的线上版本覆盖掉。
|
||||
# 保留 workflow_dispatch 只是为了万一还需要临时用旧站顶一下(手动触发)。
|
||||
# 新前端确认稳定后,这个文件可以直接删除。
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'frontend-ssr/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Build and Deploy frontend-svelte to Cloudflare Workers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'frontend-svelte/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 包管理器是 bun(见 frontend-svelte/bun.lock),不再用 pnpm/npm
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
# 部署到 Cloudflare Workers
|
||||
# --keep-vars:保留 Worker 上已配置的环境变量(如 API_KEYS),别被本次部署清掉
|
||||
- name: Deploy to Cloudflare Workers
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
cd frontend-svelte
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
bunx wrangler deploy --keep-vars
|
||||
+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)。修法是给合并键加探测类型前缀,暂未处理。
|
||||
+33
-18
@@ -4,7 +4,21 @@ import { useDark, useToggle } from '@vueuse/core';
|
||||
import { Moon, Sunny, Expand } from '@element-plus/icons-vue';
|
||||
import { config } from '../config/index';
|
||||
|
||||
const isNarrow = ref(false);
|
||||
// ==================== 窄屏判定(水合前就必须定下来) ====================
|
||||
// 为什么不能等到 onMounted 才算:SSR 不知道视口宽度,只能按宽屏输出 ——
|
||||
// 于是整条桌面版顶栏菜单(10 个条目 + 分隔线 + 3 个下拉)会被下发给手机;
|
||||
// 水合时 isNarrow 还是 false,手机就先把这一整套水合一遍
|
||||
// (实测 375×812:header-menu 区域 268 次 DOM 变更、70 次事件绑定),
|
||||
// 紧接着 onMounted 把它改成窄屏、再整棵销毁重建 —— 这一整轮纯属白干。
|
||||
// 所以改成「服务端按请求头判定、客户端直接沿用服务端序列化下来的结论」,
|
||||
// 两边输入一致 ⇒ 结论一致 ⇒ 水合期不产生任何分歧,桌面上仍是原来的形态。
|
||||
// 权威值依旧由 onMounted 的 matchMedia 覆盖,管「桌面窗口被拖窄」这类请求头判不出的情形。
|
||||
//
|
||||
// 注意:**不要**在客户端重新嗅探 navigator.userAgent。SSR 页两边大概率巧合一致,
|
||||
// 但预渲染页没有请求头(服务端恒判宽屏),客户端一嗅探就必然分叉 ——
|
||||
// 实测那会让整个 /doc/** 在真手机上水合失配、Nuxt 直接渲染 500 页。
|
||||
// 判定逻辑与文档见 app/composables/useShellNarrow.ts。
|
||||
const isNarrow = useShellNarrow()
|
||||
let mediaQueryList: MediaQueryList | null = null;
|
||||
const drawer = ref(false);
|
||||
|
||||
@@ -138,13 +152,22 @@ onMounted(() => {
|
||||
window.removeEventListener('resize', updateViewport);
|
||||
});
|
||||
|
||||
if (!umamiScript && config.umamiScriptUrl) {
|
||||
// 统计脚本延迟到浏览器空闲再注入:umami 源响应慢(实测 ~1.6s),
|
||||
// 挂在 onMounted 里会和首页首屏的拨测请求抢慢速网络的带宽,
|
||||
// 拖累 Speed Index;统计本身不需要抢首屏前的任何窗口。
|
||||
const loadUmami = () => {
|
||||
if (umamiScript || !config.umamiScriptUrl) return
|
||||
umamiScript = document.createElement('script')
|
||||
umamiScript.src = config.umamiScriptUrl
|
||||
umamiScript.async = true
|
||||
umamiScript.setAttribute('data-website-id', config.umamiWebsiteId)
|
||||
document.head.appendChild(umamiScript)
|
||||
}
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(loadUmami, { timeout: 4000 })
|
||||
} else {
|
||||
setTimeout(loadUmami, 2000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -345,7 +368,11 @@ onMounted(() => {
|
||||
</style>
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
/* 正文/页脚链接色:亮色白底需 ≥4.5:1(axe color-contrast 审计线)。
|
||||
#299764 白底只有 3.68:1,加深到 #1d7a4a = 5.34:1,留足余量。
|
||||
主题色(按钮/高亮等 --el-color-primary 消费方)保持 #299764 不动,只分流链接。 */
|
||||
--link-color: #1d7a4a;
|
||||
}
|
||||
html.dark {
|
||||
/* 暗色模式下根元素的文字色与页面底色。
|
||||
@@ -354,22 +381,10 @@ html.dark {
|
||||
`html.dark[data-v-xxxx]` —— 整条规则永不匹配,底色只能靠浏览器的 color-scheme 兜底。 */
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
/* 暗底 #242424 上 #299764 对比 ~4.2:1,且再加深只会更差 —— 暗色链接保持原色 */
|
||||
--link-color: #299764;
|
||||
}
|
||||
/* 文字链接按模式拆色:亮色下 #3EAF7C 在白底只有 2.75:1、在引用块底 #f9f9f9 上 2.61:1
|
||||
(axe 的 color-contrast,权重 7,是移动端 a11y 唯一的扣分项);换成同色系加深的
|
||||
#2F7D5A 后分别是 5.00:1 / 4.75:1。暗色底上 #3EAF7C 本来就有 5.64:1 ⇒ 保留品牌色。
|
||||
必须写在这个【非 scoped】块里:style.css 是被 <style scoped> @import 进来的,
|
||||
根元素选择器会被加上 scope 属性而永不匹配(同上面底色那两条的原因)。
|
||||
另外这三条要逐一列出而不是只写 `html.dark a`:footer .one-line a 的特异性是 (0,1,2),
|
||||
比 `html.dark a` 的 (0,1,1) 高,不显式覆盖的话页脚链接在暗色下会留在深绿。 */
|
||||
html.dark a,
|
||||
html.dark blockquote a,
|
||||
html.dark footer .one-line a,
|
||||
html.dark .header-anchor {
|
||||
color: #3EAF7C;
|
||||
}
|
||||
|
||||
/* Drawer 内部链接占满一行。
|
||||
注意用直接子选择器(> a):文档菜单(DocMenu)里的 <a> 嵌在 el-menu-item 里,
|
||||
不能被这套「整行大按钮」规则吃掉,否则菜单项会变成上下带 1em 内边距的块。
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 高级选项:请求级解析参数(与 ipw-ak 的 AdvancedOptions 同源)
|
||||
*
|
||||
* - 展开后按地址族分列:IPv4 → dns4 / resolve4,IPv6 → dns6 / resolve6
|
||||
* - stack 非空时只渲染对应栈那一列 —— tcping / speed 的前端两个栈是分开的页面,
|
||||
* 单栈页只可能发出该栈的请求,另一列的参数永远吃不到,给了就是死 UI。
|
||||
* - 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('')
|
||||
|
||||
/** 组装请求级解析参数(空值不下发) */
|
||||
function query(): Record<string, string> {
|
||||
const q: Record<string, string> = {}
|
||||
if (dns.value.trim()) q.dns = dns.value.trim()
|
||||
if (dns4.value.trim()) q.dns4 = dns4.value.trim()
|
||||
if (dns6.value.trim()) q.dns6 = dns6.value.trim()
|
||||
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 非空时覆盖它) -->
|
||||
<div 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,逗号分隔" />
|
||||
</div>
|
||||
|
||||
<!-- 两栈通用的强制解析(单栈页填这一项即可;resolve4/6 非空时覆盖它) -->
|
||||
<div v-if="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,逗号分隔" />
|
||||
</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>域名:IP</code> 或 <code>域名:CNAME</code>,IPv6 目标写 <code>[::1]</code>。{{
|
||||
stack ? 'dns4 / dns6、resolve4 / resolve6 非空时覆盖 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,34 @@
|
||||
/**
|
||||
* 顶栏 / 抽屉「宽屏还是窄屏」的唯一判定入口(服务端算一次,客户端沿用)。
|
||||
*
|
||||
* 为什么客户端不自己嗅探 navigator.userAgent:
|
||||
* SSR 出的 HTML 是跟着【服务端】的判定走的。客户端若各算各的,只要两边结论不一致,
|
||||
* Vue 水合就会整棵树失配 —— 实测在预渲染的 /doc/** 上是直接抛
|
||||
* `Cannot read properties of null (reading 'nodeType')`,然后被 Nuxt 渲染成 500 页
|
||||
* (真手机打开文档站必然复现:预渲染时没有请求头 ⇒ 服务端恒判宽屏)。
|
||||
* 把服务端的判定序列化进 payload 当客户端的初值,首帧 vdom 与 HTML 必然一致;
|
||||
* 顺带还免疫「CDN 把桌面版缓存回给手机」——那种情况下客户端也会乖乖按 HTML 走宽屏。
|
||||
*
|
||||
* 权威值仍由 app.vue 的 onMounted 用 matchMedia 覆盖,管「桌面窗口被拖窄」这类
|
||||
* 请求头判不出的情形。窄屏判定是全局共享状态(useState),文档页据此决定要不要
|
||||
* 渲染侧栏,所以两边读的必须是同一个 ref。
|
||||
*/
|
||||
const CRAWLER_RE =
|
||||
/bot|crawler|spider|slurp|bingbot|baiduspider|yandex|duckduckbot|facebookexternalhit|preview/i
|
||||
|
||||
export function uaIsNarrow(ua: string, chMobile?: string) {
|
||||
if (!ua && !chMobile) return false // 两个都拿不到(含预渲染):按宽屏,与改造前一致
|
||||
if (CRAWLER_RE.test(ua)) return false // 抓取端一律宽屏,否则顶栏导航链接会从 HTML 里消失
|
||||
if (chMobile) return chMobile === '?1' // Chromium 的 Client Hint,最准
|
||||
return /Android|iPhone|iPod|Windows Phone|webOS|BlackBerry|Opera Mini|IEMobile|Mobile/i.test(ua)
|
||||
}
|
||||
|
||||
export function useShellNarrow() {
|
||||
// 只有服务端拿得到请求头;客户端分支只为类型与「init 被意外调用」兜底。
|
||||
const headers: Record<string, string | undefined> = import.meta.server
|
||||
? useRequestHeaders(['user-agent', 'sec-ch-ua-mobile'])
|
||||
: {}
|
||||
return useState<boolean>('ipw-shell-narrow', () =>
|
||||
uaIsNarrow(headers['user-agent'] || '', headers['sec-ch-ua-mobile']),
|
||||
)
|
||||
}
|
||||
@@ -9,10 +9,10 @@ html.dark .markdown-body {
|
||||
/* GitHub 暗色主题的核心颜色 */
|
||||
--color-fg-default: #e6edf3;
|
||||
--color-fg-muted: #8b949e;
|
||||
--color-fg-accent: #3EAF7C;
|
||||
--color-fg-accent: #299764;
|
||||
--color-bg-default: #242424;
|
||||
--color-bg-muted: #080707;
|
||||
--color-bg-accent: #3EAF7C;
|
||||
--color-bg-accent: #299764;
|
||||
--color-border-default: #242424;
|
||||
--color-border-muted: #242424;
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ function getStatusClass(status: string): string {
|
||||
<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 +189,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>
|
||||
@@ -261,10 +261,10 @@ function getStatusClass(status: string): string {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { config, devNodes } 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,17 @@ const recordTypes = [
|
||||
{ value: 'ptr', label: 'PTR 记录' }
|
||||
]
|
||||
|
||||
// 高级选项:DNS 拨测问的就是真实解析结果,只取 DNS 服务器(dns4/dns6),不支持强制解析
|
||||
const adv = ref<InstanceType<typeof AdvancedOptions> | null>(null)
|
||||
|
||||
// 调试节点放最前(仅 dev 注入,生产为空数组)
|
||||
const dnsServerFetches = [
|
||||
...devNodes,
|
||||
...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,
|
||||
@@ -201,7 +207,8 @@ const doc = page.value;
|
||||
v-model="tmpDomain"
|
||||
placeholder="请输入域名(如:example.com)"
|
||||
/>
|
||||
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select">
|
||||
<!-- aria-label 落到 EP select 内部的原生 input 上,否则 Lighthouse 的 label 审计必挂 -->
|
||||
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select" aria-label="DNS 记录类型">
|
||||
<el-option
|
||||
v-for="item in recordTypes"
|
||||
:key="item.value"
|
||||
@@ -218,6 +225,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>
|
||||
@@ -297,7 +307,7 @@ const doc = page.value;
|
||||
}
|
||||
|
||||
.markdown :deep(a) {
|
||||
color: #3EAF7C !important;
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
@@ -305,11 +315,11 @@ const doc = page.value;
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index'
|
||||
import { config, devNodes } from '../../config/index'
|
||||
import { isIPv6 } from 'is-ip'
|
||||
|
||||
|
||||
@@ -71,7 +71,9 @@ const userIP = ref('')
|
||||
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
|
||||
let queryId = 0
|
||||
|
||||
// 调试节点放最前(仅 dev 注入,生产为空数组)
|
||||
const apiList = [
|
||||
...devNodes,
|
||||
...config.APIBaseURL.DualStack,
|
||||
...config.APIBaseURL.IPv4,
|
||||
...config.APIBaseURL.IPv6
|
||||
@@ -284,10 +286,10 @@ async function getUserIP() {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { getDocMeta } from '../../../config/doc'
|
||||
|
||||
// 窄屏下这条侧栏被 CSS 隐藏(导航在顶栏抽屉里),但照样要花钱水合 ——
|
||||
// 详见 app/pages/doc/index.vue 的同名注释。
|
||||
const isNarrow = useShellNarrow()
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const path = computed(() => route.path)
|
||||
@@ -18,7 +22,7 @@ useHead({
|
||||
<div class="box">
|
||||
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
|
||||
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
|
||||
<DocMenu class="sidebar-menu" />
|
||||
<DocMenu v-if="!isNarrow" class="sidebar-menu" />
|
||||
<div class="content">
|
||||
<div class="markdown-body" v-if="page">
|
||||
<div v-html="page" />
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { getDocMeta } from '../../../config/doc'
|
||||
|
||||
// 窄屏(≤768px)下这条侧栏被 CSS 整个隐藏,导航改由顶栏抽屉承载。
|
||||
// 但「只是藏起来」不等于不花钱:Vue 依旧会把整棵 40 项的 el-menu 水合一遍
|
||||
// (实测手机端 doc-sidebar 区域 53 次事件绑定)。所以直接按判定结果不渲染。
|
||||
// 这里用的是全局共享的窄屏状态(服务端按请求头判定并随 payload 下发),
|
||||
// 与 app.vue 同一份 —— 首帧与服务端 HTML 一致,不会有水合分歧。
|
||||
const isNarrow = useShellNarrow()
|
||||
|
||||
const { data: page } = await useAsyncData('doc-index', () =>
|
||||
$fetch('/api/markdown/doc/index')
|
||||
)
|
||||
@@ -18,7 +25,7 @@ useHead({
|
||||
<div class="box">
|
||||
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
|
||||
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
|
||||
<DocMenu class="sidebar-menu" />
|
||||
<DocMenu v-if="!isNarrow" class="sidebar-menu" />
|
||||
<div class="content">
|
||||
<div class="markdown-body" v-html="doc"></div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,32 @@ useHead({
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
],
|
||||
script: [
|
||||
{
|
||||
// —— IP 探测提前到 HTML 解析期(LCP 优化)——
|
||||
// 原来三个探测请求在 onMounted(水合完成后)才发出,模拟链路是
|
||||
// 「水合 → 发请求 → 等服务端 → 回填渲染」,IPv6 行作为 LCP 元素被整段串行拖住
|
||||
// (mobile 模拟 LCP 2275ms,满分线约 1600ms)。
|
||||
// 这里在 head 里用内联脚本立刻发起,与 render-blocking CSS 的下载、
|
||||
// hydration 三路并行;结果挂 window.__ipProbe,完成时派发 ip-probe 事件,
|
||||
// onMounted 只负责消费(见下方),不再等水合才起步。
|
||||
// 用原生 fetch:此刻 ofetch 还没加载;不带 retry 的语义与旧版 retry:false 一致
|
||||
// (底层 fetch 本来就不会重试)。
|
||||
innerHTML: [
|
||||
'window.__ipProbe = {};',
|
||||
'(function () {',
|
||||
` function emit(key) {`,
|
||||
` return function (text) {`,
|
||||
` window.__ipProbe[key] = text;`,
|
||||
` document.dispatchEvent(new CustomEvent('ip-probe', { detail: key }));`,
|
||||
` };`,
|
||||
` }`,
|
||||
// 与 onMounted 的语义一致:三接口各自独立、失败静默(保持「查询中」占位)
|
||||
` fetch(${JSON.stringify(config.v6OnlyAPI)}).then(function (r) { return r.text(); }).then(emit('v6')).catch(function () {});`,
|
||||
` fetch(${JSON.stringify(config.v4OnlyAPI)}).then(function (r) { return r.text(); }).then(emit('v4')).catch(function () {});`,
|
||||
` fetch(${JSON.stringify(config.DualStackAPI)}).then(function (r) { return r.text(); }).then(emit('dual')).catch(function () {});`,
|
||||
'})();',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
@@ -102,35 +128,63 @@ const yourIPv4 = ref('');
|
||||
const yourIPv6 = ref('');
|
||||
|
||||
onMounted(() => {
|
||||
// (代码高亮已移到 setup 顶部的 useAsyncData,随 SSR 一起产出,这里只剩三个 IP 查询。)
|
||||
// (代码高亮已移到 setup 顶部的 useAsyncData,随 SSR 一起产出。)
|
||||
|
||||
// 三个地址各自独立请求、各自渲染:谁先回来谁先上屏。
|
||||
// 不用 Promise.allSettled 包起来等齐——那样只要有一个慢(例如纯 IPv4 网络下 v6 接口要等超时),
|
||||
// 已经拿到的 IPv4 与双栈结果也得一起干等。
|
||||
// —— 三个 IP 探测:请求已由 head 内联脚本提前发出(见 useHead),这里只消费 ——
|
||||
// 内联脚本把结果挂 window.__ipProbe[key] 并派发 ip-probe 事件。三种时序都要覆盖:
|
||||
// ① 值已就位(事件在 onMounted 之前派发过)→ 同步读 store 直接上屏;
|
||||
// ② 值未到 → 挂监听等事件(收到匹配 key 才移除,不能 once——
|
||||
// 三个 key 共用同一事件名,once 会把别人的事件当自己的吃掉);
|
||||
// ③ window.__ipProbe 不存在(内联脚本被 CSP 拦掉/异常)→ 退回自己发请求,行为同旧版。
|
||||
//
|
||||
// retry: false —— ofetch 默认会对 GET 重发一次:网络层失败时它拿不到 response,就按 500 计
|
||||
// (500 在默认重试白名单里),且默认延迟为 0,立即重发。于是"这个节点连不上"会被原样重做一遍,
|
||||
// 用户白等一倍时间(实测 ofetch 1.5.1:裸调用触发 2 次真实 fetch,传 retry:false 后只剩 1 次;
|
||||
// 浏览器自身不会重试,底层 fetch 对同一请求只被调用过一次)。
|
||||
// 浏览器自身不会重试,底层 fetch 对同一请求只被调用过一次)。仅 ③ 的兜底路径会走到 $fetch。
|
||||
const ipFetchOptions = { retry: false } as const;
|
||||
const probeStore = (window as any).__ipProbe as Record<string, string> | undefined;
|
||||
|
||||
$fetch<string>(config.DualStackAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
ipAddress.value = ip;
|
||||
})
|
||||
.catch(() => {
|
||||
// 单个接口失败不影响其它两行展示,保持"查询中"占位
|
||||
});
|
||||
$fetch<string>(config.v4OnlyAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
yourIPv4.value = ip;
|
||||
})
|
||||
.catch(() => {});
|
||||
$fetch<string>(config.v6OnlyAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
yourIPv6.value = ip;
|
||||
})
|
||||
.catch(() => {});
|
||||
const consumeProbe = (key: string, apply: (ip: string) => void): boolean => {
|
||||
if (!probeStore) return false; // ③ 内联脚本没跑起来
|
||||
if (typeof probeStore[key] === 'string') {
|
||||
apply(probeStore[key]); // ① 值已就位
|
||||
return true;
|
||||
}
|
||||
const onProbe = (e: Event) => {
|
||||
if ((e as CustomEvent).detail !== key) return;
|
||||
document.removeEventListener('ip-probe', onProbe);
|
||||
apply(probeStore[key]); // ② 事件到达
|
||||
};
|
||||
document.addEventListener('ip-probe', onProbe);
|
||||
return true;
|
||||
};
|
||||
|
||||
// 三个地址各自独立请求、各自渲染:谁先回来谁先上屏。
|
||||
// 不用 Promise.allSettled 包起来等齐——那样只要有一个慢(例如纯 IPv4 网络下 v6 接口要等超时),
|
||||
// 已经拿到的 IPv4 与双栈结果也得一起干等。
|
||||
if (!consumeProbe('dual', (ip) => { ipAddress.value = ip; })) {
|
||||
$fetch<string>(config.DualStackAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
ipAddress.value = ip;
|
||||
})
|
||||
.catch(() => {
|
||||
// 单个接口失败不影响其它两行展示,保持"查询中"占位
|
||||
});
|
||||
}
|
||||
if (!consumeProbe('v4', (ip) => { yourIPv4.value = ip; })) {
|
||||
$fetch<string>(config.v4OnlyAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
yourIPv4.value = ip;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
if (!consumeProbe('v6', (ip) => { yourIPv6.value = ip; })) {
|
||||
$fetch<string>(config.v6OnlyAPI, ipFetchOptions)
|
||||
.then((ip) => {
|
||||
yourIPv6.value = ip;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -143,10 +197,10 @@ onMounted(() => {
|
||||
</header>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="one-line">
|
||||
<div class="one-line ip-row">
|
||||
<b>IPv4</b> <p>{{ yourIPv4 }} </p> <RouterLink :to="`/location?ip=${yourIPv4}`" target="_blank">查询归属地</RouterLink>
|
||||
</div>
|
||||
<div class="one-line">
|
||||
<div class="one-line ip-row ip-row-v6">
|
||||
<b>IPv6</b> <p v-if="yourIPv6">{{ yourIPv6 }}</p><RouterLink :to="`/location?ip=${yourIPv6}`" target="_blank" v-if="yourIPv6"> 查询归属地</RouterLink><RouterLink v-else to="/doc/user/enable_ipv6" target="_blank">没有IPv6地址,查看如何开启IPv6</RouterLink>
|
||||
</div>
|
||||
<div class="ip-priority">
|
||||
@@ -180,6 +234,20 @@ onMounted(() => {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
/* 两行 IP 记录预留确定高度,消除 CLS。
|
||||
实测(CDP 逐帧采样 + CSS 变体对照,填值后量终态几何):
|
||||
这两个 div 的文本是 onMounted 之后才由 $fetch 填上的。IPv6 地址(39 字符)
|
||||
在窄屏下把 .one-line 从 1 行撑到 3 行,下面 .ip-priority / 代码块 / blockquote /
|
||||
footer 被整体顶下 50px —— Lighthouse 记的 `div.code-block` 那条 CLS 0.0359
|
||||
(占全页 88%)就是这么来的,而满分门槛只有 0.0396,差 3% 就被扣分。
|
||||
修法是让高度与「填完之后」一致。终态高度按视口分档(CDP 实测,不是估算):
|
||||
移动 412 宽 → ip2 终态 75px(3 行);桌面 1350 宽 → 30px(1 行,.content 宽 60%)
|
||||
所以必须分媒体查询给值:一刀切 75px 会让桌面凭空多出 45px 空白。
|
||||
代价是接口 pending 时这里空着,但换来 CLS 归零。 */
|
||||
.ip-row {
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
@@ -198,17 +266,30 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ip-priority h2 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.code-block {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.ip-priority h2 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.code-block {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
/* 窄屏下 IPv6 地址换 3 行(CDP 实测 412 宽终态 h=75px),见上面 .ip-row 的注释。
|
||||
75px 只给 IPv6 行:IPv4 地址窄屏仍是 1 行(~30px),一刀切 75px 会让
|
||||
IPv4 行下部空出 ~45px,两块 IP 之间出现一大段空白。
|
||||
IPv4 回填前后都是 1 行 30px,高度不变 → 不引入新的 CLS。 */
|
||||
.ip-row {
|
||||
/* style.css 窄屏给 .one-line 加了 0.5em 上下 margin(两块之间叠 16px),
|
||||
IP 行的间距由行高本身决定,这里归零。scoped 属性选择器特异性更高,稳赢。 */
|
||||
margin: 0;
|
||||
}
|
||||
.ip-row-v6 {
|
||||
min-height: 75px;
|
||||
align-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
</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,
|
||||
@@ -191,6 +194,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" stack="ipv6" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
@@ -272,10 +277,10 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -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,
|
||||
@@ -211,6 +214,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" stack="ipv6" />
|
||||
|
||||
<div class="result-section">
|
||||
<table class="result-table">
|
||||
<thead>
|
||||
@@ -287,11 +292,11 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { config, devNodes } 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,11 @@ interface PerformanceCheckItem {
|
||||
download_speed: number
|
||||
is_reachable: boolean
|
||||
}
|
||||
const apiList = config.APIBaseURL.DualStack
|
||||
// 调试节点放最前(仅 dev 注入,生产为空数组)
|
||||
const apiList = [
|
||||
...devNodes,
|
||||
...config.APIBaseURL.DualStack,
|
||||
]
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const tmpDomain = ref('https://www.zakoflare.com')
|
||||
@@ -71,7 +76,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
|
||||
|
||||
@@ -147,6 +154,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
@@ -305,10 +314,10 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -261,7 +261,7 @@ onMounted(async () => {
|
||||
<style>
|
||||
@import "../style.css";
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
@@ -444,7 +444,7 @@ html.dark pre code {
|
||||
|
||||
.badge-item h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #3EAF7C;
|
||||
color: #299764;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
@@ -286,7 +286,7 @@ html.dark .loading-overlay p {
|
||||
}
|
||||
|
||||
.markdown :deep(a) {
|
||||
color: #3EAF7C !important;
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { config, devNodes } 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 {
|
||||
@@ -70,9 +73,19 @@ const serverResults = ref<ServerResult[]>([])
|
||||
// 避免页面加载时自动发请求浪费资源。
|
||||
// =============================================
|
||||
|
||||
/** 调试节点:仅 dev 注入,排最前(本页固定走 IPv4) */
|
||||
const devFetches = devNodes.map((server) => {
|
||||
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,
|
||||
});
|
||||
return { label: server.label, data, error: fetchError, execute };
|
||||
});
|
||||
|
||||
/** 双栈服务器:同时支持 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 +95,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,
|
||||
@@ -92,6 +105,7 @@ const ipv4Fetches = config.APIBaseURL.IPv4.map((server) => {
|
||||
|
||||
/** 合并所有服务器 fetch 列表 */
|
||||
const allFetches = [
|
||||
...devFetches.map(fetch => ({ ...fetch, type: 'IPv4' })),
|
||||
...dualStackFetches.map(fetch => ({ ...fetch, type: 'DualStack' })),
|
||||
...ipv4Fetches.map(fetch => ({ ...fetch, type: 'IPv4' }))
|
||||
];
|
||||
@@ -191,6 +205,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" stack="ipv4" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
@@ -272,10 +288,10 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { ref, onMounted, computed, nextTick, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { CircleCheckFilled, CircleCloseFilled,InfoFilled } from '@element-plus/icons-vue';
|
||||
import { config } from '../../config/index';
|
||||
import { config, devNodes } 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(/\/$/, '') + '/')
|
||||
@@ -65,7 +66,11 @@ interface SSLCheckItem {
|
||||
is_expired: boolean
|
||||
is_reachable: boolean
|
||||
}
|
||||
const apiList = config.APIBaseURL.DualStack
|
||||
// 调试节点放最前(仅 dev 注入,生产为空数组)
|
||||
const apiList = [
|
||||
...devNodes,
|
||||
...config.APIBaseURL.DualStack,
|
||||
]
|
||||
const currentApiIndex = ref(0)
|
||||
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
|
||||
const tmpDomain = ref('https://www.zakoflare.com')
|
||||
@@ -73,7 +78,9 @@ 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));
|
||||
// 高级选项(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,
|
||||
@@ -157,6 +164,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
@@ -389,7 +398,7 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index';
|
||||
import { config, devNodes } 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,16 +77,20 @@ 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
|
||||
|
||||
const allServers = [
|
||||
// 调试节点放最前(仅 dev 注入,生产为空数组);本页固定 version=ipv4,故按 IPv4 组对待
|
||||
...devNodes.map((s: ServerConfig) => ({ ...s, type: 'IPv4' })),
|
||||
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
|
||||
...config.APIBaseURL.IPv4.map((s: ServerConfig) => ({ ...s, type: 'IPv4' }))
|
||||
];
|
||||
|
||||
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 +100,16 @@ const tcpingFetches = allServers.map((server) => {
|
||||
|
||||
function initServerResults() {
|
||||
const results: ServerResult[] = []
|
||||
|
||||
|
||||
// 与 allServers 顺序保持一致:调试节点 → DualStack → IPv4
|
||||
devNodes.forEach((server) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
loading: false,
|
||||
error: ''
|
||||
})
|
||||
})
|
||||
|
||||
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
|
||||
results.push({
|
||||
label: server.label,
|
||||
@@ -202,6 +216,8 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" stack="ipv4" />
|
||||
|
||||
<div class="result-section">
|
||||
<table class="result-table">
|
||||
<thead>
|
||||
@@ -271,11 +287,11 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { useMiddlewareFetch } from '../../utils/middleware';
|
||||
import { ref, onMounted, computed, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { config } from '../../config/index'
|
||||
import { config, devNodes } from '../../config/index'
|
||||
import { withQuery } from '../../utils/tools'
|
||||
import AdvancedOptions from '../components/AdvancedOptions.vue'
|
||||
import { isIPv6 } from 'is-ip'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -64,10 +66,16 @@ const result = ref<any>(null)
|
||||
const error = ref('')
|
||||
const userIP = ref('')
|
||||
|
||||
const apiList = config.APIBaseURL.DualStack
|
||||
// 调试节点放最前(仅 dev 注入,生产为空数组)
|
||||
const apiList = [
|
||||
...devNodes,
|
||||
...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,
|
||||
@@ -178,6 +186,8 @@ function getStatusClass(status: string): string {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<AdvancedOptions ref="adv" :show-resolve="false" />
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
@@ -281,7 +291,7 @@ function getStatusClass(status: string): string {
|
||||
@import "../style.css";
|
||||
@import "../../assets/css/tool-common.css";
|
||||
.markdown :deep(a) {
|
||||
color: #3EAF7C !important;
|
||||
color: #299764 !important;
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
}
|
||||
@@ -319,10 +329,10 @@ function getStatusClass(status: string): string {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
html.dark {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -109,14 +109,14 @@ blockquote p {
|
||||
margin: 0.5em;
|
||||
}
|
||||
blockquote a{
|
||||
color: #2F7D5A;
|
||||
color: var(--link-color);
|
||||
font-size: 1em;
|
||||
/* 正文里的链接仅靠颜色区分不够(绿 #2F7D5A 与周围正文的对比度远低于 3:1),
|
||||
/* 正文里的链接仅靠颜色区分不够(绿 #299764 与周围正文的对比度远低于 3:1),
|
||||
补下划线提供「非颜色」的视觉区分(link-in-text-block) */
|
||||
text-decoration: underline
|
||||
}
|
||||
a {
|
||||
color: #2F7D5A;
|
||||
color: var(--link-color);
|
||||
font-size: 1.3em;
|
||||
text-decoration: none
|
||||
|
||||
@@ -132,7 +132,7 @@ footer .one-line {
|
||||
footer .one-line a {
|
||||
font-size: 1em;
|
||||
text-align: center;
|
||||
color: #2F7D5A;
|
||||
color: var(--link-color);
|
||||
text-decoration: none
|
||||
}
|
||||
footer {
|
||||
@@ -207,7 +207,7 @@ footer {
|
||||
}
|
||||
blockquote a{
|
||||
|
||||
color: #2F7D5A;
|
||||
color: var(--link-color);
|
||||
font-size: 1em;
|
||||
/* 与上面的非窄屏规则保持一致:窄屏同样靠下划线做非颜色区分。
|
||||
此处若仍写 none,因其位置在后、特异性相同,会把桌面的 underline 盖掉。 */
|
||||
@@ -272,7 +272,7 @@ h4>.header-anchor,
|
||||
h5>.header-anchor,
|
||||
h6>.header-anchor {
|
||||
opacity: 1;
|
||||
color: #2F7D5A;
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
h1:hover>.header-anchor,
|
||||
@@ -285,5 +285,5 @@ h6:hover>.header-anchor {
|
||||
}
|
||||
.header-anchor::before {
|
||||
content: "#";
|
||||
color: #2F7D5A;
|
||||
color: var(--link-color);
|
||||
}
|
||||
@@ -226,7 +226,7 @@ html.dark pre code {
|
||||
|
||||
.badge-item h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #3EAF7C;
|
||||
color: #299764;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
/*
|
||||
前端的一系列配置
|
||||
*/
|
||||
|
||||
// 是否开发环境。
|
||||
// 本文件同时被 nuxt.config 与 nitro server 引用,那两处 import.meta.dev 尚未注入(undefined),
|
||||
// 因此只有严格为 true 才算 dev;应用代码里由 Vite 的 define 替换成真实布尔值。
|
||||
const __DEV__ = (import.meta as any).dev === true
|
||||
|
||||
/**
|
||||
* 本机调试节点:仅开发环境注入,对应 middleware-go/setting.json 里的 local
|
||||
* (url 指向本机节点,默认 127.0.0.1:18081)。生产构建为空数组。
|
||||
*
|
||||
* 位置约定:调试节点放**第一优先级**——各页把 `...devNodes` 拼在节点列表最前面;
|
||||
* 本机只有 IPv4,所以 IPv6 页(ipv6tcping / ipv6speedtest)不拼它。
|
||||
*/
|
||||
export const devNodes: { label: string; id: string; url: string }[] = __DEV__
|
||||
? [{ label: "本机 127.0.0.1:18081(调试)", id: "local", url: "" }]
|
||||
: []
|
||||
|
||||
const config = {
|
||||
siteUrl: "https://nipw.cn/",
|
||||
// 站点名称:用于页面标题 / 描述 / 页脚品牌展示
|
||||
@@ -18,8 +35,10 @@ const config = {
|
||||
// 前端自带中间件始终放在候选列表最后一位兜底。
|
||||
// 未配置或为空数组时,直接使用前端自带中间件。
|
||||
Middleware: <string[]>[
|
||||
// dev:本机 middleware-go(8091)排最前 —— 调试节点 id "local" 只有它认识,
|
||||
// 远端中间层拿到会直接返回 Invalid backend ID(400,不在重试码里)。
|
||||
...(__DEV__ ? ["http://127.0.0.1:8091/"] : []),
|
||||
"https://middleware-1.api-ipw.wsmdn.top/"
|
||||
// "http://127.0.0.1:8092/",
|
||||
// "https://middleware-2.wsmdn.top/",
|
||||
],
|
||||
// Umami 统计
|
||||
|
||||
+43
-14
@@ -1,5 +1,4 @@
|
||||
import {config} from "./config/index";
|
||||
import { docConfig } from "./config/doc";
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
const extractDomains = (obj: any): string[] => {
|
||||
// 将对象转为 JSON 字符串,用正则匹配所有 https:// 开头的域名部分
|
||||
@@ -27,6 +26,25 @@ const middlewareOrigin = config.Middleware?.[0]
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
experimental: {
|
||||
// 关闭 app manifest 版本检查:水合后 Nuxt 会在 requestIdleCallback 里请求
|
||||
// /_nuxt/builds/meta/<buildId>.json,buildId 对不上(本地「服务旧、磁盘新」、
|
||||
// 或线上发版切换瞬间)就抛 NUXT_E5002 污染 console,还拖累 Best Practices 分。
|
||||
// 本项目没依赖 appManifest 的客户端能力(无客户端 routeRules、无 prerender
|
||||
// 判定),关掉少一个请求,console 永远干净。
|
||||
appManifest: false,
|
||||
// 注:inlineStyles: true 实测是负优化——它只内联 page 组件样式(+12KB HTML),
|
||||
// entry/EP 等共享样式仍是 render-blocking 外链,关键请求一个没省。勿开。
|
||||
defaults: {
|
||||
nuxtLink: {
|
||||
// NuxtLink 默认「链接进入视口就预取目标页 JS」——顶栏 15 个菜单项会让
|
||||
// 首屏把其它工具页的 chunk(含 87KB 的校验库等重依赖)全拉下来,
|
||||
// 白吃带宽还挤占首屏关键请求。改为「悬停/聚焦才预取」:真正要去
|
||||
// 哪个页面时再拉那一页,首屏只加载当前页需要的代码。
|
||||
prefetchOn: { interaction: true, visibility: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
modules: [
|
||||
"nitro-cloudflare-dev",
|
||||
'@element-plus/nuxt',
|
||||
@@ -53,8 +71,12 @@ export default defineNuxtConfig({
|
||||
// SSR 期算完写进 HTML,客户端根本不会执行它,纯属白下。
|
||||
// 把 manifest 上的标记抹掉即可:动态 chunk 回到「真正 import 时才加载」。
|
||||
// 静态闭包走的是 preload 分支(modulepreload),不受影响。
|
||||
// —— 2026-10-04 曾试把 preload 也置 false:FCP 确实 2333→1404(少了 22 个请求的
|
||||
// 下载竞争),但 LCP 2387→2933、TBT 86→280(权重 25%/30% 远大于 FCP 的 10%),
|
||||
// 总分 94→89 净亏。原因:这 22 个 modulepreload 是 entry 的静态依赖闭包,全掐掉后
|
||||
// hydration 推迟、长任务落进 LCP/TBT 窗口。故只掐 prefetch,preload 必须留。
|
||||
'build:manifest': (manifest) => {
|
||||
for (const chunk of Object.values(manifest) as Array<Record<string, unknown>>) {
|
||||
for (const chunk of Object.values(manifest) as unknown as Array<Record<string, unknown>>) {
|
||||
if (chunk.prefetch) chunk.prefetch = false
|
||||
}
|
||||
},
|
||||
@@ -101,9 +123,9 @@ export default defineNuxtConfig({
|
||||
rel: 'preconnect',
|
||||
href,
|
||||
crossorigin: 'anonymous',
|
||||
})),
|
||||
} as const)),
|
||||
...(middlewareOrigin
|
||||
? [{ rel: 'dns-prefetch', href: middlewareOrigin }]
|
||||
? [{ rel: 'dns-prefetch', href: middlewareOrigin } as const]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
@@ -122,10 +144,13 @@ export default defineNuxtConfig({
|
||||
},
|
||||
routeRules: {
|
||||
// 缓存头一律不在这里设,交由 CDN(EdgeOne)侧控制。
|
||||
// /doc 文档站改为 SSG:构建时预渲染为静态 HTML,由边缘静态资源直接响应
|
||||
'/doc/**': { prerender: true },
|
||||
// /doc/** 曾经是 SSG(prerender),已撤回改为 SSR,原因见下方 nitro.prerender 处的注释。
|
||||
},
|
||||
nitro: {
|
||||
// 构建期给 public/ 静态资源生成 .gz/.br 预压缩副本,按 Accept-Encoding 直接回;
|
||||
// SSR HTML 的压缩在 server/plugins/compress-html.ts。两者合起来让本地直连的
|
||||
// 传输体积与线上(EdgeOne 会压)一致,Lighthouse 的 Lantern 模拟才不会虚高。
|
||||
compressPublicAssets: true,
|
||||
publicAssets: [
|
||||
{
|
||||
dir: 'public',
|
||||
@@ -137,17 +162,21 @@ export default defineNuxtConfig({
|
||||
target: 'es2022' // 明确告诉 Nitro 使用 es2022 进行打包
|
||||
}
|
||||
},
|
||||
prerender: {
|
||||
// 显式列出所有 doc 路由(来自 config/doc.ts 的 docConfig 键),
|
||||
// 让动态 [...slug] 页面在构建时全部预渲染为静态 HTML
|
||||
routes: ['/doc', ...Object.keys(docConfig).filter((p) => p !== '/doc')],
|
||||
},
|
||||
// 这里曾用 prerender.routes 把整份 docConfig 的文档页在构建期预渲染成静态 HTML。
|
||||
// 已撤回,改回 SSR —— 因为预渲染与「按请求头决定渲染形态」根本冲突:
|
||||
// 构建期没有请求头,窄屏判定恒为宽屏,于是预渲染产物里永远躺着
|
||||
// 「15 条桌面顶栏菜单 + 被 CSS 隐藏的 40 项文档侧栏」;真手机打开时客户端判定为窄屏,
|
||||
// 首帧 vdom 与 HTML 对不上,Vue 水合直接抛
|
||||
// `Cannot read properties of null (reading 'nodeType')`,整页变成 500。
|
||||
// 顺带收益:/doc 现在也走运行时安全头(预渲染页拿不到 CSP,见下方 security.ssg)。
|
||||
// 代价只是源站多跑一层 SSR,前面有 EdgeOne 缓存兜着。
|
||||
},
|
||||
security: {
|
||||
ssg: {
|
||||
// /doc 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
|
||||
// 触发 Cloudflare _headers 限制;关闭静态页安全头写入 _headers。
|
||||
// SSR 页面仍由运行时中间件下发完整安全头,不受影响。
|
||||
// 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
|
||||
// 触发 Cloudflare _headers 限制,故关掉静态页的安全头写入。
|
||||
// 当前没有预渲染路由(/doc/** 已改回 SSR),这条是留给将来再开 prerender 时的开关:
|
||||
// 一旦重新启用 prerender,那些页就会退回「无 CSP」状态。
|
||||
nitroHeaders: false,
|
||||
},
|
||||
headers: {
|
||||
|
||||
Generated
+5
-5
@@ -2457,8 +2457,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==}
|
||||
@@ -6173,7 +6173,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
|
||||
@@ -7741,7 +7741,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:
|
||||
@@ -9052,7 +9052,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
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { brotliCompressSync, gzipSync, constants } from 'node:zlib'
|
||||
import type { NitroApp } from 'nitro/types'
|
||||
|
||||
/**
|
||||
* SSR HTML 压缩。
|
||||
*
|
||||
* ## 为什么要压
|
||||
*
|
||||
* nitro 不压 SSR 响应体(静态资源另有 compressPublicAssets 预压缩),
|
||||
* 本地直连 / Lighthouse 测到的 HTML 就是裸传的。生产链路前面有 EdgeOne 兜底,
|
||||
* 但源站自己压是标准做法,也让本地测量贴近线上。
|
||||
*
|
||||
* 只压 SSR 渲染出的 text/html:body 是字符串、体积 > 1KB、对方 Accept-Encoding
|
||||
* 支持才压,按客户端优先级选 br(brotli)否则 gzip。
|
||||
*
|
||||
* ## 为什么必须显式指定 brotli quality = 4(别删这行)
|
||||
*
|
||||
* `brotliCompressSync` 不带 params 时默认 **quality 11**,那是给「离线压一次、
|
||||
* 反复复用」准备的(压一次存盘,读一万次)。SSR 是**每请求现压**:
|
||||
*
|
||||
* quality 33KB HTML 68KB HTML
|
||||
* 11 123ms / 7.2KB 169ms / 11.0KB
|
||||
* 6 4ms / 7.8KB 5ms / 12.2KB
|
||||
* 4 2ms / 8.6KB 3ms / 13.5KB
|
||||
*
|
||||
* Node 是单线程,这段同步压缩**全程阻塞事件循环,直接进 TTFB**。
|
||||
* 首页 TTFB 实测:q11 时 225~260ms,改 q4 后 55~105ms —— 差的就是这 120~170ms。
|
||||
* q4 只多 2.5KB,在 1.6Mbps 上是 12ms,换回 166ms,净赚。
|
||||
*
|
||||
* 教训:凡「每请求现压」的同步压缩都要显式指定质量,别吃库默认值。
|
||||
*/
|
||||
export default defineNitroPlugin((nitroApp: NitroApp) => {
|
||||
nitroApp.hooks.hook('render:response', (response, { event }) => {
|
||||
const headers = (response.headers ??= {})
|
||||
const type = String(
|
||||
headers['content-type'] ?? event.node.res.getHeader('content-type') ?? '',
|
||||
)
|
||||
if (!type.includes('text/html')) return
|
||||
if (typeof response.body !== 'string' || response.body.length < 1024) return
|
||||
if (headers['content-encoding']) return // 已有编码(异常路径),不叠加
|
||||
|
||||
// —— modulepreload 降优先级(2026-10-04 实测,mobile LH 三档对比后选定 low)——
|
||||
// entry 的 26 个静态依赖 chunk 默认 High 优先级,和 4 个 render-blocking CSS 一起
|
||||
// 排队(本地 H1 只有 6 个连接),CSS 被挤到后面 ⇒ Lantern 模拟 FCP 2345ms。
|
||||
// 加 fetchpriority="low" 后 CSS 先行:FCP 2345→1109(-1236ms,首次过 1800 满分线),
|
||||
// desktop 三轮全 100。代价是 JS 下载推迟 → hydration 落到 FCP 之后,TBT 86→213。
|
||||
// 三档实测(mobile perf):无 fp 94/95,medium 92/93(medium 仍挤 CSS,FCP 2253,
|
||||
// 收益尽失),low 94/95 —— low 总分最优。
|
||||
// TBT 的 213 是这项改动的全部代价:FCP 提前后,原本在 FCP 前不计账的 hydration
|
||||
// 长任务进了 TBT 窗口(FCP 每提前 ~500ms,TBT 约 +55,两者在当前架构下互斥)。
|
||||
if (response.body.includes('rel="modulepreload"')) {
|
||||
response.body = response.body.replaceAll(
|
||||
'rel="modulepreload"',
|
||||
'rel="modulepreload" fetchpriority="low"',
|
||||
)
|
||||
}
|
||||
|
||||
const accept = String(event.node.req.headers['accept-encoding'] ?? '')
|
||||
let encoding: 'br' | 'gzip'
|
||||
if (/\bbr\b/.test(accept)) {
|
||||
response.body = brotliCompressSync(Buffer.from(response.body), {
|
||||
params: { [constants.BROTLI_PARAM_QUALITY]: 4 },
|
||||
})
|
||||
encoding = 'br'
|
||||
} else if (/\bgzip\b/.test(accept)) {
|
||||
response.body = gzipSync(Buffer.from(response.body))
|
||||
encoding = 'gzip'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
headers['content-encoding'] = encoding
|
||||
// 体积变了,content-length 必须跟着改,否则连接会被截断/挂起
|
||||
headers['content-length'] = String((response.body as Buffer).length)
|
||||
})
|
||||
})
|
||||
@@ -133,7 +133,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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
+237
-140
@@ -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)
|
||||
@@ -390,19 +403,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 +436,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 +454,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 +466,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 +482,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 +499,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 +522,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 +538,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 +577,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 +611,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 +631,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 +644,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 +661,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
|
||||
})
|
||||
@@ -649,6 +681,78 @@ func locateUserIP(c fiber.Ctx) error {
|
||||
return c.Status(http.StatusOK).JSON(ipdb.SearchIP(ip))
|
||||
}
|
||||
|
||||
// normalizeASN 把各数据源的 ASN 字符串统一成 "AS"+数字。
|
||||
// 数据源格式不一:maxmind/dbip 的 MMDBASNResult.ASN 自带 "AS" 前缀(searchMMDBASN 拼的),
|
||||
// ip2location 的 asn 是裸数字。含非数字字符时返回空串(视为无效)。
|
||||
func normalizeASN(s string) string {
|
||||
s = strings.TrimSpace(strings.ToUpper(s))
|
||||
s = strings.TrimSpace(strings.TrimPrefix(s, "AS"))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return "AS" + s
|
||||
}
|
||||
|
||||
// resolveASN 从 SearchIP 结果中按优先级提取可用 ASN(maxmind → dbip → ip2location),返回 "ASxxxx"。
|
||||
//
|
||||
// 之所以要兜底:此前 whois 段只认 maxmind_asn,某个 IP 一旦未命中该库,整段 ASN WHOIS 就凭空消失,
|
||||
// 哪怕 dbip_asn / ip2location_asn 明明有数据。
|
||||
func resolveASN(result map[string]interface{}) string {
|
||||
for _, key := range []string{"maxmind_asn", "dbip_asn"} {
|
||||
if r, ok := result[key].(*ipdb.MMDBASNResult); ok {
|
||||
if asn := normalizeASN(r.ASN); asn != "" {
|
||||
return asn
|
||||
}
|
||||
}
|
||||
}
|
||||
if m, ok := result["ip2location_asn"].(map[string]string); ok {
|
||||
if asn := normalizeASN(m["asn"]); asn != "" {
|
||||
return asn
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// asnWhoisFailed 判定 ASN WHOIS 结果是否算失败,决定缓存 TTL(失败 30s / 成功 5min)。
|
||||
// 注意 likexian/whois 出错时也返回 (result, nil),错误塞在 result.Error 里,不能只看 err 参数。
|
||||
func asnWhoisFailed(r *webtest.ASNWhoisResult) bool {
|
||||
return r == nil || r.Error != "" || (r.ASName == "" && r.OrgName == "")
|
||||
}
|
||||
|
||||
// lookupASNWhois 带缓存的 ASN WHOIS 查询,handler 与 ws 两条路径共用。
|
||||
// 缓存键取归一化后的 ASN("AS13335"):历史上是 "AS"+已带前缀的 ASN,实际拼成了 "ASAS13335"。
|
||||
func lookupASNWhois(asn string) (*webtest.ASNWhoisResult, bool) {
|
||||
asn = normalizeASN(asn)
|
||||
if asn == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if cached, ok := asnWhoisCache.Load(asn); ok {
|
||||
if entry, ok := cached.(asnWhoisCacheEntry); ok && entry.result != nil {
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return entry.result, true
|
||||
}
|
||||
}
|
||||
asnWhoisCache.Delete(asn)
|
||||
}
|
||||
|
||||
whoisData, err := webtest.QueryASNWhois(asn)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
asnWhoisCache.Store(asn, asnWhoisCacheEntry{
|
||||
result: whoisData,
|
||||
timestamp: time.Now(),
|
||||
failed: asnWhoisFailed(whoisData),
|
||||
})
|
||||
return whoisData, true
|
||||
}
|
||||
|
||||
func asnLookupHandler(c fiber.Ctx) error {
|
||||
ip := c.Params("ip")
|
||||
if ip == "" {
|
||||
@@ -696,22 +800,10 @@ func asnLookupHandler(c fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 WHOIS 对 ASN 进行进一步解析
|
||||
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
asnKey := "AS" + maxmindASN.ASN
|
||||
if cached, ok := asnWhoisCache.Load(asnKey); ok {
|
||||
entry := cached.(asnWhoisCacheEntry)
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
asnResult["whois"] = entry.result
|
||||
} else {
|
||||
asnWhoisCache.Delete(asnKey)
|
||||
}
|
||||
} else {
|
||||
whoisData, err := webtest.QueryASNWhois(maxmindASN.ASN)
|
||||
if err == nil {
|
||||
asnResult["whois"] = whoisData
|
||||
asnWhoisCache.Store(asnKey, asnWhoisCacheEntry{result: whoisData, timestamp: time.Now()})
|
||||
}
|
||||
// 使用 WHOIS 对 ASN 进行进一步解析(maxmind 未命中时回落到 dbip / ip2location)
|
||||
if asn := resolveASN(result); asn != "" {
|
||||
if whoisData, ok := lookupASNWhois(asn); ok {
|
||||
asnResult["whois"] = whoisData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,22 +818,29 @@ func whoisHandler(c fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
if cached, ok := whoisCache.Load(domain); 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 := domain + req.CacheKey()
|
||||
|
||||
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)
|
||||
result, err := webtest.QueryWhois(domain, req.Servers("ipv4"), req.Servers("ipv6"))
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.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)
|
||||
}
|
||||
|
||||
@@ -775,84 +874,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")
|
||||
@@ -884,7 +961,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) {
|
||||
@@ -896,9 +990,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(),
|
||||
@@ -906,10 +1000,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(),
|
||||
@@ -917,7 +1011,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
|
||||
@@ -925,7 +1019,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(),
|
||||
@@ -936,7 +1030,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(),
|
||||
@@ -1636,6 +1730,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
|
||||
}
|
||||
|
||||
+294
-32
@@ -2,12 +2,14 @@ package webtest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -102,8 +104,229 @@ const (
|
||||
defaultDoHEndpoint = "https://doh.pub/dns-query"
|
||||
)
|
||||
|
||||
// queryDNS 普通 DNS 查询:主从 failover——按配置顺序依次尝试每个服务器,
|
||||
// 当前服务器查询失败(网络错误/超时)自动切换下一个;全部失败返回最后一个错误。
|
||||
// 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{}
|
||||
}
|
||||
|
||||
// queryDNS 普通 DNS 查询:使用配置的 dns-server(主从 failover,见 queryWithServers)。
|
||||
// resolveTyped 未收到请求级 servers 时走这里。
|
||||
func queryDNS(msg *dns.Msg) (*dns.Msg, error) {
|
||||
return queryWithServers(msg, dnsServers)
|
||||
}
|
||||
@@ -206,13 +429,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 +466,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 +479,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 +488,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 +498,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 +507,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 +516,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 +525,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 +534,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 +543,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 +557,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 +596,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 +606,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()
|
||||
|
||||
+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
|
||||
|
||||
+3
-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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+235
-67
@@ -104,20 +104,20 @@ const whoisReferralTimeout = 3 * time.Second
|
||||
// 这样即便转介服务器不可达也只在 ~1s 内拿到注册局记录,不会干等满库的 30s 超时。
|
||||
// 兜底:主路径拿不到内容时才 fallback(内置 TLD 映射 → IANA 查服务器 → 自定义 DNS 解析 IP →
|
||||
// Happy Eyeballs 双栈竞争拨号)。
|
||||
func QueryWhois(domain string) (*WhoisResult, error) {
|
||||
raw, err := queryWhoisRegistry(domain)
|
||||
func QueryWhois(domain string, v4Servers, v6Servers []string) (*WhoisResult, error) {
|
||||
raw, err := queryWhoisRegistry(domain, v4Servers, v6Servers)
|
||||
|
||||
// 库在「连接成功但读失败」等场景会同时返回部分数据与错误,此时数据不能丢;
|
||||
// 因此只在「内容为空」或「带错误」时让 fallback 再兜一次,并取内容更长的一方。
|
||||
if err != nil || raw == "" {
|
||||
if fb, fbErr := whoisRetryWithFallback(domain, err); len(fb) > len(raw) {
|
||||
if fb, fbErr := whoisRetryWithFallback(domain, err, v4Servers, v6Servers); len(fb) > len(raw) {
|
||||
raw, err = fb, fbErr
|
||||
}
|
||||
}
|
||||
|
||||
// 仅当注册局响应干净(err == nil)时才补查注册商转介
|
||||
if err == nil && raw != "" {
|
||||
if extra := chaseReferral(domain, raw); extra != "" {
|
||||
if extra := chaseReferral(domain, raw, v4Servers, v6Servers); extra != "" {
|
||||
raw += extra
|
||||
}
|
||||
}
|
||||
@@ -129,16 +129,63 @@ func QueryWhois(domain string) (*WhoisResult, error) {
|
||||
|
||||
// queryWhoisRegistry 走 IANA → 注册局两跳取注册局记录,关闭库的转介链
|
||||
// (转介改由 chaseReferral 限时补查,避免被不可达的转介服务器拖满 30s)
|
||||
func queryWhoisRegistry(domain string) (string, error) {
|
||||
return whois.NewClient().
|
||||
func queryWhoisRegistry(domain string, v4Servers, v6Servers []string) (string, error) {
|
||||
client := whois.NewClient().
|
||||
SetDisableReferral(true).
|
||||
SetTimeout(whoisLibTimeout).
|
||||
Whois(domain)
|
||||
SetTimeout(whoisLibTimeout)
|
||||
// 请求级 DNS 覆盖:库默认走系统解析器,注入自定义拨号器改成走我们的 DNS
|
||||
if len(v4Servers) > 0 || len(v6Servers) > 0 {
|
||||
client.SetDialer(whoisResolveDialer{v4Servers: v4Servers, v6Servers: v6Servers})
|
||||
}
|
||||
return client.Whois(domain)
|
||||
}
|
||||
|
||||
// whoisResolveDialer 实现 x/net/proxy.Dialer(结构体天然满足接口):
|
||||
// likexian/whois 内部需要拨 whois 服务器时,先按请求级 DNS 解析成 IP 再拨,
|
||||
// 避免库退回系统解析器、绕过 dns=/dns4=/dns6= 覆盖。
|
||||
type whoisResolveDialer struct {
|
||||
v4Servers, v6Servers []string
|
||||
}
|
||||
|
||||
// Dial 优先 A(v4),失败再 AAAA(v6)——whois 服务器绝大多数只有 v4。
|
||||
func (d whoisResolveDialer) Dial(network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: whoisConnectTimeout}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
if aRes, aErr := ResolveARecord(host, d.v4Servers); aErr == nil && len(aRes.Record) > 0 {
|
||||
if conn, err := dialer.Dial("tcp", net.JoinHostPort(aRes.Record[0], port)); err == nil {
|
||||
return conn, nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
} else if aErr != nil {
|
||||
lastErr = aErr
|
||||
}
|
||||
if aaaaRes, aaaaErr := ResolveAAAARecord(host, d.v6Servers); aaaaErr == nil && len(aaaaRes.Record) > 0 {
|
||||
if conn, err := dialer.Dial("tcp", net.JoinHostPort(aaaaRes.Record[0], port)); err == nil {
|
||||
return conn, nil
|
||||
} else if lastErr == nil {
|
||||
lastErr = err
|
||||
}
|
||||
} else if aaaaErr != nil && lastErr == nil {
|
||||
lastErr = aaaaErr
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no IP found for whois server %s", host)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// chaseReferral 从注册局响应里取出注册商转介服务器,限时补查一次
|
||||
// 任何失败(无转介 / 不可达 / 超时)都返回空串,绝不影响已到手的注册局数据
|
||||
func chaseReferral(domain, raw string) string {
|
||||
func chaseReferral(domain, raw string, v4Servers, v6Servers []string) string {
|
||||
server, port := extractReferralServer(raw)
|
||||
if server == "" {
|
||||
return ""
|
||||
@@ -148,7 +195,7 @@ func chaseReferral(domain, raw string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
v4IPs, v6IPs, err := resolveWhoisServerIPs(server)
|
||||
v4IPs, v6IPs, err := resolveWhoisServerIPs(server, v4Servers, v6Servers)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -268,7 +315,7 @@ func filterPublicIPs(ips []string) []string {
|
||||
|
||||
// resolveWhoisServerIPs 使用 webtest/dns 的统一解析接口
|
||||
// 并行查询 A + AAAA(走自定义 DNS 服务器,不受系统 resolver 策略影响)
|
||||
func resolveWhoisServerIPs(server string) (v4IPs, v6IPs []string, err error) {
|
||||
func resolveWhoisServerIPs(server string, v4Servers, v6Servers []string) (v4IPs, v6IPs []string, err error) {
|
||||
var (
|
||||
aResult DNSResult
|
||||
aaaaResult DNSResult
|
||||
@@ -279,11 +326,11 @@ func resolveWhoisServerIPs(server string) (v4IPs, v6IPs []string, err error) {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
aResult, aErr = ResolveARecord(server)
|
||||
aResult, aErr = ResolveARecord(server, v4Servers)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
aaaaResult, aaaaErr = ResolveAAAARecord(server)
|
||||
aaaaResult, aaaaErr = ResolveAAAARecord(server, v6Servers)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
@@ -372,7 +419,7 @@ func happyEyeballsWhoisQuery(parent context.Context, domain string, v4IPs, v6IPs
|
||||
}
|
||||
|
||||
// whoisRetryWithFallback 在 whois.Whois 失败后,手动解析 WHOIS 服务器所有 IP 逐个尝试
|
||||
func whoisRetryWithFallback(domain string, firstErr error) (string, error) {
|
||||
func whoisRetryWithFallback(domain string, firstErr error, v4Servers, v6Servers []string) (string, error) {
|
||||
ext := getExtension(domain)
|
||||
|
||||
// 解析 WHOIS 服务器地址(内置映射 + IANA fallback)
|
||||
@@ -382,7 +429,7 @@ func whoisRetryWithFallback(domain string, firstErr error) (string, error) {
|
||||
}
|
||||
|
||||
// 使用 ResolveA/AAAA(自定义 DNS)并行解析 v4/v6
|
||||
v4IPs, v6IPs, err := resolveWhoisServerIPs(server)
|
||||
v4IPs, v6IPs, err := resolveWhoisServerIPs(server, v4Servers, v6Servers)
|
||||
if err != nil {
|
||||
return "", firstErr
|
||||
}
|
||||
@@ -614,63 +661,184 @@ type ASNWhoisResult struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// asnFieldPatterns 用于从原始 ASN WHOIS 文本中提取字段
|
||||
var asnFieldPatterns = map[string]*regexp.Regexp{
|
||||
"asNumber": regexp.MustCompile(`(?i)^\s*ASNumber\s*[:=]\s*(.+?)\s*$`),
|
||||
"asName": regexp.MustCompile(`(?i)^\s*ASName\s*[:=]\s*(.+?)\s*$`),
|
||||
"asHandle": regexp.MustCompile(`(?i)^\s*ASHandle\s*[:=]\s*(.+?)\s*$`),
|
||||
"regDate": regexp.MustCompile(`(?i)^\s*RegDate\s*[:=]\s*(.+?)\s*$`),
|
||||
"updated": regexp.MustCompile(`(?i)^\s*Updated\s*[:=]\s*(.+?)\s*$`),
|
||||
"orgName": regexp.MustCompile(`(?i)^\s*OrgName\s*[:=]\s*(.+?)\s*$`),
|
||||
"orgId": regexp.MustCompile(`(?i)^\s*OrgId\s*[:=]\s*(.+?)\s*$`),
|
||||
"country": regexp.MustCompile(`(?i)^\s*Country\s*[:=]\s*([A-Z]{2})\s*$`),
|
||||
"abuseName": regexp.MustCompile(`(?i)^\s*OrgAbuseName\s*[:=]\s*(.+?)\s*$`),
|
||||
"abuseEmail": regexp.MustCompile(`(?i)^\s*OrgAbuseEmail\s*[:=]\s*(.+?)\s*$`),
|
||||
"abusePhone": regexp.MustCompile(`(?i)^\s*OrgAbusePhone\s*[:=]\s*(.+?)\s*$`),
|
||||
// ==================== ASN WHOIS 多 RIR 解析 ====================
|
||||
//
|
||||
// QueryASNWhois 走 likexian/whois 的默认链路:先问 IANA(IANA 按 ASN 给出归属 RIR),
|
||||
// 再取该 RIR 的记录;若 ARIN 只回了「转介桩」,库会再跟一跳取到真实记录。
|
||||
// 因此 raw 里可能同时存在两段内容,且字段名随 RIR 而异:
|
||||
//
|
||||
// ARIN : ASNumber / ASName / OrgName / OrgId / Country / RegDate / Updated / OrgAbuse*
|
||||
// APNIC : aut-num / as-name / descr / country / org / last-modified(无 org-name 时组织名在 descr)
|
||||
// RIPE : aut-num / as-name / org / org-name / country / created / last-modified
|
||||
// LACNIC : aut-num / owner / ownerid / country / created / changed
|
||||
//
|
||||
// 改造前只认 ARIN 字段 ⇒ 除 ARIN 注册的 ASN(Cloudflare AS13335、Google AS15169 等)外,
|
||||
// 其余 RIR 的 ASN 全部解析为空(如中国电信 AS4134 只拿到 ARIN 转介桩的 "APNIC-4134")。
|
||||
//
|
||||
// 现在的规则:raw 里**存在 RIR 原生 aut-num 记录时一律以它为准,并整体忽略 ARIN 段**
|
||||
// (ARIN 对非 ARIN 的 ASN 只回桩,其 ASName/OrgName/OrgAbuseEmail 都是误导信息);
|
||||
// 没有原生记录时才按 ARIN 格式解析。
|
||||
var (
|
||||
// 原生(APNIC/RIPE/LACNIC/AFRINIC)字段
|
||||
reASNAutNum = regexp.MustCompile(`(?im)^\s*aut-num:\s*AS?(\d+)\s*$`)
|
||||
reASNAsName = regexp.MustCompile(`(?im)^\s*as-name:\s*(.+?)\s*$`)
|
||||
reASNOrgName = regexp.MustCompile(`(?im)^\s*org-name:\s*(.+?)\s*$`)
|
||||
reASNOwner = regexp.MustCompile(`(?im)^\s*owner:\s*(.+?)\s*$`)
|
||||
reASNOwnerID = regexp.MustCompile(`(?im)^\s*ownerid:\s*(.+?)\s*$`)
|
||||
reASNOrgRef = regexp.MustCompile(`(?im)^\s*org:\s*(.+?)\s*$`)
|
||||
reASNDescr = regexp.MustCompile(`(?im)^\s*descr:\s*(.+?)\s*$`)
|
||||
reASNCreated = regexp.MustCompile(`(?im)^\s*created:\s*(.+?)\s*$`)
|
||||
reASNRegistered = regexp.MustCompile(`(?im)^\s*registered:\s*(.+?)\s*$`)
|
||||
reASNLastMod = regexp.MustCompile(`(?im)^\s*last-modified:\s*(.+?)\s*$`)
|
||||
reASNChanged = regexp.MustCompile(`(?im)^\s*changed:\s*(.+?)\s*$`)
|
||||
reASNMailbox = regexp.MustCompile(`(?im)^\s*abuse-mailbox:\s*(\S+@\S+)\s*$`)
|
||||
// APNIC/RIPE 会在响应顶部给一行注释:% Abuse contact for 'AS37963' is 'xxx@yyy'
|
||||
reASNCommentAbuse = regexp.MustCompile(`(?i)%\s*Abuse contact for '?\s*AS\d+\s*'?\s*is\s*'?([^'\s]+)'?`)
|
||||
|
||||
// ARIN 字段(保持改造前语义;注意:这些正则在「多行整段」文本上用,必须带 m 标志)
|
||||
reASNNumberArin = regexp.MustCompile(`(?im)^\s*ASNumber\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNNameArin = regexp.MustCompile(`(?im)^\s*ASName\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNHandleArin = regexp.MustCompile(`(?im)^\s*ASHandle\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNRegDateArin = regexp.MustCompile(`(?im)^\s*RegDate\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNUpdatedArin = regexp.MustCompile(`(?im)^\s*Updated\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNOrgNameArin = regexp.MustCompile(`(?im)^\s*OrgName\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNOrgIDArin = regexp.MustCompile(`(?im)^\s*OrgId\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNCountryArin = regexp.MustCompile(`(?im)^\s*Country\s*[:=]\s*([A-Z]{2})\s*$`)
|
||||
reASNAbuseName = regexp.MustCompile(`(?im)^\s*OrgAbuseName\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNAbuseEmail = regexp.MustCompile(`(?im)^\s*OrgAbuseEmail\s*[:=]\s*(.+?)\s*$`)
|
||||
reASNAbusePhone = regexp.MustCompile(`(?im)^\s*OrgAbusePhone\s*[:=]\s*(.+?)\s*$`)
|
||||
|
||||
// RIR 通用的「非 ASN 记录块」噪音(as-block 对象的 descr,如 "APNIC ASN block")
|
||||
reASNBlockNoise = regexp.MustCompile(`(?i)(^|\s)ASN? block\s*$`)
|
||||
// 滥用联系人的对象名:RIPE 用 irt:,APNIC 的 abuse-c 对象用 role:
|
||||
reASNRoleName = regexp.MustCompile(`(?im)^\s*(?:irt|role):\s*(.+?)\s*$`)
|
||||
reASNPhone = regexp.MustCompile(`(?im)^\s*phone:\s*(.+?)\s*$`)
|
||||
)
|
||||
|
||||
// splitWhoisBlocks 按空行把响应切成记录块(每个 RIR 对象一段)
|
||||
func splitWhoisBlocks(raw string) []string {
|
||||
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||||
return strings.Split(raw, "\n\n")
|
||||
}
|
||||
|
||||
// parseASNWhoisRaw 从原始 ASN WHOIS 响应中解析结构化数据
|
||||
func parseASNWhoisRaw(raw string) *ASNWhoisResult {
|
||||
result := &ASNWhoisResult{Raw: raw}
|
||||
lines := strings.Split(raw, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
for field, pattern := range asnFieldPatterns {
|
||||
if matches := pattern.FindStringSubmatch(line); len(matches) == 2 {
|
||||
switch field {
|
||||
case "asNumber":
|
||||
result.ASNumber = matches[1]
|
||||
case "asName":
|
||||
result.ASName = matches[1]
|
||||
case "asHandle":
|
||||
if result.ASNumber == "" {
|
||||
result.ASNumber = strings.TrimPrefix(matches[1], "AS")
|
||||
}
|
||||
case "regDate":
|
||||
result.RegDate = matches[1]
|
||||
case "updated":
|
||||
result.Updated = matches[1]
|
||||
case "orgName":
|
||||
result.OrgName = matches[1]
|
||||
case "orgId":
|
||||
result.OrgID = matches[1]
|
||||
case "country":
|
||||
result.Country = matches[1]
|
||||
case "abuseName":
|
||||
result.AbuseName = matches[1]
|
||||
case "abuseEmail":
|
||||
result.AbuseEmail = matches[1]
|
||||
case "abusePhone":
|
||||
result.AbusePhone = matches[1]
|
||||
}
|
||||
// pickField 按给定优先级在文本中取第一个命中的字段值;全不命中返回 ""
|
||||
func pickField(text string, patterns ...*regexp.Regexp) string {
|
||||
for _, pattern := range patterns {
|
||||
if m := pattern.FindStringSubmatch(text); len(m) == 2 {
|
||||
if v := strings.TrimSpace(m[1]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findASNNativeBlock 找出含 aut-num 的原生记录块;没有返回 ""
|
||||
func findASNNativeBlock(raw string) string {
|
||||
for _, block := range splitWhoisBlocks(raw) {
|
||||
if reASNAutNum.MatchString(block) {
|
||||
return block
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findASNOrgBlock 按原生块里的 org: 句柄定位对应的 organisation/owner 对象
|
||||
// (RIPE 的 org-name 与 country 不在 aut-num 对象里,而在 organisation 对象里)
|
||||
func findASNOrgBlock(raw, nativeBlock string) string {
|
||||
handle := pickField(nativeBlock, reASNOrgRef)
|
||||
if handle == "" {
|
||||
return ""
|
||||
}
|
||||
for _, block := range splitWhoisBlocks(raw) {
|
||||
if strings.Contains(block, handle) && (reASNOrgName.MatchString(block) || reASNOwner.MatchString(block)) {
|
||||
return block
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findASNOrgName 取组织名:organisation 对象的 org-name/owner(RIPE/APNIC/JPNIC)
|
||||
// → 原生块首行 descr(APNIC 常见,如 "Hangzhou Alibaba Advertising Co.,Ltd."),并过滤 block 噪音
|
||||
func findASNOrgName(orgBlock, nativeBlock string) string {
|
||||
if v := pickField(orgBlock, reASNOrgName, reASNOwner); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, m := range reASNDescr.FindAllStringSubmatch(nativeBlock, -1) {
|
||||
v := strings.TrimSpace(m[1])
|
||||
if v == "" || reASNBlockNoise.MatchString(v) {
|
||||
continue
|
||||
}
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findASNCommentAbuseEmail 取响应顶部注释里的 Abuse 邮箱(APNIC/RIPE 提供)
|
||||
func findASNCommentAbuseEmail(raw string) string {
|
||||
if m := reASNCommentAbuse.FindStringSubmatch(raw); len(m) == 2 {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findASNMailboxAbuse 取含 abuse-mailbox 的记录块里的联系人名与电话(RIPE 的 irt:、APNIC 的 role:)
|
||||
func findASNMailboxAbuse(raw string) (name, email, phone string) {
|
||||
for _, block := range splitWhoisBlocks(raw) {
|
||||
mailbox := pickField(block, reASNMailbox)
|
||||
if mailbox == "" {
|
||||
continue
|
||||
}
|
||||
return pickField(block, reASNRoleName), mailbox, pickField(block, reASNPhone)
|
||||
}
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
// parseASNWhoisRaw 从原始 ASN WHOIS 响应中解析结构化数据(多 RIR)
|
||||
func parseASNWhoisRaw(raw string) *ASNWhoisResult {
|
||||
result := &ASNWhoisResult{Raw: raw}
|
||||
|
||||
// 情况一:有 RIR 原生记录(APNIC/RIPE/LACNIC/AFRINIC),只认它
|
||||
if nativeBlock := findASNNativeBlock(raw); nativeBlock != "" {
|
||||
orgBlock := findASNOrgBlock(raw, nativeBlock)
|
||||
result.ASNumber = pickField(nativeBlock, reASNAutNum, reASNNumberArin)
|
||||
result.ASName = pickField(nativeBlock, reASNAsName)
|
||||
result.OrgName = findASNOrgName(orgBlock, nativeBlock)
|
||||
result.OrgID = pickField(nativeBlock, reASNOrgRef, reASNOwnerID)
|
||||
result.Country = pickField(nativeBlock, reASNCountryArin)
|
||||
if result.Country == "" {
|
||||
result.Country = pickField(orgBlock, reASNCountryArin)
|
||||
}
|
||||
result.RegDate = pickField(nativeBlock, reASNCreated, reASNRegistered, reASNRegDateArin)
|
||||
result.Updated = pickField(nativeBlock, reASNLastMod, reASNUpdatedArin, reASNChanged)
|
||||
|
||||
// 滥用联系人:先用注释行(最权威),再用 abuse-mailbox 所在对象
|
||||
result.AbuseEmail = findASNCommentAbuseEmail(raw)
|
||||
name, email, phone := findASNMailboxAbuse(raw)
|
||||
if result.AbuseName == "" {
|
||||
result.AbuseName = name
|
||||
}
|
||||
if result.AbuseEmail == "" {
|
||||
result.AbuseEmail = email
|
||||
}
|
||||
result.AbusePhone = phone
|
||||
return result
|
||||
}
|
||||
|
||||
// 情况二:ARIN 格式(ARIN 自有的 ASN),保持改造前行为
|
||||
result.ASNumber = pickField(raw, reASNNumberArin)
|
||||
if result.ASNumber == "" {
|
||||
result.ASNumber = strings.TrimPrefix(pickField(raw, reASNHandleArin), "AS")
|
||||
}
|
||||
result.ASName = pickField(raw, reASNNameArin)
|
||||
result.OrgName = pickField(raw, reASNOrgNameArin)
|
||||
result.OrgID = pickField(raw, reASNOrgIDArin)
|
||||
result.Country = pickField(raw, reASNCountryArin)
|
||||
result.RegDate = pickField(raw, reASNRegDateArin)
|
||||
result.Updated = pickField(raw, reASNUpdatedArin)
|
||||
result.AbuseName = pickField(raw, reASNAbuseName)
|
||||
result.AbuseEmail = pickField(raw, reASNAbuseEmail)
|
||||
result.AbusePhone = pickField(raw, reASNAbusePhone)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -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,24 @@ 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 {
|
||||
req, err := webtest.ParseReqResolve(func(k string) string { return query[k] })
|
||||
if err != nil {
|
||||
return 400, map[string]string{"error": err.Error()}
|
||||
}
|
||||
// 缓存键带上请求级 DNS 覆盖(结果取决于所用服务器,参数不同不能共用)
|
||||
cacheKey := raw + req.CacheKey()
|
||||
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)
|
||||
result, err := webtest.QueryWhois(raw, req.Servers("ipv4"), req.Servers("ipv6"))
|
||||
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":
|
||||
@@ -234,9 +270,9 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
} else if errStr, ok := result["dbip_asn"].(string); ok {
|
||||
asnResult["dbip_asn"] = map[string]string{"error": errStr}
|
||||
}
|
||||
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
|
||||
whoisData, err := webtest.QueryASNWhois(maxmindASN.ASN)
|
||||
if err == nil {
|
||||
// WHOIS 进一步解析(与 /v1/asn/:ip 走同一套兜底与缓存)
|
||||
if asn := resolveASN(result); asn != "" {
|
||||
if whoisData, ok := lookupASNWhois(asn); ok {
|
||||
asnResult["whois"] = whoisData
|
||||
}
|
||||
}
|
||||
@@ -258,7 +294,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