Compare commits

..
Author SHA1 Message Date
nomdn 9a4c7d57a8 fix(report): nodeIsProbeType 白名单补齐 location,与中心侧对齐
节点侧 nodeIsProbeType 白名单原漏 location,导致节点转发/自发 location 查询只累加统计
(中心 request_stats 收全部 apiType,故大盘有 Location 统计)而不上报明细(被 !isProbeType 挡),
造成『大盘有、明细没有』。补齐后与中心 store.go 白名单一致,location 现进入 probe_results 明细。
2026-09-19 23:01:41 +08:00
nomdn 8522fd5c23 Merge branch 'main' of https://github.com/nomdn/ipw-cn 2026-09-19 15:35:32 +08:00
nomdn 08f2a6780d feat(install): 交互改造——DNS 留空即自动探测 / WS 预设指向收集中心 / 注册 key 自动生成
- dns-server 去掉写死的 119.28.28.28:53 默认值:留空 = 不写 Environment,节点启动探 /etc/resolv.conf
  (docs/guide/deploy-node 一直写着"留空=自动探测",是脚本把这个默认值堵死了,两边现在对齐)
- WS 预设链接改为收集中心 wss://boce-api.api-ipw.wsmdn.top/ws(原 middleware-1,提示语与默认值两处)
- 注册 key 留空自动生成 32 位 hex,不再强制必填重问;汇总与结尾信息明文回显(非机密,access-token 仍隐藏)
- 节点 id 提问提到 WS 分支之外:WS 关掉也问、也写 Environment=NODE_ID(HTTP 上报身份同样依赖它)
- 汇总展示补「节点 id」行;移除脚本头注释里的第三方品牌字样
2026-09-19 15:15:38 +08:00
nomdn b03d0a05ee fix(middleware): WS 已知帧显式丢弃——report/config_result/ota_result 不再落 default 刷日志
节点按 report-interval-seconds 把统计与拨测明细广播给所有在线 WS 连接(多活),
它们的消费者是收集中心 ipw-boce;middleware-go 只做转发与拨测代理、不在转发路径采集,
原先这三个帧落到 default 被当成"未知消息"逐条打印,日志噪音随节点数 × 上报频率线性增长。

- ws.go:显式列出 report / config_result / ota_result,收到即丢弃并注明归属(空 case 体,不走 default)
- docs:补齐「节点 → middleware」帧表(ping/pong、report、config_result/ota_result),
  修正心跳周期(middleware → 节点 ping 为 20s),删除已废弃的 command 行,register 补 version/capabilities
2026-09-19 15:15:28 +08:00
nomdn 7beb66ee2b Merge pull request #31 from nomdn/dependabot/npm_and_yarn/serverless/lemon-getip/hono-4.13.5
chore(deps): bump hono from 4.12.34 to 4.13.5 in /serverless/lemon-getip
2026-09-19 08:56:14 +08:00
nomdn 5ff430214d Merge pull request #33 from nomdn/dependabot/npm_and_yarn/frontend-ssr/devalue-5.9.2
chore(deps): bump devalue from 5.9.0 to 5.9.2 in /frontend-ssr
2026-09-19 08:55:57 +08:00
nomdn 1588896fac Add Baidu site verification meta tag 2026-09-19 08:55:37 +08:00
nomdn edc8f97acd Clear ICP and GongAn values in config
Removed ICP and GongAn values from configuration.
2026-09-18 23:00:26 +08:00
nomdn 4dd807d99c Update siteUrl in configuration 2026-09-18 22:33:40 +08:00
nomdn 2cf31260ae Update index.ts 2026-09-18 22:31:10 +08:00
dependabot[bot] d7df245b75 chore(deps): bump devalue from 5.9.0 to 5.9.2 in /frontend-ssr
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.9.0 to 5.9.2.
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.9.0...v5.9.2)

---
updated-dependencies:
- dependency-name: devalue
  dependency-version: 5.9.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 02:51:04 +00:00
nomdn 345422b60f Merge branch 'main' of https://github.com/nomdn/ipw-cn 2026-09-13 21:10:43 +08:00
nomdn d7cc9d45e7 feat: 节点远程运维能力落地——运行时配置/能力协商/OTA 下发 + EO 上报通道
OTA 模型反转:节点不再跟随 GitHub Release 自更新,改为收集中心下发、节点执行
的一次性任务(WS type=ota / HTTP POST /v1/ota),升级时机由管理员掌握。

- 新增 /v1/config(GET/PATCH,?persist=true 写回)与 /v1/config/refresh,WS 同款 type=config;凭据类键遮蔽为 ***,应答带 restartRequired / remoteProtectedKeys 键
- access-token / report-token 列入远端不可覆盖名单,本地 PATCH 不受限
- node-ota 开关支持运行时热改(每次下发 OTA 指令时才读取),非法字面值报 unknown 且不覆盖原值
- 节点上报 capabilities(probe/report/config/ota),收集中心三态门控管理指令
- ws-url 支持热更新(per-URL 连接协程多退少补),node-id/node-key 仍需重启
- EO 边缘函数补齐 HTTP 上报通道(report.go)与 GET / 的版本/能力回报
- 上报循环常驻化;显式持有 http.Server 实现优雅停机
- 修复 block-private-ips 启动期忽略 setting.json 的口径不一致
- OTA 开关键沿用 node-ota / NODE_OTA,语义改为缺省允许、仅显式 false 禁用;install.sh 不再询问自更新
- 文档站新增 节点 API与协议参考 / 公开 API / 功能总览
- Dockerfile 注入 VERSION/COMMIT/BUILD_TIME
2026-09-13 21:05:27 +08:00
nomdn 972ca4c744 Merge pull request #32 from nomdn/dependabot/npm_and_yarn/serverless/lemon-getip/multi-d0c2d048a7
chore(deps): bump @vitest/mocker and vitest in /serverless/lemon-getip
2026-09-10 23:34:47 +08:00
dependabot[bot] 12c91ed2d3 chore(deps): bump @vitest/mocker and vitest in /serverless/lemon-getip
Bumps [@vitest/mocker](https://github.com/vitest-dev/vitest/tree/HEAD/packages/mocker) to 4.1.11 and updates ancestor dependency [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.


Updates `@vitest/mocker` from 4.1.10 to 4.1.11
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/mocker)

Updates `vitest` from 4.1.10 to 4.1.11
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest)

---
updated-dependencies:
- dependency-name: "@vitest/mocker"
  dependency-version: 4.1.11
  dependency-type: indirect
- dependency-name: vitest
  dependency-version: 4.1.11
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-10 06:54:32 +00:00
dependabot[bot] 4fceb8421e chore(deps): bump hono from 4.12.34 to 4.13.5 in /serverless/lemon-getip
Bumps [hono](https://github.com/honojs/hono) from 4.12.34 to 4.13.5.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.34...v4.13.5)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.13.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-10 00:04:48 +00:00
nomdn 56d6bc6c08 refactor: 上报归属收敛——节点为唯一记录者,废除前端转发双算协议
- frontend-ssr(Nuxt TS 中间件):移除转发路径上的 /report 数据上报与 X-Boce-Reporter 归属标记头及 nuxt.config 上报配置,本转发入口不再计数(转发请求也不再携带防双算标记)
- src/report.go:归属规则改为"节点是唯一记录者",统计与拨测明细均由本节点上报,中间件不在转发路径计数故不存在双算;HTTP(/v1) 与 WS 下发两条来源都上报;明细类型扩充为 tcping/speed/detail/ssl/dns(dnssec 只进统计不进明细);废除按 requestId/X-Boce-Reporter 判归属,改用 X-Scheduler-Probe 头(HTTP)与 Scheduler 标记(WS)区分收集中心主动调度拨测(收集中心侧本地落库 sched/biz,本节点跳过避免双算)
- src/ws.go:wsHandleProbe 识别 Scheduler=true 仅回 probe_result 不上报
- 依赖整理:AdguardTeam/golibs 转直接依赖,go.sum tidy
2026-09-06 16:54:37 +08:00
nomdn 981b2fec9c feat(node): 数据上报收集中心对接;修复非 OTA 节点启动即退出
数据上报(src/report.go):
- 新增 report-url / report-token / report-interval-seconds 配置(env REPORT_URL 等)
- 节点把统计与拨测明细按 report-interval-seconds 周期上报收集中心(ipw-boce):
  WS 在线优先广播 report 消息,全部掉线时 HTTP POST <report-url>/report 兜底
- 归属规则防双算:中间件带 X-Boce-Reporter 标记头的转发请求、WS 带 requestId
  的下发拨测由中间件侧上报,节点只报直连/前端/边缘流量
- report-token 属敏感凭据,不随远端配置覆盖

修复节点启动即退出(严重,v3.3.0 回归):
- OTA 重构后 main 仅在「OTA 开启且 Windows」停车,其余形态 main 返回 = 进程
  立即退出(Serve 在后台 goroutine),节点起不来(systemd 下 5s 崩溃循环)
- 非 OTA 交接模式改为阻塞等退出信号(Ctrl+C / SIGTERM),收到后走
  gracefulShutdown 优雅停机;顺带使 systemctl stop 从硬杀变为优雅退出

附带:
- 修复 report 排水循环 break 位置错误(每周期仅上报 1 条明细,队列打满后
  明细近乎全丢)
2026-08-29 22:23:23 +08:00
nomdn f1790742a3 fix(middleware): 恢复 setting.json port 生效(env > setting.json > 默认 8080)
OTA 重构时 main() 的 PORT = os.Getenv("PORT") 在 env 未设置时覆盖了
readConfig 从 setting.json 读出的端口,导致 setting.json 的 port 永不生效。
删掉该覆盖,仅在 PORT 为空时补默认 8080;env 与 setting.json 的合并
本就由 readConfig 完成,优先级恢复为 env > setting.json > 默认
2026-08-29 21:08:05 +08:00
nomdn 6b9f6b6b54 fix(ci): matrix 改纯 include 模式,修复任务被折叠成 2 个
GitHub Actions 的 include 规则会把能匹配现有组合的条目合并进组合本身,
image 顶层数组 + 部分字段 include 的写法导致 6 个任务折叠成 2 个
(且都用了最后一条的 armv7)。改为每条 include 自带全部字段
2026-08-29 20:42:16 +08:00
nomdn cfabb06f73 fix(ci): merge 阶段按文件名收集 digest,不再依赖 artifact 目录结构
download-artifact@v4 pattern 下载的实际布局与脚本假设不符,glob 未命中
导致 @sha256:* 字面量传入 imagetools。改为 digest 文件名自带
<镜像>-<平台>-<hex> 元数据 + merge-multiple 平铺下载,脚本按前缀收集,
缺失时列出目录内容并显式报错
2026-08-29 20:36:50 +08:00
nomdn 372cc62473 fix(ci): docker digest 推送去掉 tags 参数
新版 buildx 禁止 tagged ref 按 digest 推送(can't push tagged ref by digest),
push-by-digest 模式改为 outputs name= 传仓库名,最终 tag 由 merge 阶段
imagetools create 统一打
2026-08-29 20:24:20 +08:00
nomdn e9b3a75f02 feat: v3.3.0 — OTA 优雅交接与中间件自更新、多架构镜像、全项目审计修复
OTA 自更新:
- Windows 改优雅交接:预检新二进制 → 原子替换 → 优雅停机 → 拉起新进程 → 健康检查确认 → 老进程退出,失败自动回滚 .old,不再依赖端口抢占竞态
- 下载增加 Release 资产尺寸精确比对;库缺失慢启动按子进程存活判定(10min 宽限)
- OTA 移植至中间件(middleware-go/ota.go,ota/OTA + gh-proxy 配置)

多架构镜像:
- 新增 docker.yml:amd64 原生 + GitHub 原生 ARM 实例构建 arm64/armv7(无 QEMU),打 v* 标签推送 GHCR(DockerHub 预留注释暂未启用)
- 两个 Dockerfile 支持 buildx 交叉编译(TARGETVARIANT → GOARM 映射),修复 middleware-go Dockerfile 漏拷 ws.go 导致镜像无法构建

审计修复(安全/崩溃/数据正确性):
- SSRF:私网黑名单补齐 CGNAT/ULA/NAT64/组播段;重定向校验检查即将跳转的目标并限 10 跳;边缘函数 tcping 补私网拦截;探针响应体 10MB 上限
- 修复 url.Parse 空指针 ×4、启动解析器初始化崩溃、IP2LOCATION .ZIP 大小写永久缺库、残缺下载覆盖现有库、WHOIS 单次 Read 截断、Bilibili 查询无超时
- 修复中间件 WS 写失败干等 30s、僵死节点永久占位;前端死 catch、路径穿越、toFixed 空指针、首页 IPv4/IPv6 优先判断
- 新增 TRUSTED_PROXIES(后端/中间件)与缓存定期清扫;CORS trim/端口归一化等 20+ 项边缘修复

部署:
- install.sh:curl -f、systemd unit 引号与 env 转义、重跑安装 restart 生效
- CI go-version 对齐 1.26.6
2026-08-29 20:17:31 +08:00
nomdn 4ce9637918 feat(install.sh): 节点 id 支持自定义(默认生成 UUID)+ 新增 OTA 配置项
- WS 节点 id:改为交互输入,直接回车才自动生成 UUID(原强制生成)
- 新增 OTA 自更新询问项(y/N),启用后注入 NODE_OTA=true 到 systemd 环境变量
- 汇总展示增加 OTA 行;ipdb 下载体积提示 200MB→450MB
2026-08-28 22:39:01 +08:00
nomdn 56c08f3629 feat(ota): major 版本变化不自动更新
- 新增 majorSame():latest 与 current 的 major 段不同(如 3.x→4.x)时跳过更新,
  记 Warn 日志提示人工升级(minor/patch 仍自动跟进)
- 任一段无法解析(本地构建 VERSION 为空)放行,不影响首次部署升级
2026-08-28 22:30:03 +08:00
nomdn 949f71ca16 refactor(ota): 下载临时文件使用远端资产名
- 临时文件从 <exe>.ota.tmp 改为 <同目录>/<远端资产名>.tmp(如 lemonipw-linux-amd64.tmp),
  替换时再 rename 到目标路径,最终二进制名不变(服务引用不受影响)
2026-08-28 22:20:50 +08:00
nomdn c0a8f7e55c feat(node): 新增 OTA 自更新(node-ota / NODE_OTA 开关)
- 新增 src/ota.go:定期检查 GitHub Release(启动 5 分钟后首次,之后 6h+jitter),
  按 GOOS/GOARCH 匹配资产,下载后替换当前二进制并重启
- 替换策略:当前二进制改名备份为 <程序名>.old,新二进制就位,失败自动回滚;
  下载文件过小(<1MB)判定为异常丢弃
- 重启:Unix 用 syscall.Exec 原地换镜像(PID 不变),Windows 起新进程后退出
- 版本比较支持 v 前缀与预发布后缀;远端为空/解析失败则不更新
- 默认关闭,需 node-ota:'true' 或 NODE_OTA=true;远端配置可统一开关
- 文档:config.md 配置项 + deploy-node.md「OTA 自更新」章节
2026-08-28 22:04:37 +08:00
nomdn fa9b83c951 docs(deploy-node): 修正 IP 数据库下载体积 200MB→450MB 2026-08-28 19:57:24 +08:00
nomdn c81f92bbd4 docs: DNS 自动探测兜底与数据库体积说明更新
- config.md / deploy-node.md / getting-started.md:dns-server/dnssec-server
  留空时启动自动探测系统 DNS 的说明
- 顺带修正 IP 数据库下载体积 200MB→450MB(含 IP2Location DB11+ASN)
2026-08-28 19:57:01 +08:00
nomdn a411d0bb72 feat(node): DNS/DNSSEC 服务器留空时自动探测系统 DNS 兜底
- readConfig:DNS_SERVER / DNSSEC_DNS_SERVER 未配置时,
  用 sysresolv.NewSystemResolvers 读取系统 DNS,
  经 AddrPortsToCSV 转 'IP:Port' 逗号分隔串后回填
- webtest/helpers.go 新增 AddrPortsToCSV([]netip.AddrPort) string
- go.mod:go 1.26.2→1.26.6,引入 AdguardTeam/golibs(间接依赖)
- main.go 全局变量区 gofmt 对齐
2026-08-28 19:55:36 +08:00
nomdn a93e74a8c7 refactor(ipv6): ipv6.vue 只保留跳转 /location,其余删除
- 页面简化为 onMounted 携带 ?ip= 跳转到 /location(归属地查询页)
- 删除原完整模板/脚本/样式(399 行 → 22 行)
2026-08-28 14:24:07 +08:00
nomdn 4cf136b367 feat(location): IP2Location 行右侧列显示 ASN 组织信息
- location.vue / ipv6.vue 的 IP2Location 行第三列显示 ip2location_asn.as(组织名),
  与 Maxmind(org) / DB-IP(org) 行结构对齐
- 补 IPLocationType 的 ip2location / ip2location_asn 字段
2026-08-28 14:22:11 +08:00
nomdn b350413902 style(location): IP2Location 行去掉邮编与时区展示 2026-08-28 14:16:52 +08:00
nomdn 08877871af feat(ipdb): 装载 IP2Location LITE ASN 数据库
- 新增 ip2location_asn 数据源:IP2LOCATION-LITE-ASN.IPV6.BIN(277MB,v4/v6 全覆盖)
- /v1/asn 与 WS asn 查询加入该源,输出 { asn, as },LITE 占位串过滤为空
- PullDatabase 自动下载(ghproxy 前缀)+ zip 解压复用
- 前端 asn.vue 展示 IP2Location ASN 行,描述更新为多数据源
2026-08-28 14:07:08 +08:00
nomdn d942ff0c5c feat(ipdb): 装载 IP2Location LITE DB11 数据库
- 新增 ip2location 数据源:官方 ip2location-go/v9 读取 .BIN(IPV6 双栈)
- PullDatabase 自动下载(ghproxy 前缀的 nomdn/ip2location release ZIP)+ 新增 zip 解压支持
- SearchIP 输出 {country, country_code, administrative_area, city, zipcode,
  timezone, latitude, longitude, asn, isp...};LITE 版不可用字段(isp/asn 等)过滤为空
- 前端 location/ipv6 页面与 /ip 路由展示 IP2Location 行
- .gitignore 忽略 *.BIN(229MB 数据不入库,靠自动下载)
2026-08-28 14:01:35 +08:00
nomdn 5aaf30dc16 fix(ipdb): 修正 ip2region 管道串解析错位
实测 v4/v6 xdb 返回格式为 国家|省份|城市|ISP(或0)|国家代码,
原实现按 国家|区域|省份|城市|ISP 切分导致省/市/ISP/国家代码全部错位。
- administrative_area/city 改取第 1/2 段
- isp 取第 3 段并过滤 "0"(v4 xdb 无运营商数据)
- 兼容老 4 段格式
2026-08-28 11:09:07 +08:00
nomdn 789968069b feat(ipv6webcheck): 增加网站不支持 IPv4 的检测结论分支
- 反向场景:v4 不可达但 v6 可达时提示『网站不支持 IPv4 访问』
- 配《IPv6发展报告》2026 文案与 jingya 图
2026-08-28 11:03:05 +08:00
nomdn 87fd2fd4df fix: 移除重复的节点配置并调整节点顺序 2026-08-27 13:39:21 +08:00
nomdn 57b5824370 Merge pull request #27 from nomdn/dependabot/go_modules/serverless/edgeone/cloud-functions/golang.org/x/net-0.55.0
chore(deps): bump golang.org/x/net from 0.54.0 to 0.55.0 in /serverless/edgeone/cloud-functions
2026-08-27 10:37:57 +08:00
nomdn 52796aa089 Merge pull request #29 from nomdn/dependabot/npm_and_yarn/serverless/lemon-getip/multi-d2d58c24ab
chore(deps): bump undici, @cloudflare/vitest-pool-workers and wrangler in /serverless/lemon-getip
2026-08-27 10:37:36 +08:00
dependabot[bot] 62b9f686e9 chore(deps): bump undici, @cloudflare/vitest-pool-workers and wrangler
Bumps [undici](https://github.com/nodejs/undici) to 7.29.0 and updates ancestor dependencies [undici](https://github.com/nodejs/undici), [@cloudflare/vitest-pool-workers](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/vitest-pool-workers) and [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler). These dependencies need to be updated together.


Updates `undici` from 7.28.0 to 7.29.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.28.0...v7.29.0)

Updates `@cloudflare/vitest-pool-workers` from 0.18.8 to 0.22.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Changelog](https://github.com/cloudflare/workers-sdk/blob/@cloudflare/vitest-pool-workers@0.22.0/packages/vitest-pool-workers/CHANGELOG.md)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/@cloudflare/vitest-pool-workers@0.22.0/packages/vitest-pool-workers)

Updates `wrangler` from 4.114.0 to 4.124.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.124.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: "@cloudflare/vitest-pool-workers"
  dependency-version: 0.22.0
  dependency-type: direct:development
- dependency-name: undici
  dependency-version: 7.29.0
  dependency-type: indirect
- dependency-name: wrangler
  dependency-version: 4.124.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 02:36:57 +00:00
nomdn 7e5bc92aea Merge pull request #30 from nomdn/dependabot/npm_and_yarn/serverless/lemon-getip/multi-1160cd7046
chore(deps): bump sharp, @cloudflare/vitest-pool-workers and wrangler in /serverless/lemon-getip
2026-08-27 10:34:41 +08:00
nomdn ee28aa2cd6 Merge pull request #28 from nomdn/dependabot/go_modules/serverless/edgeone/cloud-functions/github.com/quic-go/quic-go-0.59.1
chore(deps): bump github.com/quic-go/quic-go from 0.59.0 to 0.59.1 in /serverless/edgeone/cloud-functions
2026-08-27 10:34:03 +08:00
dependabot[bot] a95519428b chore(deps): bump sharp, @cloudflare/vitest-pool-workers and wrangler
Bumps [sharp](https://github.com/lovell/sharp) to 0.35.2 and updates ancestor dependencies [sharp](https://github.com/lovell/sharp), [@cloudflare/vitest-pool-workers](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/vitest-pool-workers) and [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler). These dependencies need to be updated together.


Updates `sharp` from 0.34.5 to 0.35.2
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.2)

Updates `@cloudflare/vitest-pool-workers` from 0.18.5 to 0.18.8
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Changelog](https://github.com/cloudflare/workers-sdk/blob/@cloudflare/vitest-pool-workers@0.18.8/packages/vitest-pool-workers/CHANGELOG.md)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/@cloudflare/vitest-pool-workers@0.18.8/packages/vitest-pool-workers)

Updates `wrangler` from 4.111.0 to 4.114.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.114.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.2
  dependency-type: indirect
- dependency-name: "@cloudflare/vitest-pool-workers"
  dependency-version: 0.18.8
  dependency-type: direct:development
- dependency-name: wrangler
  dependency-version: 4.114.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 02:30:56 +00:00
dependabot[bot] aa65f23ac9 chore(deps): bump github.com/quic-go/quic-go
Bumps [github.com/quic-go/quic-go](https://github.com/quic-go/quic-go) from 0.59.0 to 0.59.1.
- [Release notes](https://github.com/quic-go/quic-go/releases)
- [Commits](https://github.com/quic-go/quic-go/compare/v0.59.0...v0.59.1)

---
updated-dependencies:
- dependency-name: github.com/quic-go/quic-go
  dependency-version: 0.59.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 02:30:30 +00:00
dependabot[bot] 0dfbf7cbdf chore(deps): bump golang.org/x/net
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.54.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.54.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 02:30:26 +00:00
nomdn 130ac61df0 fix: 更新 README.md 中的项目结构注释以更准确地描述 Go 后端 2026-08-27 10:28:29 +08:00
nomdn e1a00236ee chore: 项目结构重组(Go 后端迁入 src/,serverless 迁入 serverless/)
- Go 后端(main.go/ws.go/ipdb/webtest/ssrf/go.mod 等)移入 src/
- EdgeOne/lemon-getip 等 serverless 组件移入 serverless/
- 同步更新 README 结构图、docs 路径引用、GitHub Actions 工作流
  (build_and_release 加 working-directory: src;edgeone/workers 触发路径与 cd 更新)
2026-08-27 10:28:29 +08:00
nomdn 71ec93f068 Merge pull request #26 from AoruiKai/patch-1
Update README.md with some ....
2026-08-27 10:05:43 +08:00
AKC c0f0640075 Update README.md with some .... 2026-08-27 01:08:03 +08:00
nomdn 778ed2bd29 refactor: 配置键统一 kebab-case、ip2region 返回 JSON、WS 多活
- 中间件/后端 JSON 配置键统一 kebab-case:APIBaseURL→api-base-url、
  IPLocationAPI→ip-location-api、apiKeys→api-keys、wsKeys→ws-keys;
  环境变量 APIKEYS/WSKEYS 改下划线 API_KEYS/WS_KEYS;
  修正 remote-ingore-config 拼写为 remote-ignore-config(REMOTE_IGNORE_CONFIG)
- ip2region 后端预分割为 JSON(country/administrative_area/city/isp),
  前端 location/ipv6 页面与 /ip 路由不再 split 管道串
- WS_URL 多中间件同时连接全部(多活),任一断开只重连自己,不再主备切换
- 新增前端 /ip 归属地查询路由(index/[ip],text/plain 输出)
- 同步更新 docs/install.sh/setting.json.example
2026-08-26 23:27:23 +08:00
nomdn 2ec182fb08 feat: 更新配置以添加新的中间件和节点信息 2026-08-24 23:20:15 +08:00
nomdn e8ec3757fd fix: 更新文档以支持多个中间件地址同时连接的说明 2026-08-24 22:57:30 +08:00
nomdn d398c4085c Fix link formatting in README.md 2026-08-24 22:37:44 +08:00
nomdn d04a90b20c Update README with community IPW version links
Add community links for IPW versions in README.
2026-08-24 22:37:15 +08:00
nomdn 92a8c1f308 feat:CURL查询IP接口 2026-08-23 22:01:37 +08:00
nomdn d2e3601f20 fix: 修复发布配置文件的复制命令 2026-08-23 08:59:44 +08:00
nomdn 6a4a3821de fix: 修复发布配置文件的复制命令 2026-08-22 22:39:06 +08:00
nomdn 069847e11f fix: 统一配置文件中的 access_token 为 access-token 2026-08-22 22:36:09 +08:00
nomdn 30ba61bc11 refactor(middleware): 拆分 apiKeys/wsKeys 为独立鉴权表
- apiKeys 仅用于 HTTP 转发鉴权(注入 Authorization: Bearer)
- 新增 wsKeys 用于 WS 注册校验(register key 比对,否则 401 断开)
- 两表全局变量(API_KEYS/WS_KEYS)、环境变量(APIKEYS/WSKEYS)相互独立
- 远端配置对 apiKeys/wsKeys 均强制忽略,不随远端覆盖
- 更新 config/deploy 文档与 install.sh 提示,标明两者分开管理
2026-08-22 22:19:45 +08:00
nomdn 94f03ffa85 chore: 删除不再使用的设置文件 setting.json 2026-08-22 22:01:01 +08:00
nomdn 3a3eeab9f9 fix: 修复 ipdb 配置为字符串类型并添加 node-key 2026-08-22 21:57:17 +08:00
nomdn fa5c2329fe refactor: 配置大重构——节点池统一结构(APIBaseURL 三栈 + IPLocationAPI 纯数组)
将前端与中间件的『一功能一配置项』改为统一节点池结构:
- APIBaseURL:合并原 apiBaseUrls(whois/ssl/detail)、NSLookup(dns/dnssec)、
  TCPing、SpeedTest,含 {IPv6,IPv4,DualStack} 三栈;
  whois/ssl/detail 用 DualStack,dns/dnssec/tcping/speed 用三栈平铺
- IPLocationAPI:原 IPLocationAPIs,location/asn,纯数组(无栈区分)
- 节点项统一为 {label, id, url, ws?};前端无 ws 字段
- 中间件 handler/readConfig/applyRemoteConfig 改为按池取数,
  flattenStack 平铺查找节点,tcping/speed 分页取栈(普通页 DualStack+IPv4、
  ipv6 页 DualStack+IPv6)均可在池内命中
- 前端 11 个页面与 server 路由同步改用新池取数
- 文档 config.md / deploy-* 同步;.gitignore 忽略 tmp/(冒烟产物)
- 冒烟验证:whois/ssl/detail/dns/dnssec/location/asn/tcping/udping/speed 分发正确
2026-08-22 21:51:32 +08:00
nomdn 957ac5a4c8 docs: 安装脚本管道执行警告、统一 GitHub alert 语法、README 安装命令更新
- getting-started.md:交互式部署新增 [!WARNING] 警告——勿用
  `curl ... | sudo bash` 管道方式执行(边下载边执行,网络抖动时 bash
  读到不完整内容报 syntax error);curl 参数改为 -fsSL -o
- deploy-node.md:方案五命令块补全获取脚本步骤(git clone / curl -fsSL),
  版本兼容提醒改为 [!WARNING]
- config.md:两处敏感凭据警告去 emoji(块引用改 [!WARNING],列表项去 ⚠️)
- 全站警告块统一为 GitHub alert 语法([!WARNING] / [!IMPORTANT]),无 emoji

- 快速开始命令补全 git clone 步骤(install.sh 需先从仓库获取)
2026-08-22 15:32:29 +08:00
nomdn 63c42627f6 Fix installation command URL in README
Updated installation command in README to use raw.githubusercontent.com.
2026-08-22 15:00:54 +08:00
nomdn 6512a46596 fix: WS 通道转发响应 Content-Type 缺失(text/plain → application/json)
## 问题

中间件经 WS 通道转发拨测结果时,成功路径用 fiber 的 `c.Send(body)`
直接返回节点上报的 JSON 字节,未指定 Content-Type——fiber 默认
`text/plain; charset=utf-8`,即使 body 是合法 JSON。

仅 WS 通道受影响:HTTP 直连通道(forwardUpstream)本就 `.Type("json")`。

## 修复

middleware-go/main.go 两处 WS 转发成功路径(detail/whois/ssl 等 322 行、
tcping/speed 368 行)加 `.Type("json")`:

    return c.Status(status).Type("json").Send(body)

失败路径(502 文本)保持不变。

## 冒烟验证

- 复现:/v1/test/tcping/1.1.1.1?count=1 → Content-Type: text/plain(body 为 JSON)
- 修复后:tcping(368 分支)与 whois(322 分支)均返回
  application/json; charset=utf-8
- go build / go vet 通过
2026-08-22 12:47:35 +08:00
nomdn e261666efc fix:补全SINGLE_STACK 2026-08-22 11:28:59 +08:00
nomdn 0d1a9e5bbd fix:修正一键安装脚本 2026-08-22 11:24:20 +08:00
nomdn 279f663694 fix:测试配置误上传 2026-08-21 22:40:52 +08:00
nomdn 3af1076492 docs:清理README的过时内容 2026-08-21 22:25:37 +08:00
nomdn 10f48f8619 feat: 一键安装脚本(install.sh 交互式部署 + systemd),快速开始只保留交互式部署
## install.sh(新增)

一键安装后端节点:自动检测架构 → 下载最新 release 二进制 → 交互式输入配置
(环境变量注入,无需 setting.json)→ 生成并启动 systemd 守护进程。

- 前置检查:root 权限 / systemd 存在 / wget|curl 可用
- 交互式配置项:安装目录、端口、access_token、DNS(主从逗号分隔)、DNSSEC 专用
  DNS、ipdb、CORS、远端配置地址、WS 通道接入、其他环境变量
- 下载用 GitHub API 解析最新 tag(releases/download/latest 会 404,latest 非真实 tag)
- WS 接入规则:WS_URL 留空默认 wss://middleware-1.api-ipw.wsmdn.top;
  NODE_ID 强制自动生成 UUID(/proc → uuidgen → /dev/urandom 三级降级);
  NODE_KEY 必填(不加 key 禁止启用 WS),安装完成提示加入中间件 apiKeys
- 生成 systemd 服务:配置以 Environment="K=V" 注入(仅非空写入),
  daemon-reload + enable --now 自动启动
- service 描述使用中性名(Lemon IPW Backend Node),不含站点标识

## 文档

- getting-started.md 重写:只保留交互式一键部署(install.sh),删除手动构建、
  配置示例与手写 systemd 章节
- deploy-node.md 新增「方案五:一键安装(install.sh)」:配置项表格、WS 规则、
  systemd 管理命令、版本兼容提醒(v1.1.0 读 IPW_ 旧变量名)
- deploy-middleware.md / deploy-node.md / index.md:systemd 指引改指向方案五

## 其他

- setting.json 清理测试残留值(remote-config-url / ws-url 的 ws://localhost)
2026-08-21 22:17:52 +08:00
nomdn 694ddac6eb refactor: WS 配置去 IPW 前缀、删除 command 指令、注册被拒 30s 重试
## WS 客户端配置改名(去 IPW 前缀)

- 环境变量:IPW_WS_URL → WS_URL、IPW_NODE_ID → NODE_ID、IPW_NODE_KEY → NODE_KEY
- setting.json / 远端配置键:ipw-ws-url → ws-url、ipw-node-id → node-id、ipw-node-key → node-key
- 同步位置:main.go(env 读取 / viper 键 / 远端 configValue + ignored 判断)、
  ws.go(配置注释 / 日志)、setting.json、docs/guide/config.md
- 全局变量名 WS_URL / WS_NODE_ID / WS_NODE_KEY 保持不变

## 删除 WS command 指令(两侧)

- 中间件(middleware-go):删除 handleWSCommand(reload_config 热重载,原本即死代码)、
  onCommand 回调字段、newWSServer 参数、Handler 的 case "command" 分支
- 节点侧:撤销未落地的 wsActive / wsSendCommand / /v1/ws-command 路由
- wsCommand 类型保留(register_error 错误消息仍使用)

## 注册被拒重试间隔 3s → 30s

- wsClientOnce 返回值从 bool 改为 time.Duration(重试等待时长):
  连接失败/断开/心跳失败 → 3s;注册被拒(register_error,多为 key 配置错误)
  → 30s 避免空转刷日志;配置缺失 → 0 停止
- wsClientLoop 直接使用返回时长,多中间件场景各 URL 独立计间隔

## 文档

- deploy-node.md 新增「WS 通道接入(可选)」:WS_URL/NODE_ID/NODE_KEY 三配置
  用法、主备切换、双向心跳、注册 key 校验、NODE_ID 与中间件节点 id 对应关系
- deploy-middleware.md 节点侧接入步骤交叉引用 deploy-node.md WS 章节
- config.md 同步 ws-url/node-id/node-key 新键名(remote-ingore-config 示例、安全说明)

验证:go build/vet 通过;全站文档无 IPW 前缀残留。
2026-08-21 18:22:51 +08:00
nomdn 73ecf67c4e feat: WS 通道、探针函数抽取 webtest、统一 viper 配置、DNS 主从 failover
大幅重构与功能扩展,覆盖后端探针、WS 双通道、配置体系与文档四大块。

## 探针核心抽取 webtest 包(按 API 单文件)

- 从 main.go 抽取站点检测核心到 webtest/,每个 API 一个文件:
  website.go(CheckWebsite) / speed.go(SpeedTest) / ssl.go(CheckSSL) /
  dns.go / dnssec.go / tcping.go / whois.go + types.go / helpers.go / client.go
- 函数签名保持原样 (url, version):HTTP 客户端经 webtest.SetHTTPClient
  (V4Client, V6Client) 注入,main.go 启动时接线,不污染签名
- main.go 只留 handler 薄包装(缓存 / SINGLE_STACK / 双栈编排 / c.JSON 都在
  handler 内),用户注释、包名、函数名、变量名均未改动
- 删除中途的 probeX 编排层 / 自调用方案(均已被"函数+handler 同源"取代)

## WS 双通道(中间件服务端 + 节点客户端)

中间件(middleware-go/ws.go,新增):
- 独立端口 WS 服务端(ws-port,默认 8092,0=关闭,路径 /ws),与 fiber HTTP
  完全分离;节点配置 "ws": true 时拨测请求改走 WS 通道,HTTP 通道保留
- 注册 key 校验:节点在 apiKeys 配了 key 就必须传对,不传/传错返回
  register_error {"code":401,"command":"invalid key"} 并断开;未配置开放
- probe/probe_result 按 requestId 异步关联(并发多拨测),未连接/超时返回 502
- 每 20s ping + status 统计上报;新增处理节点主动心跳 ping → 回 pong

节点客户端(ws.go,新增):
- IPW_WS_URL 支持逗号分隔多中间件(第一个为主,故障自动切换下一个)
- 双向心跳:每 10s 发 ping,发送失败重试,连续失败 3 次判定中间件不可用,
  断开并切换下一个 URL;收到中间件 ping 回 pong;断线 3s 重连
- 收到 probe 直调 webtest 函数 + 共享缓存,probe_result 上报
- 配置:IPW_WS_URL / IPW_NODE_ID / IPW_NODE_KEY(env > setting.json > 远端)

## 配置体系统一 viper + remote-ingore-config

- middleware-go 从自定义 loader 迁移到 viper 统一读取(先 Getenv 再
  viper.GetString,env 优先 > setting.json > 默认值)
- remote-ingore-config(env REMOTE_INGORE_CONFIG):数组中的配置项不被远端
  覆盖,applyRemoteConfig 逐键 !ignored(key) 判断跳过(两端 main.go 均已实现)
- 敏感凭据强制忽略:apiKeys(中间件)与 access_token(后端)不随远端配置覆盖
- JSON 键名规则:标量配置项用连接线(http-timeout-seconds / rate-limit /
  ws-port / remote-config-url / remote-ingore-config),节点键保留驼峰
  (apiBaseUrls / IPLocationAPIs / TCPing / SpeedTest / NSLookup / apiKeys)
- ws-port 与 port 一致用 stringOrNumber(兼容 "8092" 与 8092,"0"=关闭)

## DNS 主从 failover + DNSSEC 专用服务器

- dns-server 支持逗号分隔多地址(第一个为主,主失败自动切换从,超时/网络
  错误/SERVFAIL 均触发切换);每项支持 ip:port(UDP)或 https://(DoH)
- 新增 dnssec-server(env DNSSEC_DNS_SERVER):DNSSEC 查询专用 DNS,
  未配置则沿用 dns-server;dnssec.go 改用 queryDNSSEC
- EO 兼容版本(edgeone/cloud-functions/webtest)同步:主从列表 +
  queryDNSSECMsg(保留带 duration 签名)+ index.go DNSSEC_DNS_SERVER 接线

## 前端

- utils/middleware.ts:RETRY_STATUS_CODES 加入 502——请求中间件收到 502
  自动切换下一个候选(config.Middleware 外部节点 + 内置中间件兜底)
- server/routes/middleware/[...slug].get.ts:内置中间件新增单 IP 限流
  (rateLimitPerMinute,默认 120,0=不限流)+ Origin/Referer 校验
- config/index.ts:新增 rateLimitPerMinute;新增 IP 直连新节点
  (tencent-sh 等 url 留空,仅经独立中间件转发);删除 huawei 节点

## 文档

- 新增 docs/guide/config.md「配置文件」独立页面(后端 setting.json 全字段、
  前端 config/index.ts、内置/独立中间件、远端配置详解)
- getting-started.md 只留最小示例 + systemd,配置详解移入 config.md
- deploy-middleware.md 新增「WS 通道」章节(架构/协议表/接入步骤/注册 key
  校验);deploy-node/deploy-frontend 链接修正到 config.md
- 侧边栏新增「配置文件」入口;outline 显示 h2+h3

冒烟验证:WS 端到端(注册 key 四场景、拨测 200、断线切换中间件)、DNS 主从
failover、dnssec-server 专用、ws-port 三写法均通过;go build/vet 双端通过。
2026-08-20 22:54:08 +08:00
nomdn b4a18e0fbf feat: 文档站新增简介/部署方案页与 favicon,中间件不再透传 Origin
- docs: 新增 info/overview(项目概述)、guide/getting-started(快速入门:构建/配置/systemd)、deploy-node(EO/Vercel/Docker/二进制 + Serverless IPv4-only 说明)、deploy-middleware(Docker/二进制 + example 复制提示)
- docs: 前端部署页重构为 EdgeOne/Vercel/Cloudflare Workers/直接运行(去掉 Docker,去掉 CF 一键按钮);部署总览改三组件表;首页按钮指向快速开始;侧边栏分简介/部署;移植 favicon.svg/ico
- middleware-go / 前端内置中间件:转发不再携带 Origin,避免上游 CORS 误判(保留 Authorization Bearer 注入)
2026-08-19 16:10:39 +08:00
nomdn b36314174c fix: 关闭 nuxt-security 静态页安全头写入 _headers,修复 Cloudflare 部署超长行错误
根因:/doc 改为 SSG 后,nuxt-security 在预渲染时把每页 CSP 的 SRI modulepreload 哈希全量拼入
script-src,单行超过 Cloudflare _headers 2000 字符上限(24 个 doc 路由各一处)。
修复:security.ssg.nitroHeaders=false,静态页不再写 _headers(SSR 页面仍由运行时中间件下发完整安全头)。
已验证本地构建产物 _headers 无超长行。
2026-08-18 23:00:54 +08:00
nomdn a3b7d9375f feat: 文档站初始化、远端配置、DNS 双通道、Release 配置文件附件
- docs: 初始化 VitePress+Teek 文档站(部署指南/前端部署/首页特性),关闭自动侧边栏插件改为手动维护
- main/edgeone: 支持 REMOTE_CONFIG_URL 远端配置拉取(远端 > env > setting.json,access_token 保持原优先级)
- main/edgeone: DNS 层支持 DoH+UDP 双通道自动互备,dns-server 支持 URL 或 ip:port
- edgeone: 补齐 /v1/dnssec 路由,WHOIS 增加 SSRF 公网 IP 过滤
- frontend-ssr: /doc 路由改为 SSG 预渲染;移除未使用的 @nuxt/content 依赖
- release: 工作流附带 setting.json.example 与 middleware.setting.json.example,正文给出重命名指引
- frontend: 响应式折叠菜单、节点重试、siteName/内置中间件开关等既有改动一并提交
- README: 部署文档与配置优先级说明更新
2026-08-18 22:45:12 +08:00
nomdn 21989b06ce fix:修复节点重试和middleware重试竞态,修复ACTION部署覆盖ENV 2026-08-14 20:55:56 +08:00
nomdn c4d796868a feat: 独立转发中间件支持Docker部署,环境变量,CORS 2026-08-14 15:13:27 +08:00
nomdn 5e4d7a6a18 fix: 更新模块路径为正确的 GitHub 地址 2026-08-14 12:08:20 +08:00
nomdn e5357b8bab fix: 注释掉本地中间件地址以避免冲突 2026-08-13 22:04:00 +08:00
nomdn fd17ff4b5b feat: 新增middleware-go中间件、外部中间件候选重试与响应式折叠菜单
- middleware-go: Go(Fiber v3) 实现的独立转发中间件,配置化(setting.json)、
  版本控制系统(ldflags 注入 VERSION/COMMIT/BUILD_TIME)
- CI: Release 工作流集成 middleware 多平台构建(16 个二进制)并注入版本信息
- 前端: useMiddlewareFetch 外部中间件候选重试(连接失败/401),12 个页面接入,
  修复 statusCode 弃用警告改用 status
- app.vue: el-menu 样式统一(menu-item-text),视口映射表驱动溢出折叠进
  「更多」,暗色切换不折叠,窄屏媒体查询兜底
2026-08-13 22:00:35 +08:00
nomdn 4882c09f73 Merge pull request #21 from KFCV50TK/fix/zstd-decoder-pool
fix: prevent active zstd decoder reuse
2026-08-13 00:50:15 +08:00
nomdn 9c5872910f Delete zstd_test.go 2026-08-12 23:40:40 +08:00
nomdn e2d8968c6e Delete edgeone/cloud-functions/zstd_test.go 2026-08-12 23:37:07 +08:00
wsmdn-next 7daca97785 fix: 优化参数类型定义,确保后端ID和API类型的正确性 2026-08-12 23:33:41 +08:00
nomdn 554890a502 Merge pull request #23 from Linming-XHL/main
feat. 优化生成截图时的提示
2026-08-11 13:08:47 +08:00
nomdn 9240558800 feat: 添加NITRO_PRESET环境变量以支持Cloudflare Workers部署 2026-08-11 12:33:53 +08:00
nomdn 07dacb2ac7 fix: 移除不必要的cloudflare-module预设 2026-08-11 12:29:08 +08:00
Linming-XHL 918cf272a5 feat. 优化生成截图时的提示 2026-08-11 12:23:42 +08:00
KFCV50TK 718e4d9136 fix: keep zstd decoders checked out until close 2026-08-11 03:26:06 +08:00
nomdn a2e39dd3a8 fix:Umami两次请求bug,移动端不协调样式修改 2026-08-10 19:51:39 +08:00
nomdn 8edb679685 feat: 增强错误处理和调试信息;更新多个页面以支持可选错误属性 2026-08-10 18:21:01 +08:00
nomdn 27b53a2ac9 feat: 更新样式和文档链接;优化IPv6提示逻辑;清理API密钥配置 2026-08-09 22:44:32 +08:00
nomdn e64738da64 Merge remote-tracking branch 'origin/main' 2026-08-09 15:35:45 +08:00
nomdn cf76d50fa8 feat: 重构路由中间件、新增 ASN/DNSSEC/SSRF、移除 UDPing
- 删除 UDPing 功能(webtest/udping.go、edgeone/udping.go、/udping API)
- 合并 8 个独立中间件为统一 [...slug].get.ts,支持按 backendID + apiType 分发
- 新增 ASN 自治系统查询(/v1/asn/:ip)及前端页面 asn.vue
- 新增 DNSSEC 验证(/v1/dnssec/:domain)及前端页面 dnssec.vue
- 新增 SSRF 防护模块(ssrf/ssrf.go),block-private-ips 配置项
- 新增 API access_token 访问令牌鉴权,中间件支持 Bearer 认证
- 新增 -v/--version 版本信息输出,CI 构建时通过 ldflags 注入 VERSION/COMMIT/BUILD_TIME
- 更新 frontend-ssr 节点配置:移除 UDPing 节点,新增 us-la 节点
- 更新 whois.vue 使用统一中间件路由,移除 RDAP 限制说明
- 更新 README 文档及 CI 构建流程
2026-08-09 15:35:32 +08:00
wsmdn-next e2359a6320 feat: 新增DNSSEC前端中间件路由 2026-08-08 01:26:10 +08:00
nomdn 2c67b55001 fix: 更新.gitignore以包含setting.json;修复配置文件中的DNS服务器地址 2026-08-07 23:01:18 +08:00
nomdn 723b025586 feat: 新增UDPing/Whois/DNSSEC功能;优化前端UI与路由;扩展缓存与API路由 2026-08-07 22:53:33 +08:00
nomdn 640c8b6a38 Merge pull request #20 from Linming-XHL/main
fix. 修复某些图标的字体兼容性
2026-08-06 09:20:16 +08:00
Linming-XHL 06f6808b7a fix. 修复某些图标的字体兼容性 2026-08-06 09:17:33 +08:00
nomdn de0ce85ef4 Merge pull request #19 from nomdn/dependabot/npm_and_yarn/lemon-getip/postcss-8.5.25
chore(deps): bump postcss from 8.5.19 to 8.5.25 in /lemon-getip
2026-08-05 09:04:45 +08:00
nomdn cc122f253f Add Contributor Covenant Code of Conduct
Added Contributor Covenant Code of Conduct to outline community standards and enforcement guidelines.
2026-08-05 09:03:43 +08:00
dependabot[bot] 7be4517086 chore(deps): bump postcss from 8.5.19 to 8.5.25 in /lemon-getip
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.19 to 8.5.25.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.19...8.5.25)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.25
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 00:50:02 +00:00
nomdn fd657feb22 Merge pull request #18 from nomdn/dependabot/npm_and_yarn/lemon-getip/hono-4.12.34
chore(deps): bump hono from 4.12.27 to 4.12.34 in /lemon-getip
2026-08-05 08:48:48 +08:00
dependabot[bot] 23939960f1 chore(deps): bump hono from 4.12.27 to 4.12.34 in /lemon-getip
Bumps [hono](https://github.com/honojs/hono) from 4.12.27 to 4.12.34.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.27...v4.12.34)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.34
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 15:26:44 +00:00
wsmdn-next 0246f8db44 sync: CORS实现与main.go保持一致 2026-08-03 14:07:11 +08:00
wsmdn-next b4eadc1e36 fix:修复CORS 2026-08-03 13:32:28 +08:00
nomdn b3b510b776 feat: CORS配置; tcping IP直连跳过DNS+SSRF防护; initHTTPClient IP直连跳过DNS 2026-08-02 22:21:58 +08:00
wsmdn-next 688813f5b1 fix:修复Dockerfile构建 修复DBIP ASN修复GEOCN运营商dingw 2026-08-02 14:41:38 +08:00
wsmdn-next 83a8d46410 fix:修复Dockerfile构建 修复DBIP ASN修复GEOCN运营商dingw 2026-08-02 14:41:38 +08:00
nomdn 4c0de76668 Add new IPLocationAPI for Jiangsu region 2026-08-02 14:17:22 +08:00
wsmdn-next a869787e7b fix:edgeone兼容版本在其他平台无法正常运行 2026-07-31 17:26:43 +08:00
wsmdn-next 8ede1fd8c9 fix: DNS timing measurement and TCP/HTTP connection precision
- Add measureDNSTime() to accurately measure DNS lookup time using
  webtest resolver (same resolver as DialContext for SSRF protection)
- Fallback to measureDNSTime() when trace.DNSLookup is zero (custom
  DialContext bypasses httptrace DNS events)
- Change timing conversion from .Milliseconds() to .Seconds() * 1000
  to preserve sub-millisecond precision
- Set DisableKeepAlives: true on http.Transport to force new
  connections per request, ensuring accurate TCP/HTTP timing
2026-07-30 22:29:29 +08:00
wsmdn-next 3951d281fc fix:修复单等号错误 2026-07-30 16:11:55 +08:00
wsmdn-next 1e4931d243 fix:避免TMP进入git 2026-07-30 15:34:26 +08:00
wsmdn-next 95df26db9a fix:修复文档中过时的描述,完善文档,优化edgeone serverless在其他无服务器平台的兼容性 2026-07-30 15:32:08 +08:00
wsmdn-next 9a3f8d3592 feat: 更新前端配置及页面节点信息 2026-07-30 10:22:26 +08:00
wsmdn-next 29f27c652e fix: 修复 V4Client/checkSSL 获取 IPv6 而非 IPv4 的问题,统一使用 ResolveARecord/ResolveAAAARecord;edgeone 版补上 initHttpClient 调用,ResolveIP 增加 IP 地址直接返回逻辑 2026-07-29 11:11:06 +08:00
wsmdn-next 4ecf067deb feat: 添加响应式样式和优化IP信息展示 2026-07-25 15:05:07 +08:00
wsmdn-next e5fbffff0e feat: narrow-screen responsive styles, SSRF log cleanup, setting.json updates 2026-07-24 22:11:50 +08:00
wsmdn-next 8079529722 refactor: extract SSRF protection to ssrf package, add bilingual comments 2026-07-24 03:16:45 +08:00
wsmdn-next 09188708ee fix: add URL scheme validation to suppress CodeQL SSRF alert
Only allow http/https schemes in validateOutboundTarget.
Blocks javascript:, file:, gopher:, etc. schemes.
2026-07-24 02:14:37 +08:00
wsmdn-next ebcfa960f2 ci:修改Release名 2026-07-24 01:55:22 +08:00
nomdn 2324972606 Merge pull request #14 from nomdn/dependabot/go_modules/edgeone/cloud-functions/golang.org/x/net-0.55.0
chore(deps): bump golang.org/x/net from 0.51.0 to 0.55.0 in /edgeone/cloud-functions
2026-07-24 01:49:32 +08:00
nomdn f8cba3db48 Merge branch 'main' into dependabot/go_modules/edgeone/cloud-functions/golang.org/x/net-0.55.0 2026-07-24 01:49:15 +08:00
nomdn e7e266a1d1 Merge pull request #15 from nomdn/dependabot/go_modules/edgeone/cloud-functions/golang.org/x/crypto-0.52.0
chore(deps): bump golang.org/x/crypto from 0.48.0 to 0.52.0 in /edgeone/cloud-functions
2026-07-24 01:48:22 +08:00
nomdn cf48dff1c4 Merge pull request #16 from nomdn/dependabot/go_modules/github.com/quic-go/quic-go-0.59.1
chore(deps): bump github.com/quic-go/quic-go from 0.59.0 to 0.59.1
2026-07-24 01:47:59 +08:00
nomdn 70847cbbfd Update CodeQL workflow to remove manual build steps
Removed manual build steps for JavaScript/TypeScript and adjusted build mode.
2026-07-24 01:42:47 +08:00
nomdn 28dedd4e41 Update codeql.yml 2026-07-24 01:39:32 +08:00
nomdn d064b336d3 Update codeql.yml 2026-07-24 01:36:13 +08:00
dependabot[bot] 1b200eafc8 chore(deps): bump github.com/quic-go/quic-go from 0.59.0 to 0.59.1
Bumps [github.com/quic-go/quic-go](https://github.com/quic-go/quic-go) from 0.59.0 to 0.59.1.
- [Release notes](https://github.com/quic-go/quic-go/releases)
- [Commits](https://github.com/quic-go/quic-go/compare/v0.59.0...v0.59.1)

---
updated-dependencies:
- dependency-name: github.com/quic-go/quic-go
  dependency-version: 0.59.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-23 17:35:03 +00:00
dependabot[bot] 8fcd57e0af chore(deps): bump golang.org/x/crypto in /edgeone/cloud-functions
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.48.0 to 0.52.0.
- [Commits](https://github.com/golang/crypto/compare/v0.48.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-23 17:34:58 +00:00
dependabot[bot] 6b3a80a127 chore(deps): bump golang.org/x/net in /edgeone/cloud-functions
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.51.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.51.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-23 17:34:57 +00:00
nomdn 71eb98ad20 Update codeql.yml 2026-07-24 01:34:08 +08:00
nomdn 36da411d27 Create codeql.yml 2026-07-24 01:18:37 +08:00
nomdn 7a9bdc7505 Merge pull request #13 from nomdn/dependabot/npm_and_yarn/lemon-getip/hono-4.12.27
chore(deps): bump hono from 4.12.25 to 4.12.27 in /lemon-getip
2026-07-24 00:33:25 +08:00
wsmdn-next 2f76f9b546 ci:改用个人PAT 2026-07-24 00:20:07 +08:00
wsmdn-next 87eebcb214 fix: set generate_release_notes to false to avoid 403 permission error 2026-07-23 23:22:18 +08:00
wsmdn-next a63795cdb4 fix: remove trailing comma in package.json after pnpm field removal 2026-07-23 23:04:59 +08:00
dependabot[bot] 0d0c1a6c2b chore(deps): bump hono from 4.12.25 to 4.12.27 in /lemon-getip
Bumps [hono](https://github.com/honojs/hono) from 4.12.25 to 4.12.27.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.25...v4.12.27)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.27
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-23 15:04:46 +00:00
wsmdn-next 264e6f1407 fix: remove deprecated pnpm.onlyBuiltDependencies field
pnpm 10 no longer supports this field. better-sqlite3 has prebuilt
binaries for common platforms, so no special handling needed.
2026-07-23 23:02:11 +08:00
wsmdn-next 26c65c32bf fix: remove darwin/386 from build matrix (unsupported in Go 1.17+) 2026-07-23 22:54:01 +08:00
wsmdn-next 4a9eb1d89b fix: rename contextKey to ssrfContextKey to avoid EdgeOne build conflict
EdgeOne's generated middleware file (edgeone_pages_middleware.go)
also declares 'contextKey', causing a redeclaration error during
the EdgeOne CI build. Renamed to ssrfContextKey/ssrfValidatedIPsKey.
2026-07-23 22:53:20 +08:00
wsmdn-next ac8ceb5684 feat: SSRF DNS rebinding fix, frontend dedup tools/CSS, code cleanup
- SSRF: eliminate DNS rebinding window via context-bound IP cache
- checkSSL: use validated IP from context, not hostname re-resolution
- Frontend: extract shared tools to utils/tools.ts (extractHost, formatTime, etc.)
- Frontend: extract common CSS to assets/css/tool-common.css
- Remove dead code (testWebTools, testWebsite, multicast)
- Fix Bilibili URL injection, parseURL error handling
- Atomic rename for database hot-update race fix
- Add fake result layer for local/private IPs
2026-07-23 22:45:57 +08:00
nomdn 69f20a1149 fix:Update content-type header and add comment for clarity
Change content-type header from 'application/json' to 'text/plain' and add a comment for clarity.
2026-07-16 23:39:13 +08:00
nomdn f1769a1ee9 Merge pull request #10 from nomdn/dependabot/npm_and_yarn/lemon-getip/multi-951a3dbb3a
chore(deps): bump vite and vitest in /lemon-getip
2026-07-16 20:13:06 +08:00
nomdn 9de93bf5c0 Merge pull request #12 from nomdn/dependabot/npm_and_yarn/lemon-getip/postcss-8.5.19
chore(deps): bump postcss from 8.5.9 to 8.5.19 in /lemon-getip
2026-07-16 20:12:39 +08:00
dependabot[bot] e374cf9546 chore(deps): bump postcss from 8.5.9 to 8.5.19 in /lemon-getip
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.9 to 8.5.19.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.9...8.5.19)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.19
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 12:04:53 +00:00
dependabot[bot] 5165bfb458 chore(deps): bump vite and vitest in /lemon-getip
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) to 8.1.5 and updates ancestor dependency [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.


Updates `vite` from 7.3.2 to 8.1.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.5/packages/vite)

Updates `vitest` from 3.2.4 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.1.5
  dependency-type: indirect
- dependency-name: vitest
  dependency-version: 4.1.10
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 12:04:38 +00:00
nomdn 52adf9f5fb Merge pull request #6 from nomdn/dependabot/npm_and_yarn/lemon-getip/hono-4.12.25
chore(deps): bump hono from 4.12.12 to 4.12.25 in /lemon-getip
2026-07-16 20:03:53 +08:00
nomdn 646de80d5e Merge pull request #7 from nomdn/dependabot/npm_and_yarn/lemon-getip/multi-7e33c7b665
chore(deps): bump undici, @cloudflare/vitest-pool-workers and wrangler in /lemon-getip
2026-07-16 20:03:13 +08:00
dependabot[bot] 9daaf157ed chore(deps): bump undici, @cloudflare/vitest-pool-workers and wrangler
Bumps [undici](https://github.com/nodejs/undici) to 7.28.0 and updates ancestor dependencies [undici](https://github.com/nodejs/undici), [@cloudflare/vitest-pool-workers](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/vitest-pool-workers) and [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler). These dependencies need to be updated together.


Updates `undici` from 7.18.2 to 7.28.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.18.2...v7.28.0)

Updates `@cloudflare/vitest-pool-workers` from 0.12.21 to 0.18.5
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Changelog](https://github.com/cloudflare/workers-sdk/blob/main/packages/vitest-pool-workers/CHANGELOG.md)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/@cloudflare/vitest-pool-workers@0.18.5/packages/vitest-pool-workers)

Updates `wrangler` from 4.81.1 to 4.111.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.111.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.28.0
  dependency-type: indirect
- dependency-name: "@cloudflare/vitest-pool-workers"
  dependency-version: 0.18.5
  dependency-type: direct:development
- dependency-name: wrangler
  dependency-version: 4.111.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 07:10:46 +00:00
dependabot[bot] bfe180a25f chore(deps): bump hono from 4.12.12 to 4.12.25 in /lemon-getip
Bumps [hono](https://github.com/honojs/hono) from 4.12.12 to 4.12.25.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.12...v4.12.25)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.25
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 07:10:34 +00:00
nomdn c782fcff96 feat: 更新EdgeOne边缘后端描述,移除旧的SPA前端信息 2026-07-15 12:10:18 +08:00
nomdn 15741403da feat(BREAK CHANGE):删除对Vite Vue SPA的支持 2026-07-15 12:03:30 +08:00
nomdn d49ebfcd6f feat: 设置Nitro预设为cloudflare-module 2026-07-14 11:17:16 +08:00
wsmdn-next a50506b435 Merge branch 'main' of github.com:nomdn/ipw-cn 2026-07-14 02:20:52 +08:00
wsmdn-next e8b5dd4ffe feat: 添加IPv6/IPv4地址查询工具及相关页面更新 2026-07-14 02:19:28 +08:00
nomdn d4c7e51dd5 feat: 更新Node.js版本至24并添加pnpm设置步骤 2026-07-13 16:54:43 +08:00
nomdn 6253567d05 feat: 添加workflow_dispatch触发器到EdgeOne构建与部署工作流 2026-07-13 16:50:34 +08:00
nomdn ae5093da92 feat: 添加EdgeOne和Cloudflare Workers的构建与部署工作流 2026-07-13 16:48:33 +08:00
nomdn cce2c3adcb feat: 添加Dockerfile和EdgeOne请求处理功能 2026-07-13 13:18:33 +08:00
wsmdn-next 669c48a0d8 feat: 添加搜索引擎索引控制和更新Sitemap 2026-07-13 02:25:38 +08:00
nomdn d7e378c299 Update issue templates 2026-07-12 00:46:55 +08:00
nomdn 601ed3cf70 feat:edgeone版本增加使用DOH解析功能 2026-07-11 13:02:58 +08:00
wsmdn-next bc20834577 fix:修复在cloudflare workers环境下的运行问题以及文档对github 锚点适配器的适配问题 2026-07-11 02:13:54 +08:00
柠檬小助手 57d694a4b7 Update nuxt.config.ts 2026-07-10 23:31:44 +08:00
柠檬小助手 4cc3231a40 Configure esbuild to target ES2022
Add esbuild options to specify ES2022 target for Nitro.
2026-07-10 23:26:59 +08:00
nomdn 7ab5cee6b5 feat(backend): HTTPS请求失败时自动fallback到HTTP, 外部文档链接改为内部链接
- 后端 checkWebsite/websiteSpeed: HTTPS请求失败时自动重试HTTP, fallback后HTTPSSStatusCode设为0
- edgeone版本同步相同fallback逻辑
- ssl.vue/ipv6tcping.vue: 将ipw-docs.wsmdn.top外部文档链接改为/doc/内部链接
- 前端SSR: 文档系统重构、markdown渲染修复、speedtest预加载服务器列表等累计改动
2026-07-10 22:52:48 +08:00
nomdn 56b432843f fix(dns): 确保 DNS 查询结果的 Record 字段不为 nil,尝试修复Content 2026-07-08 16:56:21 +08:00
nomdn 821251a6a8 feat: update configuration and assets for Cloudflare deployment
- Added `umamiHost` and `umamiScriptUrl` to config for better clarity.
- Introduced `cleanChineseCharacters` function in `app.vue` to sanitize input.
- Enhanced footer links with conditional rendering based on config values.
- Updated `.gitignore` to include `.dev.vars*` and `.wrangler`.
- Added `security` module configuration in `nuxt.config.ts` for improved security headers.
- Updated `package.json` scripts for deployment and preview using `wrangler`.
- Added `wrangler.jsonc` for Cloudflare configuration with asset binding.
- Included `备案图标.png` in the public directory.
2026-07-08 15:47:08 +08:00
nomdn 802b9ce5f9 chore: update pnpm-workspace.yaml to allow builds for sharp and workerd 2026-07-06 13:44:40 +08:00
nomdn 6d63f0f86f feat(server): 移除IndexNow中间件
移除frontend-ssr/server/middleware/indexnow.ts文件,
不再提供IndexNow密钥验证功能
2026-06-30 20:47:07 +08:00
nomdn 34c3cebf70 feat(middleware): 添加 indexnow 中间件以处理特定路径请求 2026-06-30 14:57:19 +08:00
nomdn 343f6c5659 fix(nitro): 更新 Nitro 预设为 cloudflare_module 2026-06-30 13:57:45 +08:00
nomdn 730c4f5629 feat(dependencies): 添加 better-sqlite3 依赖并更新 pnpm 配置 2026-06-30 13:48:50 +08:00
nomdn 6dd6464a86 refactor(frontend-ssr): 移除 better-sqlite3 依赖并更新可选依赖配置
移除项目中未使用的 better-sqlite3 依赖,该依赖已不再需要。

同时更新 pnpm-lock.yaml 文件中的多个依赖项,将以下包标记为可选依赖:
- bindings
- buffer
- chownr
- deep-extend
- end-of-stream
- expand-template
- fs-constants
- github-from-package
- ini
- mkdirp-classic
- napi-build-utils
- node-abi
- simple-get
- pump
- readable-stream
- strip-json-comments
- tar-stream (多个版本)
- tunnel-agent

这些更改优化了项目的依赖结构,减少了不必要的包安装。
```
2026-06-30 13:45:49 +08:00
nomdn 5b9eb9dc85 feat(nuxt): 配置 Cloudflare Pages 部署和 D1 数据库集成
- 添加 nitro 配置以支持 Cloudflare Pages 预设
- 集成 content 模块并配置 D1 数据库连接
- 设置 DB 绑定名称与 Cloudflare Dashboard 保持一致
2026-06-30 13:43:56 +08:00
nomdn f1e992db37 feat(dns): 添加用户IP获取和IPv6检测功能
- 集成is-ip库用于IPv6地址检测
- 添加getUserIP函数获取用户IP地址
- 在DNS页面显示用户IP及网络访问类型
- 改进响应式数据更新机制,直接更新对应索引的结果

refactor(dns): 优化DNS查询响应式更新

- 修改promise处理逻辑,直接更新results数组对应索引
- 移除最后的results.value赋值,避免重复更新
- 初始化结果数组保持响应式结构

feat(content): 集成Nuxt内容模块并添加动态内容渲染

- 安装@nuxt/content模块及相关依赖
- 配置运行时环境变量siteUrl
- 在DNS页面添加动态内容渲染组件

style(dns): 调整页面布局和样式优化

- 移除IP地址显示的引号样式
- 优化DNS记录类型的说明文字格式
- 添加内容渲染器的CSS样式

refactor(code-block): 优化代码块显示组件

- 修复代码块的空行问题,添加trim处理
- 将pre标签改为div标签以更好控制样式

feat(pages): 统一页面标题命名规范

- IPv6网站性能测速工具 -> IPv6 网站测速
- IPv6 TCPing连通性测试工具 -> IPv6 Ping 测试
- IPv6网站访问支持检测 -> IPv6网站检测
- SSL证书检测工具 -> SSL证书检查
- IPv4 TCPing连通性测试工具 -> IPv4 TCPing 测试

fix(ssl): 添加文档链接增强用户体验

- 为SSL证书检测页面添加相关文档链接
- 提供网站开启IPv6和Nginx配置的详细说明

style(style): 优化暗色主题和引用样式

- 调整blockquote中链接的颜色和字体大小
- 优化暗色模式下quate类的背景色和文字颜色
2026-06-30 13:27:32 +08:00
nomdn cb60f13c84 feat(app): 添加客户端暗黑模式切换功能
在导航菜单中添加ClientOnly包装器,确保暗黑模式切换
功能在客户端正确渲染,避免SSR环境下的兼容性问题。

fix(dns): 优化DNS查询页面SEO和UI展示

更新页面标题、描述和关键词,添加结构化数据,
修正页面标题显示,改进IP地址显示样式,并添加
DNS记录类型说明文档。

feat(index): 重构首页SEO优化和元数据配置

更新页面标题和描述,添加结构化数据支持,
优化关键词配置,提升搜索引擎可见性。

feat(ipv6): 增强IPv6地址查询页面功能

更新页面SEO配置,添加结构化数据,
改进页面标题,优化页面布局和样式。

feat(speedtest): 优化网站测速页面SEO配置

更新所有测速相关页面的标题、描述和结构化数据,
统一页面命名规范,提升搜索体验。

feat(tcping): 扩展TCPing测试功能

添加IPv6支持检测,获取用户IP地址,
增加页面说明文档,完善IPv6相关测试功能。

feat(webcheck): 改进IPv6网站检测页面

更新页面SEO配置,添加结构化数据,
优化页面标题和说明文档。

feat(ssl): 增强SSL证书检测页面

更新页面SEO配置,添加结构化数据,
优化页面标题和功能说明。

feat(style): 优化全局样式和引用显示

添加quate样式类,改进blockquote样式,
增强页面内容的视觉表现。
2026-06-29 22:28:47 +08:00
nomdn 2208e7afa6 Clean up configuration by removing duplicate entries
Removed duplicate entries for '中国 上海 Anycast/cloudflare' from TCPing and SpeedTest configurations.
2026-06-29 11:49:04 +08:00
nomdn 2536f09256 Update apiBaseUrl to new endpoint 2026-06-28 16:17:25 +08:00
nomdn 6699df9403 feat(app): 添加响应式布局支持并优化SEO配置
- 实现窄屏设备响应式布局切换功能
- 添加CSS类防止水合前布局闪烁问题
- 为图片元素添加尺寸和加载属性优化性能
- 在header中添加canonical链接和Twitter卡片元数据
- 使用computed属性动态生成规范化URL

refactor(app): 重构类型定义和错误处理逻辑

- 将媒体查询类型从any改为MediaQueryList
- 改进事件处理器类型定义使用MediaQueryListEvent
- 修复可选链操作符处理媒体查询移除监听器
- 优化代码高亮模块的动态导入方式

perf(index): 优化并发请求和回退渲染策略

- 使用Promise.allSettled实现并发API请求
- 添加代码块回退渲染机制提高用户体验
- 重构shiki代码高亮为按需动态导入
- 添加错误边界处理代码高亮失败情况

style(css): 统一代码块样式和间距配置

- 更新shiki代码块内边距单位为rem
- 添加代码块容器的基础样式配置
- 为回退代码块添加深色主题样式
- 修复footer移动端适配样式问题

chore(nuxt): 添加客户端主题同步脚本

- 注入浏览器端颜色主题同步逻辑
- 确保首次渲染时正确应用暗黑模式
- 添加本地存储和系统偏好设置同步
2026-06-27 12:11:10 +08:00
nomdn 6a6c75ca63 feat(app): 添加响应式布局支持并更新页脚信息
- 添加移动端媒体查询适配(最大宽度768px)
- 更新页脚包含备案号、GitHub链接和QQ交流群
- 修复CSS中多余的空格问题
- 优化页脚在小屏幕设备上的显示效果
2026-06-26 23:30:19 +08:00
nomdn 6f70bb652a feat(main): 添加TLS配置跳过证书验证
为网站速度测试功能添加TLS客户端配置,设置InsecureSkipVerify为true,
以解决SSL证书验证问题,确保在各种网络环境下都能正常进行网站速度测试
2026-06-26 13:06:20 +08:00
nomdn f7e0a81e77 feat(ssl-check): 添加TLS配置跳过证书验证并修复SSL测试功能
- 在Go后端代码中为resty客户端添加TLSClientConfig配置,
  设置InsecureSkipVerify为true以跳过证书验证
- 修复SSL测试页面域名提取逻辑,使用extractHost函数处理输入域名
- 修正SSL测试结果显示逻辑,确保证书有效性检查时正确显示默认值
- 更新错误处理逻辑,当SSL握手失败时将IsReachable设置为false
- 完善SSL检查处理器中的错误处理,为错误情况设置正确的可达性状态
2026-06-26 12:17:15 +08:00
nomdn cc839d6068 feat(ssl): 移除IPv4/IPv6可达性显示
移除了SSL页面中关于IPv4和IPv6是否可达的状态显示
2026-06-26 11:26:15 +08:00
nomdn b7ea508d61 fix:目标网站证书失效时无法返回检查结果 2026-06-26 11:01:45 +08:00
nomdn 97c22f5b28 feat:江苏节点 2026-06-26 10:04:10 +08:00
nomdn 405e01623f fix(app): 调整公告消息显示时长
将公告消息的持续显示时间从5秒调整为3秒,
以提升用户体验和界面响应性
2026-06-26 09:02:11 +08:00
nomdn 65df3572e2 feat:公告功能 2026-06-25 23:23:18 +08:00
nomdn c35d728c43 feat:杂鱼耀斑占位符 2026-06-25 22:44:29 +08:00
nomdn 347c7e53d3 feat:上海cloudflare节点,IPLocation节点改为沙渠 2026-06-25 22:36:27 +08:00
nomdn 2ba603ae2e fix: 修复多处并发安全与前端显示问题
- 修复 downloadSpeed 除零错误(totalTime 为 0 时跳过计算)
- 修复 IPv6 TCPing 页面错误引用 ipv4 属性的问题
- 为 formatTime 添加 null 防御,避免 undefined 报错
- 为行政区划数据添加读写锁,修复并发安全问题
- 添加端口号校验(1-65535)
- SSL 查询时对输入域名做 extractHost 处理
- IP 查询错误时正确重置 loading 状态
- Umami 统计配置移至 config 统一管理
- 修复 SPA 前端 HTML 结构与 lang 属性
- 完善 README 项目文档
2026-06-21 21:41:43 +08:00
nomdn 05e45373b4 feat:umami 2026-06-20 21:28:57 +08:00
nomdn f004ba7823 fix(dns): 修复模板中nowRecordType默认值语法错误 2026-06-20 17:51:25 +08:00
nomdn b0c2e8c9ce feat(dns): 修复DNS查询结果显示问题
- 添加nowRecordType响应式变量用于存储当前记录类型
- 在查询完成后更新nowRecordType值以确保显示正确的记录类型
- 修复模板中recordType显示逻辑,使用nowRecordType替代原始值
- 优化加载状态显示,区分失败和加载中状态
2026-06-20 17:45:00 +08:00
nomdn 90cc03ee40 fix(speedtest): 去除测速请求URL中的协议头 2026-06-20 16:43:52 +08:00
nomdn c40cff8fb5 fix:修复错误的资源引用 2026-06-20 16:27:22 +08:00
nomdn aa77d5345e fix:删除错误的工作区定义 2026-06-20 16:24:23 +08:00
nomdn 5e47eaec2a fix:删除失效的umami 2026-06-20 16:20:31 +08:00
nomdn bb0ed9056d feat:SSR前端
feat: 添加IPv4 TCPing功能到前端界面

添加了IPv4 TCPing测试页面和路由配置,在导航菜单和侧边栏中增加
了TCPing测试入口,方便用户进行IPv4网络连通性检测。同时修复了
DNS查询结果展示中的时间格式显示问题。
2026-06-20 16:08:55 +08:00
nomdn 15d86711df fix:服务器名匹配错误 2026-06-20 13:25:53 +08:00
nomdn 5a7e333c03 feat(speedtest): 添加IPv4和双栈测速支持并优化域名处理
- 删除协议头,只保留host:port/path格式的域名处理逻辑
- 为WebSpeed.vue和IPv4speedtest.vue添加IPv4测速功能
- 扩展测速配置,支持DualStack和IPv4两种测速类型
- 统一错误处理机制,确保请求失败时返回适当的错误信息
- 更新API端点从config.SpeedTest配置中动态获取服务器信息
2026-06-20 12:54:47 +08:00
nomdn 3a8d065291 feat(config): 添加新加坡腾讯云节点并更新广州节点地理位置
- 在IPv4配置中添加新加坡腾讯云节点,地址为 https://sg-1.api-ipw.wsmdn.top/
- 更新广州节点标签,从"中国 广州 腾讯云"改为"中国 广东 广州 腾讯云"
- 在IPv6配置中同步添加新加坡节点
- 所有地区节点配置中统一添加新加坡节点
2026-06-20 12:31:30 +08:00
nomdn f426f93e68 feat(website-speed-test): 添加SINGLE_STACK配置验证并优化响应结构
- 在websiteSpeedTestHandler中添加对SINGLE_STACK配置的验证逻辑
- 当请求版本与SINGLE_STACK配置不匹配时返回相应的跳过结果
- 提前声明result和err变量以避免重复定义
- 统一代码风格和结构
2026-06-20 11:28:46 +08:00
nomdn d439d6afc2 feat(cache): 实现失败结果的短期缓存机制
- 在checkWebsiteHandler中,当IPv4和IPv6都不可达时,将缓存时间缩短为30秒
- 在sslCheckHandler中,当IPv4和IPv6都不可达时,将缓存时间缩短为30秒
- 在websiteSpeedTestHandler中,错误结果只缓存30秒后自动删除
- 在ping
2026-06-20 10:55:42 +08:00
nomdn 3f21589fa4 feat:edgeone版本用DoH 2026-06-20 09:44:43 +08:00
nomdn fe46c278ad feat(edgeone): 优化DNS查询功能并更新依赖
- 更换稳定公共DNS服务器(119.29.29.29:53),避免运营商DNS在云环境不可达问题
- 重构DNS查询逻辑,提取通用executeQuery函数支持多种记录类型
- 强制使用TCP连接并设置3秒超时,提升查询稳定性
- 精简各记录类型查询函数代码,统一错误处理逻辑
- 添加并发查询所有DNS记录的功能
- 更新项目依赖包版本,移除冗余依赖
2026-06-20 09:16:49 +08:00
nomdn 1f9364804a fix(config): 修复API URL中的双冒号错误
- 修复了apiBaseUrl配置项中的语法错误 "https:://" -> "https://"
- 修复了TCPing配置中多个URL的双冒号问题
- 修复了NSLookup配置中URL的双冒号问题
```
2026-06-19 22:30:21 +08:00
nomdn 140ffa86bb feat(config): 更新后端API地址为生产环境
- 将apiBaseUrl从localhost:8080更新为https:://api-ipw.wsmdn.dpdns.org
- 更新TCPing和NSLookup配置中的测试URL为生产环境地址
- 在.gitignore中添加lemon-ipw忽略规则
2026-06-19 21:47:36 +08:00
nomdn b83bc75757 feat: 添加TCPing、DNS查询和网站测速功能
添加了TCPing测试功能,支持IPv4/IPv6双栈检测
实现DNS记录查询功能,支持A、AAAA、CNAME、MX等常见记录类型
新增网站速度测试功能,提供DNS解析时间、连接时间、下载速度等指标
在前端界面中增加相应路由和页面组件
更新API配置以支持新的服务端点
优化现有IPv6相关页面的文字描述准确性
```
2026-06-19 21:24:21 +08:00
nomdn ba51c6983d feat:umami 2026-06-17 22:59:28 +08:00
nomdn dd7dcf5db9 fix:严肃下线美团接口 2026-06-09 21:35:01 +08:00
nomdn cfbe4b6ff5 feat:后端edgeone版本,支持单栈机器部署 2026-06-07 18:53:41 +08:00
nomdn 95ad209db3 style:修改数据结构
feat:IPLocation页面如果数据库宕机则不显示对应卡片
2026-06-05 22:07:41 +08:00
nomdn e4fc4fb276 feat: 添加提取主机名的功能,优化SSL和Testweb页面的显示逻辑 2026-06-01 23:43:29 +08:00
nomdn 6b1326aaa2 feat: 更新依赖版本,优化IPLocation和Whatsmyip页面的显示逻辑 2026-06-01 21:20:28 +08:00
nomdn c4c9db0987 feat(IPLocation): 更新IPv6归属地精度说明,添加美团VPN溯源接口信息
feat(style): 添加悬停效果的spoiler样式
2026-05-31 09:15:41 +08:00
nomdn da446df7e4 feat(ipdb): 统一IP数据库查询模块,多数据源并行查询与缓存
- 新增 ipdb/searchip.go: 统一查询接口 SearchIP,支持 ip2region/qqwry/maxmind_city/maxmind_asn/geocn/bilibili
- 新增 ipdb/meituan.go: 美团IP查询支持
- 重构 ipdb/ipdb.go: 并发下载、断点重试、gzip解压
- 重构 main.go: 精简为调用 ipdb.Init/SearchIP,Website/SSL检测改为IPv4/IPv6并行
- Website检测缓存5天,SSL检测缓存1天,Bilibili查询缓存30天
- 配置改用 viper,Port 兼容数字/字符串
- 新增依赖: ipdb-go, maxminddb-golang/v2, viper
2026-05-30 22:59:45 +08:00
nomdn aee629c5d8 feat:table暗黑模式
refactor:重写一部分ai史山
2026-04-25 20:14:03 +08:00
nomdn f28c240287 feat: 添加移动端适配和暗色模式支持
- 集成 ElDrawer 组件以支持移动端导航抽屉
- 使用 @vueuse/core 实现响应式断点检测和暗色模式切换
- 添加移动端窄屏检测逻辑,在小屏幕设备上显示抽屉菜单
- 实现主题切换功能,支持亮色/暗色模式
- 更新组件类型声明文件以包含新引入的 ElDrawer 组件
- 优化 CSS 样式以适配暗色模式和移动端布局
2026-04-25 18:27:15 +08:00
nomdn a98657de7a refactor(App): 使用router-link替代手动路由跳转
- 将菜单项中的@click="$router.push()"替换为router-link组件
- 修复首页logo点击区域的样式问题
- 统一菜单项中链接的字体大小样式
- 添加deep样式选择器来正确应用router-link的样式
2026-04-25 14:28:10 +08:00
nomdn 36ac9b6165 fix(App): 修复导航菜单路由跳转问题
- 移除 el-menu 的 router 属性,改用 @click 事件手动处理路由跳转
- 为每个菜单项添加 index 属性以支持导航功能
- 修复外部链接菜单项的索引设置
2026-04-25 14:00:33 +08:00
nomdn 86f0f761c5 feat(App): 更新导航菜单结构并添加新组件类型定义
- 添加 ElDivider 和 ElSubMenu 组件到全局类型定义
- 调整菜单项顺序,将 IPv6 相关功能重新排序
- 添加外部链接:IPv6 Ping测试、IPv6 DNS解析、IPv6 网站测速
- 使用分隔线分组相关功能
- 添加 IPv4 工具箱子菜单
2026-04-25 11:58:30 +08:00
nomdn 3ded290207 feat(IPLocation): 添加URL参数支持IP地址查询
通过useRoute()获取URL参数中的IP地址,并在页面加载时自动执行IP定位查询,
提升用户体验和分享便利性。

feat(SSLtest): 优化SSL证书检测结果展示逻辑

完善IPv4和IPv6证书状态检测条件判断,增加对仅支持IPv4的情况显示,
修复空值判断逻辑并改进错误提示文案。

feat(Testweb): 增强网站测试结果的IPv6访问检测功能

为主机记录添加地理位置跳转链接,优化IPv6支持状态判断逻辑,
修正错误提示信息并改善可达性检测条件。
2026-04-25 11:20:24 +08:00
nomdn 1ddfb06a97 feat(ssltest): 添加网站可达性检查功能并修复参数名
- 新增 is_reachable 字段用于标识网站是否可达
- 将路由参数从 url 改为 site 以保持一致性
- 修改 issuer_organization 类型为 string[] | null 并添加安全调用
- 在结果展示中添加对网站可达性的判断逻辑
- 添加网站不可达情况下的错误提示页面

feat(testweb): 添加网站性能检测中的可达性检查

- 在 PerformanceCheckItem 中新增 is_reachable 字段
- 统一路由参数名称为 site
- 添加当 IPv4 和 IPv6 均不可达时的错误处理页面

feat(api): 扩展后端 API 返回数据结构

- 为 WebsiteCheckDetail 和 SSLCheckDetail 添加 IsReachable 字段
- 在 checkWebsite 和 checkSSL 函数中设置默认 IsReachable 为 true
- 当检查失败时将 IsReachable 设置为 false,SSL 检查失败时同时设置 IsExpired 为 true
2026-04-24 22:53:34 +08:00
nomdn 359c1418d2 feat: 添加IPv6网站检测功能并重构IP查询页面
- 在导航菜单中添加IPv6网站检测入口
- 集成Webcheck页面到路由配置
- 重写IPLocation页面为完整的IPv6地址查询工具
- 添加IP定位、地区显示和网络类型检测功能
- 集成代码高亮显示和用户IPv6连接状态检测
- 更新SSL测试页面的API地址配置
``
2026-04-19 15:37:58 +08:00
nomdn 794a2359dd feat(ipdb): 添加GitHub代理支持并优化数据库下载功能
- 为PullDatabase函数添加ghproxy参数,支持通过代理下载IP数据库
- 修改下载URL构造方式,使用传入的代理地址拼接目标URL
- 添加client.Close()延迟调用以正确释放资源
- 优化错误处理和日志记录

config(main): 实现配置文件和环境变量双重配置机制

- 新增Setting结构体定义端口和GitHub代理配置项
- 实现readConfig函数,支持从环境变量或setting.json文件读取配置
- 配置优先级:环境变量 > 配置文件 > 默认值
- 支持PORTS和GH_PROXY两个关键配置项
- 修改服务器启动逻辑,使用配置的端口号运行服务
2026-04-19 11:12:30 +08:00
nomdn a4042b0b2b Merge branch 'main' of https://github.com/nomdn/ipw.cn 2026-04-19 00:02:34 +08:00
nomdn 067c220784 feat:go后端
feat: 添加 vue-router 支持并重构 App.vue 组件

- 在 components.d.ts 中添加 ElImage、RouterLink 和 RouterView 的类型声明
- 在 package.json 和 package-lock.json 中添加 vue-router 依赖
- 将 App.vue 从单页面应用重构为路由驱动的应用
- 添加菜单项用于导航到不同的功能页面
- 在 main.ts 中集成 vue-router 并配置路由
2026-04-19 00:02:11 +08:00
nomdn 24d6ababa3 Merge pull request #3 from nomdn/cloudflare/workers-autoconfig-3
Add Cloudflare Workers configuration
2026-04-12 21:02:10 +08:00
cloudflare-workers-and-pages[bot] a714e73abc Add Cloudflare Workers configuration 2026-04-12 11:49:56 +00:00
nomdn 03c33db8d2 first commit 2026-04-12 18:41:38 +08:00
nomdn 1ec0c7625f Initial commit 2026-04-12 18:36:45 +08:00
260 changed files with 42149 additions and 7676 deletions

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
---
name: Bug report
about: 提交一个BUG以帮助我们改进
title: BUG
labels: ''
assignees: ''
---
**介绍您的问题**
**复现流程**
复现问题的步骤:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: 对于本项目的一些想法
title: Feature
labels: ''
assignees: ''
---
**你的功能请求是否是为了解决某个痛点?请详细描述。**
请清晰、简明地描述该痛点。例如:我认为[...]的设计并不好,我觉得这样做会更优秀。
**描述你期望的解决方案**
请清晰、简明地描述你期望实现的效果或功能。
**描述你考虑过的替代方案**
请清晰、简明地描述你考虑过的其他替代方案或功能。
**补充信息**
请在此处补充关于该功能请求的其他背景信息或相关截图。
+10
View File
@@ -0,0 +1,10 @@
---
name: 其他问题
about: 请尽可能详细的描述您的问题,或者直接加入项目官方交流群询问
title: ''
labels: ''
assignees: ''
---
+149
View File
@@ -0,0 +1,149 @@
name: Build and Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '1.26.6' # 与 src/go.mod 声明保持一致,避免依赖 GOTOOLCHAIN 隐式下载
- name: Set version metadata
run: |
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
echo "COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV
- name: Build for Linux
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-386
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-armv7
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-armv6
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o ../lemonipw-linux-loong64
- name: Build for Windows
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-386.exe
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-amd64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-windows-arm64.exe
- name: Build for macOS
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-darwin-amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-darwin-arm64
- name: Build for FreeBSD
working-directory: src
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-386
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-amd64
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../lemonipw-freebsd-arm64
- name: Build middleware-go for Linux
working-directory: middleware-go
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-386
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-armv7
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-armv6
CGO_ENABLED=0 GOOS=linux GOARCH=loong64 go build -ldflags "$LDFLAGS" -o ../middleware-go-linux-loong64
- name: Build middleware-go for Windows
working-directory: middleware-go
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-386.exe
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-amd64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-windows-arm64.exe
- name: Build middleware-go for macOS
working-directory: middleware-go
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-darwin-amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-darwin-arm64
- name: Build middleware-go for FreeBSD
working-directory: middleware-go
run: |
LDFLAGS="-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT} -X main.BUILD_TIME=${BUILD_TIME}"
CGO_ENABLED=0 GOOS=freebsd GOARCH=386 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-386
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-amd64
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ../middleware-go-freebsd-arm64
- name: Prepare config files for release
run: |
# 后端:发布 src/ 下的 setting.json.example;中间件:只发布 setting.json.example
cp src/setting.json.example setting.json.example
cp middleware-go/setting.json.example middleware.setting.json.example
- name: Release
uses: softprops/action-gh-release@v3
if: github.ref_type == 'tag'
with:
name: Release ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
token: ${{ secrets.MINT_TOKEN }}
generate_release_notes: true
body: |
## 配置文件说明
Release 附带两份配置文件(**.example** 后缀用于标识,需重命名后使用):
| 发布文件 | 重命名为 | 放置位置 |
|----------|----------|----------|
| `setting.json.example` | `setting.json` | 主程序(后端测试节点)同目录 |
| `middleware.setting.json.example` | `setting.json` | `middleware-go/` 目录 |
```bash
# 后端测试节点
cp setting.json.example setting.json
# 独立中间件
cp middleware.setting.json.example middleware-go/setting.json
```
files: |
lemonipw-linux-386
lemonipw-linux-amd64
lemonipw-linux-arm64
lemonipw-linux-armv7
lemonipw-linux-armv6
lemonipw-linux-loong64
lemonipw-windows-386.exe
lemonipw-windows-amd64.exe
lemonipw-windows-arm64.exe
lemonipw-darwin-amd64
lemonipw-darwin-arm64
lemonipw-freebsd-386
lemonipw-freebsd-amd64
lemonipw-freebsd-arm64
middleware-go-linux-386
middleware-go-linux-amd64
middleware-go-linux-arm64
middleware-go-linux-armv7
middleware-go-linux-armv6
middleware-go-linux-loong64
middleware-go-windows-386.exe
middleware-go-windows-amd64.exe
middleware-go-windows-arm64.exe
middleware-go-darwin-amd64
middleware-go-darwin-arm64
middleware-go-freebsd-386
middleware-go-freebsd-amd64
middleware-go-freebsd-arm64
setting.json.example
middleware.setting.json.example
+94
View File
@@ -0,0 +1,94 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '22 10 * * 4'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
+203
View File
@@ -0,0 +1,203 @@
name: Docker Multi-Arch
# 打 v* 标签发版时构建多架构镜像并推送到 GHCR + Docker Hub。
# ARM 用 GitHub 原生 arm64 实例(ubuntu-24.04-arm,公开仓库免费)交叉编译,全程无 QEMU:
# - ubuntu-latest → linux/amd64(原生)
# - ubuntu-24.04-arm → linux/arm64(原生)、linux/arm/v7(Go 交叉编译,GOARM 由 Dockerfile 处理)
# 各平台按 digest 推到 GHCR,最后由 merge job 用 buildx imagetools 合并成多架构
# manifest list,同时打上 GHCR 与 Docker Hub 的标签。
#
# Docker Hub 推送为可选:需要仓库 Secrets 配置 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN,
# 未配置时只推 GHCR,不影响流程。
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
# 纯 include 模式:每条自带全部字段,独立成一个任务。
# 注意不能把 image 写成顶层数组再用 include 拆——GitHub 会把能匹配
# 现有组合的 include 条目合并进组合本身,导致任务被折叠(见 v3.3.0 事故)
include:
- image: lemon-ipw
context: src
runner: ubuntu-latest
platforms: linux/amd64
platform_id: amd64
- image: lemon-ipw
context: src
runner: ubuntu-24.04-arm
platforms: linux/arm64
platform_id: arm64
- image: lemon-ipw
context: src
runner: ubuntu-24.04-arm
platforms: linux/arm/v7
platform_id: armv7
- image: lemon-ipw-middleware
context: middleware-go
runner: ubuntu-latest
platforms: linux/amd64
platform_id: amd64
- image: lemon-ipw-middleware
context: middleware-go
runner: ubuntu-24.04-arm
platforms: linux/arm64
platform_id: arm64
- image: lemon-ipw-middleware
context: middleware-go
runner: ubuntu-24.04-arm
platforms: linux/arm/v7
platform_id: armv7
steps:
- name: Checkout repository
uses: actions/checkout@v4
# 版本注入 src/Dockerfile(build-args 见下方构建步骤):
# VERSION 取 ref 名——打 v* 标签时与 build_and_release.yml 的二进制产物一致(带 v 前缀),
# 手动触发时为分支名(与 merge 阶段打的 main 标签对应)
- name: Set version metadata
run: |
echo "VERSION=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
echo "COMMIT=${GITHUB_SHA}" >> "$GITHUB_ENV"
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/${{ matrix.image }}
# push-by-digest 模式:不传 tags(新 buildx 禁止 tagged ref 按 digest 推送),
# 仓库名通过 outputs 的 name= 传入,最终 tag 由 merge 阶段统一打
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
platforms: ${{ matrix.platforms }}
# 只有 src/Dockerfile 声明了这三个 ARG;middleware-go 未声明,BuildKit 会静默忽略
build-args: |
VERSION=${{ env.VERSION }}
COMMIT=${{ env.COMMIT }}
BUILD_TIME=${{ env.BUILD_TIME }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=ghcr.io/${{ github.repository }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.platform_id }}
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.platform_id }}
- name: Export digest
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
# 文件名自带镜像与平台元数据:merge 阶段 merge-multiple 平铺下载后
# 按前缀收集,不依赖 artifact 的目录结构
touch "${{ runner.temp }}/digests/${{ matrix.image }}-${{ matrix.platform_id }}-${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.image }}-${{ matrix.platform_id }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# ===== Docker Hub 推送暂时停用 =====
# 需要启用时:取消注释本步骤与下方 merge 脚本中的 docker.io 标签行,
# 并在仓库 Secrets 配置 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN
# - name: Log in to Docker Hub
# if: ${{ secrets.DOCKERHUB_USERNAME != '' }}
# uses: docker/login-action@v3
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push multi-arch manifest lists
# DOCKERHUB_USERNAME 暂不注入(Docker Hub 推送停用,见上方登录步骤注释)
# env:
# DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
run: |
set -euo pipefail
DIGESTS_DIR="${{ runner.temp }}/digests"
GHCR_REPO="ghcr.io/${GITHUB_REPOSITORY,,}"
# 标签:v1.2.3 → ["1.2.3", "1.2"];手动触发 → ["main"]
if [[ "${GITHUB_REF_NAME}" == v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
MINOR="$(echo "${VERSION}" | cut -d. -f1,2)"
TAGS=("${VERSION}" "${MINOR}")
else
TAGS=("main")
fi
for IMG in lemon-ipw lemon-ipw-middleware; do
ARGS=()
for TAG in "${TAGS[@]}"; do
ARGS+=(-t "${GHCR_REPO}/${IMG}:${TAG}")
# Docker Hub 推送停用;启用时取消注释并恢复上方 DOCKERHUB_USERNAME env
# if [ -n "${DOCKERHUB_USERNAME}" ]; then
# ARGS+=(-t "docker.io/${DOCKERHUB_USERNAME,,}/${IMG}:${TAG}")
# fi
done
# 各平台单架构 manifest(GHCR 上按 digest 推送的产物)合并为一份
# 多架构 manifest list;imagetools create 会按 -t 目标推送到 GHCR
shopt -s nullglob
for PLATFORM_ID in amd64 arm64 armv7; do
PREFIX="${IMG}-${PLATFORM_ID}-"
FILES=("${DIGESTS_DIR}"/${PREFIX}*)
if [ ${#FILES[@]} -eq 0 ]; then
echo "ERROR: no digest file for ${IMG} (${PLATFORM_ID}); dir contents:" >&2
ls -la "${DIGESTS_DIR}" >&2 || true
exit 1
fi
for f in "${FILES[@]}"; do
hex="$(basename "${f}")"
ARGS+=("${GHCR_REPO}/${IMG}@sha256:${hex#${PREFIX}}")
done
done
shopt -u nullglob
echo "Merging ${#ARGS[@]} refs for ${IMG}:"; printf ' %s\n' "${ARGS[@]}"
docker buildx imagetools create "${ARGS[@]}"
done
echo "Pushed multi-arch manifests: ${TAGS[*]}"
+33
View File
@@ -0,0 +1,33 @@
name: Build and Deploy
on:
push:
branches:
- main
paths:
- 'serverless/edgeone/**'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24' # 选择适合的 Node.js 版本
- name: Install dependencies
run: npm install -g edgeone
- name: Deploy to EdgeOne Pages
run: cd serverless/edgeone && npx edgeone pages deploy -n ipw-cn -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
- name: Deploy to EdgeOne Pages
run: cd serverless/edgeone && npx edgeone pages deploy -n ipw-cn2 -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
+29
View File
@@ -0,0 +1,29 @@
name: Build and Deploy
on:
push:
branches:
- main
paths:
- 'serverless/edgeone-getip/**'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24' # 选择适合的 Node.js 版本
- name: Install dependencies
run: npm install -g edgeone
- name: Deploy to EdgeOne Pages
run: cd serverless/edgeone-getip && npx edgeone pages deploy -n edgeone-getip -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
+34
View File
@@ -0,0 +1,34 @@
name: Build and Deploy to Cloudflare Workers
on:
push:
branches: ['main']
paths:
- 'frontend-ssr/**'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- uses: pnpm/action-setup@v6
with:
version: 10
# 部署到 Cloudflare Workers
- name: Deploy to Cloudflare Workers
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
NITRO_PRESET: cloudflare_module
run: |
cd frontend-ssr
pnpm install
pnpm run deploy --keep-vars
+2 -2
View File
@@ -4,7 +4,7 @@ on:
push:
branches: ['main']
paths:
- 'lemon-getip/**'
- 'serverless/lemon-getip/**'
workflow_dispatch:
jobs:
deploy:
@@ -25,6 +25,6 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
cd lemon-getip
cd serverless/lemon-getip
npm install
npm run deploy
+27
View File
@@ -0,0 +1,27 @@
main.exe
ip2region_v4.xdb
ip2region_v6.xdb
GeoCN.mmdb
GeoLite2-City.mmdb
GeoLite2-ASN.mmdb
qqwry.ipdb
*.gz
full.txt
short.txt
*.mmdb
*.exe
lemon-ipw
.mmdb.tmp
*.tmp
*.log
node_modules/
.workbuddy/
# VitePress 构建产物
docs/.vitepress/.temp/
docs/.vitepress/cache/
docs/.vitepress/dist/
# 构建期临时配置文件(vitepress 每次构建生成,用完即弃)
docs/.vitepress/*.timestamp-*
tmp/
setting.json
*.BIN
+1
View File
@@ -0,0 +1 @@
24
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+54 -2
View File
@@ -1,2 +1,54 @@
# ipw.cn
ipw.cn替代品,用于ipw.cn宕机等情况
# Lemon IPW
ipw.cn 替代品,提供 IP 查询、网站检测、SSL 检查、DNS 解析、TCPing 测速、Whois、DNSSEC、ASN 查询、网站截图等功能。
**[预览站点](https://ipw.wsmdn.top/)** · **[文档](https://github.com/nomdn/ipw-cn/tree/main/docs)**
## 快速开始
```bash
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh && sudo bash install.sh
```
完整部署(后端节点 / 独立中间件 / 前端)与配置说明见 [文档](https://github.com/nomdn/ipw-cn/tree/main/docs):
- [功能总览](docs/info/features.md)(探测能力、IP 数据源、可靠性设计)
- [快速开始](docs/guide/getting-started.md)
- [后端节点部署](docs/guide/deploy-node.md)
- [中间件部署](docs/guide/deploy-middleware.md)
- [前端部署](docs/guide/deploy-frontend.md)
- [配置文件](docs/guide/config.md)
- [节点 API 与协议](docs/guide/node-api.md)(业务接口、管理接口、WS 协议、上报协议)
- [公开 API(/api/v1)](docs/guide/public-api.md)(收集中心程序化接口:鉴权、端点与响应示例、公开状态接口)
## 项目结构
```
ipw-cn/
├── src/ # Go 后端
├── middleware-go/ # 独立组件 转发中间件(Fiber v3,多节点候选)
├── serverless/ # 独立组件 边缘/Serverless(edgeone、edgeone-getip、lemon-getip)
├── frontend-ssr/ # Nuxt 4 SSR 前端
├── docs/ # 文档站
└── install.sh # 一键安装脚本
```
## 友情链接
### 来自社区的IPW版本
[ZAKOFLARE/ipw-cn-php](https://github.com/ZAKOFLARE/ipw-cn-php) 一个基于PHP的IPW节点
[ZAKOFLARE/ipw-cn-rust](https://github.com/ZAKOFLARE/ipw-cn-rust) 一个生锈的IPW节点[开发成本过高,已存档]
[KFCV50TK/ipw-cn](https://github.com/KFCV50TK/ipw-cn) 一个有许多扩展功能的IPW分支
## 技术栈
| 组件 | 技术 |
|------|------|
| 后端(自托管) | Go + Gin + Resty |
| 独立中间件 | Go + Fiber v3(WS 通道:coder/websocket) |
| 边缘后端(EdgeOne) | Go 边缘函数 |
| SSR 前端 | Nuxt 4 + Vue 3 + Element Plus |
| DNS 查询 | miekg/dns(DoH / UDP 双通道 + 主从 failover) |
| 部署 | Docker、Cloudflare Workers、EdgeOne Pages、systemd |
## 许可证
[GPL-3.0](LICENSE)
+88
View File
@@ -0,0 +1,88 @@
import { defineConfig } from 'vitepress'
// .vitepress/config.mts
import { defineTeekConfig } from "vitepress-theme-teek/config";
// Teek 主题配置:defineTeekConfig 返回的对象必须合并进 defineConfig 才会生效。
// teekHome:false + vpHome:true 使用 VitePress 标准首页布局,避免出现空荡的博客风格右侧卡片。
const teekConfig = defineTeekConfig({
teekHome: false,
vpHome: true,
vitePlugins: {
// 关闭文章统计插件,不再扫描/统计文档
docAnalysis: false,
// 关闭自动生成侧边栏插件,侧边栏由下方 themeConfig.sidebar 手动维护
sidebar: false,
},
});
// https://vitepress.dev/reference/site-config
export default defineConfig({
// 合并 Teek 注入的 vite / markdown / ignoreDeadLinks 等配置
...teekConfig,
title: "LEMON IPW",
description: "柠檬味 ipw.cn 替代品 · 文档(占位)",
lang: "zh-CN",
cleanUrls: true,
head: [
['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
['link', { rel: 'icon', href: '/favicon.ico' }],
],
themeConfig: {
// 合并 Teek 的 themeConfig(如 teekHome 等开关)
...teekConfig.themeConfig,
nav: [
{ text: 'Home', link: '/' },
{ text: '部署', link: '/guide/' },
{ text: 'GitHub', link: 'https://github.com/nomdn/ipw-cn', target: '_blank' },
],
// 右侧目录:显示 h2 + h3(默认只显示 h2)
outline: {
level: [2, 3],
label: '本页目录',
},
sidebar: [
// 简介
{
text: '简介',
items: [
{ text: '概述', link: '/info/overview' },
{ text: '功能总览', link: '/info/features' },
{ text: '快速入门', link: '/guide/getting-started' },
{ text: '配置文件', link: '/guide/config' },
],
},
// 部署
{
text: '部署',
items: [
{ text: '部署指南', link: '/guide/' },
{ text: '后端节点', link: '/guide/deploy-node' },
{ text: '中间件', link: '/guide/deploy-middleware' },
{ text: '前端', link: '/guide/deploy-frontend' },
],
},
// 参考
{
text: '参考',
items: [
{ text: '节点 API 与协议', link: '/guide/node-api' },
{ text: '公开 API(/api/v1)', link: '/guide/public-api' },
],
},
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/nomdn/ipw-cn' },
],
search: {
provider: 'local',
},
},
})
+6
View File
@@ -0,0 +1,6 @@
import { defineTeekConfig } from "vitepress-theme-teek/config";
export const teekConfig = defineTeekConfig({
homeCardListPosition: false,
teekHome: false,
});
+7
View File
@@ -0,0 +1,7 @@
// .vitepress/theme/index.ts
import Teek from "vitepress-theme-teek";
import "vitepress-theme-teek/index.css";
export default {
extends: Teek,
};
+347
View File
@@ -0,0 +1,347 @@
# 配置文件
各组件配置说明与完整字段参考。快速上手示例见 [快速开始](/guide/getting-started)。
## 后端(setting.json)
后端从当前目录读取 `setting.json`,不存在时使用默认值。最小配置:
```json
{
"port": 8080,
"dns-server": "119.28.28.28:53",
"block-private-ips": true,
"ipdb": true,
"cors": "",
"access-token": ""
}
```
- `port`:监听端口
- `dns-server`:DNS 解析服务器,支持**主从 failover**——逗号分隔多地址(如 `"119.28.28.28:53,223.5.5.5:53"`),第一个为主,主查询失败(超时/网络错误)自动切换下一个;每项支持 `ip:port`(UDP)或 `https://` URL(DoH);**留空则启动时自动探测系统 DNS**(`IP:Port` 逗号分隔串)兜底
- `dnssec-server`:**DNSSEC 专用** DNS 服务器(同样支持主从逗号分隔);留空则沿用 `dns-server`;`dns-server` 与 `dnssec-server` **都留空时自动探测系统 DNS** 兜底
- `block-private-ips`:SSRF 防护开关(拒绝出站连接内网/私有地址;探测目标命中私有网段时直接返回伪造结果,禁止跨跳重定向)
- `single-stack`:单栈模式,`ipv4` / `ipv6` / 留空(双栈)。声明单栈的节点跳过另一栈的探测(直接返回 `Skipped due to SINGLE_STACK=...`),EdgeOne / Vercel 等 IPv4-only 环境建议设为 `ipv4`
- `ipdb`:IP 数据库开关。**不配置时默认启用**(首次启动自动下载约 450MB,之后每 24h 更新);仓库的 `setting.json.example` 里显式写为 `"false"`,即**示例配置是关闭状态**——关闭后不加载任何数据库,`/v1/location`、`/v1/asn` 路由不注册,适合只做网络拨测的轻量节点
- `cors`:允许的请求来源(逗号分隔)
- `trusted-proxies`:可信代理 IP/CIDR(逗号分隔,如 `"10.0.0.0/8,173.245.48.0/20"`)。配置后 `ClientIP` 与 `/v1/location` 归属地接口只信任这些代理转发的 `X-Forwarded-For`,直连公网的节点可防止访客伪造 XFF;**留空保持默认行为(信任所有代理,XFF 可被伪造)**
- `access-token`:API 访问令牌,留空则不启用鉴权。**它同时是 HTTP 管理面的开关**:留空时 `/v1/config`、`/v1/ota` 整组返回 `403`,只能走 WS 通道管理
- `gh-proxy`:GitHub 下载代理前缀(如 `"https://ghproxy.com/"`),用于 IP 数据库拉取与 OTA 下载加速;留空直连
- `node-ota`:OTA 开关,缺省 `true`(允许收集中心下发)。设为 `false` 时节点拒绝一切 OTA 指令并回传原因,适合只读文件系统 / 编排托管(升级走各自部署渠道)的部署。**支持运行时热改**(`PATCH /v1/config` 或 WS `config` 下发即时生效,改后按新值判定后续 OTA 指令)
- `ws-url`:WS 通道地址——接入独立中间件的 WebSocket 地址(如 `"wss://middleware-1.api-ipw.wsmdn.top/ws"`,须含 `wss://` 前缀与 `/ws` 路径);支持**逗号分隔多个中间件,同时连接全部(多活)**,任一断开只重连自己,不影响其他连接;留空 = 不启用 WS 客户端,走原 HTTP 接口
- `node-id`:WS 节点 id(与中间件 `api-keys`/`ws-keys` 键、前端配置的节点 `id` 一致;建议用 UUID 唯一标识)
- `node-key`:WS 注册 key(与中间件 `ws-keys[节点id]` 一致;**必填**——节点未配置该 key 时中间件拒绝注册并返回 401)
- `report-url`:**数据上报收集中心** HTTP 基址(如 `"https://collector.example.com"`,即 ipw-boce 中间件)。节点把自身观测的统计/拨测按 `report-interval-seconds` 周期上报;`ws-url` 已启用时优先走 WS `report` 消息(所有已连接中间件广播),WS 未启用或全部掉线时 HTTP POST 到 `<report-url>/report` 兜底;远端配置可覆盖(在 `remote-ignore-config` 里加上 `report-url` 可禁止覆盖)
- `report-token`:收集中心 `/report` 鉴权 token(与中间件 `report-token` 配置一致);属凭据类键,GET 快照里遮蔽为 `***`;**不随远端配置覆盖**(硬编码保护,清单见本页末的「远端配置」一节)
- `report-interval-seconds`:上报周期秒,缺省 15
以上字段均可用环境变量覆盖(`PORTS` / `SINGLE_STACK` / `DNS_SERVER` / `DNSSEC_DNS_SERVER` / `BLOCK_PRIVATE_IPS` / `IPDB` / `CORS` / `TRUSTED_PROXIES` / `GH_PROXY` / `ACCESS_TOKEN` / `WS_URL` / `NODE_ID` / `NODE_KEY` / `NODE_OTA` / `REPORT_URL` / `REPORT_TOKEN` / `REPORT_INTERVAL_SECONDS`)。需要从远端拉取配置时,设置 `remote-config-url` 或环境变量 `REMOTE_CONFIG_URL`(优先级:远端 > 环境变量 > setting.json);`remote-ignore-config` 对应的环境变量为 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串)。
### 运行时配置管理
除启动加载外,节点(v1.4+)还支持被**远程读写当前生效配置**——收集中心控制台可以改配置而不必登录节点主机,节点也可以在运行中刷新远端配置。
| 动作 | HTTP | WS | 说明 |
|------|------|----|------|
| 读取 | `GET /v1/config` | `config` + `action=get` | 返回生效配置快照 + `secretKeys` + `restartRequiredKeys` |
| 修改 | `PATCH /v1/config`(`?persist=1` 时写回本地 setting.json) | `config` + `action=patch` | 逐键应用,只改传入的键 |
| 刷新远端 | `POST /v1/config/refresh` | `config` + `action=refresh` | 重新拉取 `remote-config-url` 并应用 |
三条约定:
- **凭据类键不回显明文**:`access-token` / `node-key` / `report-token` 在快照里是 `***`。下发时必须剔除这些键,否则会把真值覆盖成三个星号(收集中心控制台已自动剔除)。
- **凭据类键中 `access-token` / `report-token` 不受远端配置影响**:它们被硬编码进节点侧的保护名单(`configRemoteProtectedKeys`),远端配置里写了也会被跳过,并在应答 `protectedIgnored` 与日志中如实回报;本地 PATCH 不受此限。原因见本页末的「远端配置」一节。
- **节点不自行重启**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 是启动期固定的,改动后在应答 `restartRequired` 里如实列出,但**不会自动重启**;只有"值真的变了"才报,避免每次全量下发都误报。**`ws-url` 是例外,热生效**(控制器按新地址多退少补);`dns-server` / `dnssec-server` / `block-private-ips` / `single-stack` / `node-ota` 每次消费重读,同样即时生效。
- **内存改动会被重启顶掉**:节点侧优先级为 远端 > 环境变量 > `setting.json`。只 PATCH 内存(或 `persist` 写本地文件)时,重启后仍可能被 ENV 顶掉;要长期生效应 **`refresh`**(改远端配置)或把改动合并进托管配置。
> [!NOTE]
> **与托管配置的区别**:托管配置(收集中心的 `node_configs`,经 `GET /remote-config/:nodeId` 下发)是**节点启动时拉的远端配置**,是持久来源;运行时配置读写的是**节点进程内存里当前生效的值**。前者决定"重启后是什么",后者反映"现在是什么"。
接口细节见 [节点 API 与协议参考](/guide/node-api) 的「管理接口」一节。
---
## 前端(config/index.ts)
前端配置在 `frontend-ssr/config/index.ts`,部署前按需修改。完整配置项如下:
| 配置项 | 类型 | 说明 |
|--------|------|------|
| `siteUrl` | string | 站点对外地址 |
| `siteName` | string | 站点名称(页面标题 / 描述 / 页脚品牌) |
| `EnableInternalMiddleware` | boolean | 是否启用前端内置中间件(作为 `/middleware/*` 候选最后一位兜底),默认 `true` |
| `rateLimitPerMinute` | number | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
| `Middleware` | string[] | 外部独立中间件 base URL 列表,`/middleware/*` 请求依次尝试、出错重试下一个 |
| `umamiHost` / `umamiScriptUrl` / `umamiWebsiteId` | string | Umami 统计配置 |
| `ICP` / `GongAn` | string | 网站备案号(页脚展示) |
| `noindex` | boolean | 全站是否禁止搜索引擎索引 |
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | string | IPv4 / IPv6 / 双栈 IP 查询接口 |
| `APIBaseURL` | 节点池 | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
| `IPLocationAPI` | 节点数组 | IP 归属地 / ASN 查询节点池(纯数组,无栈区分) |
完整模板(复制到 `frontend-ssr/config/index.ts`,替换占位值即可):
```ts
const config = {
siteUrl: "https://your-domain.com/", // 站点对外地址
siteName: "你的站点名称", // 站点名称(页脚展示)
// 是否启用前端内置中间件(本地转发,作为候选最后一位兜底)
EnableInternalMiddleware: true,
// 内置中间件单 IP 限流次数(次/分钟),0 表示不限流
rateLimitPerMinute: 120,
// 外部独立中间件 base URL 列表(可留空,仅用内置中间件)
Middleware: <string[]>[],
// Umami 统计(不需要留空)
umamiHost: "",
umamiScriptUrl: "",
umamiWebsiteId: "",
// 备案号(页脚展示,不需要留空)
ICP: "",
GongAn: "",
// 是否禁止搜索引擎索引
noindex: false,
// 出站 IP 检测接口(页面直连需 CORS,建议 wsmdn.top 的三个)
v4OnlyAPI: "https://4.wsmdn.top",
v6OnlyAPI: "https://6.wsmdn.top",
DualStackAPI: "https://test.wsmdn.top",
// 拨测节点池(whois / ssl / detail / dns / dnssec / tcping / speed)
// IPv4-only 节点只能放 IPv4 栈,IPv6-only 放 IPv6 栈,双栈放 DualStack
APIBaseURL: {
DualStack: [],
IPv4: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
],
IPv6: []
},
// IP 归属地 / ASN 查询节点池(纯数组,无栈区分)
IPLocationAPI: [
{ label: "节点显示名", id: "node-1", url: "https://node-1.example.com/" }
]
}
export { config }
```
> 节点 `id` 需与后端节点 / 中间件 `api-keys` 的键保持一致,中间件按 `id` 注入 `Authorization: Bearer <key>`;WS 通道的节点注册校验另见中间件 `ws-keys`(与后端 `node-key` 对应)。
节点数组项均为 `{ label, id, url }` 结构,例如:
```ts
APIBaseURL: {
DualStack: [
{ label: "中国 江苏 移动", id: "cn-jiangsu", url: "https://cn-jiangsu.api-ipw.wsmdn.top/" }
],
IPv4: [],
IPv6: []
}
```
`label` 为节点展示名,`id` 为节点唯一标识(中间件 `api-keys` 按此 id 注入 HTTP 转发 key,WS 注册校验另见 `ws-keys`),`url` 为节点 base URL。节点池结构如下:`APIBaseURL` 为拨测节点池,含 `DualStack` / `IPv4` / `IPv6` 三栈(分别对应双栈 / 单栈测试时使用的节点列表,IPv4-only 节点只能放 `IPv4` 栈,IPv6-only 只能放 `IPv6` 栈,不能放 `DualStack`);`IPLocationAPI` 为 IP 归属地 / ASN 查询节点池,为纯数组(无栈区分)。
### 出站 IP 检测接口(v4OnlyAPI / v6OnlyAPI / DualStackAPI)
`v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` 用于获取请求方的出站 IP(分别对应 IPv4、IPv6、双栈),首页与部分页面会直接请求。可直接使用的接口:
| 接口 | 类型 |
|------|------|
| `https://4.wsmdn.top` | IPv4 |
| `https://6.wsmdn.top` | IPv6 |
| `https://test.wsmdn.top` | 双栈 |
| `https://ipv4.ip.sb` | IPv4 |
| `https://ipv6.ip.sb` | IPv6 |
| `https://ip.sb` | 双栈 |
| `https://4.itdog.cn` | IPv4 |
| `https://6.itdog.cn` | IPv6 |
| `https://v.itdog.cn` | 双栈 |
> **CORS 说明**:以上接口仅 `4.wsmdn.top` / `6.wsmdn.top` / `test.wsmdn.top` 返回 `Access-Control-Allow-Origin: *`,其余(ip.sb、itdog.cn)不带 CORS 头。前端页面在客户端导航时会从浏览器直接请求这些接口,**没有 CORS 会被浏览器拦截**,因此页面直连检测建议使用 wsmdn.top 的三个;ip.sb / itdog.cn 适合在服务端或命令行场景使用。
---
## 前端内置中间件(NUXT_APIKEYS)
前端内置中间件(`frontend-ssr/server/routes/middleware/[...slug].get.ts`)作为 `/middleware/*` 候选列表的最后一位兜底,转发时也会注入 key:
- 读取方式:`useRuntimeConfig(event).apiKeys`,按请求的 `backendID`(节点 `id`)查找
- 环境变量:**`NUXT_APIKEYS`**(Nuxt 约定 `NUXT_` + 配置键 `apiKeys`)
- 格式:JSON 字符串,例如 `NUXT_APIKEYS='{"cn-jiangsu":"your-key"}'`
- 注入方式:转发请求头 `Authorization: Bearer <key>`
也可以在 `nuxt.config.ts` 的 `runtimeConfig.apiKeys` 里直接写死(不推荐,密钥不应进仓库)。
### 内置中间件数据上报(调一次上报一次)
内置中间件可作为数据上报入口:每转发一次请求,向收集中心(ipw-boce 中间件)`POST /report` 上报一次统计与拨测明细。**配置全部经环境变量注入**(敏感项不进仓库):
| 环境变量 | 说明 |
|------|------|
| `NUXT_BOCE_REPORT_URL` | 收集中心基址(如 `https://collector.example.com`);**留空 = 不上报**(此时转发也不带标记头,由节点兜底记账) |
| `NUXT_BOCE_REPORT_TOKEN` | 收集中心 `/report` 鉴权 token(敏感,与收集中心 `report-token` 配置一致) |
| `NUXT_BOCE_REPORT_INSTANCE` | 上报方标识(存收集库 `probe_results.origin`);默认 `frontend-ssr` |
行为约定:
- 配置了上报后,内置中间件转发请求会带 `X-Boce-Reporter` 标记头——后端节点据此跳过这些请求的统计(防双算,见后端节点"数据上报"一节)
- 上报为 fire-and-forget:失败静默丢弃、不重试(统计是增量累加语义,重试会双算),不影响转发响应
- 上报失败不影响主链路;超时 5 秒
---
## 独立中间件(middleware-go)
中间件配置在 `middleware-go/setting.json`:
```json
{
"port": "8091",
"http-timeout-seconds": 30,
"cors": "",
"trusted-proxies": "",
"gh-proxy": "",
"ota": "false",
"rate-limit": 120,
"ws-port": 8092,
"remote-config-url": "",
"remote-ignore-config": [],
"api-base-url": {
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"IPv4": [],
"IPv6": []
},
"ip-location-api": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"api-keys": { "cn-jiangsu": "" },
"ws-keys": { "cn-jiangsu": "" }
}
```
- `rate-limit`:单 IP 每分钟限流次数,默认 `120`,`0` 表示不限流(也可用环境变量 `RATE_LIMIT` 覆盖)
- `trusted-proxies`:可信代理 IP/CIDR(逗号分隔)。配置后 `c.IP()`(限流按它分桶)只信任这些代理转发的 `X-Forwarded-For`,反代/CDN 后限流才按真实客户端 IP 计数;留空保持默认(不信任 XFF 头,按直连地址限流)。也可用环境变量 `TRUSTED_PROXIES` 覆盖
- `gh-proxy`:GitHub 代理前缀(OTA 下载加速,如 `"https://ghproxy.com/"`),可空;也可用环境变量 `GH_PROXY` 覆盖
- `ota`:**OTA 自更新开关**(`"true"` 启用)。启用后中间件定期检查本仓库 GitHub Release,发现新版本时下载匹配的 `middleware-go-*` 资产、预检、**替换自身并重启**(Windows 下为优雅交接,失败自动回滚旧版;major 版本升级需人工);默认关闭。也可用环境变量 `OTA` 覆盖
- `ws-port`:WS 服务端口,默认 `8092`,`0` 表示关闭 WS 通道(也可用环境变量 `WS_PORT` 覆盖;优先级:远端配置 > 环境变量 > setting.json)
- `remote-config-url`:远端配置地址,也可用环境变量 `REMOTE_CONFIG_URL`(环境变量优先)
- `remote-ignore-config`:**不被远端配置覆盖的配置项列表**(数组),如 `["port", "rate-limit"]`;也可用环境变量 `REMOTE_IGNORE_CONFIG`(JSON 数组字符串,优先于 setting.json)
- `api-keys`:用于向后端注入 `Authorization: Bearer <key>`,优先级:setting.json `api-keys` > 环境变量 `API_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**
- `ws-keys`:WS 注册校验表(`节点id → key`):节点经 WS 注册时,`register` 消息的 `key` 必须与 `ws-keys[节点id]` 一致,否则返回 401 并断开;优先级:setting.json `ws-keys` > 环境变量 `WS_KEYS`(JSON 字符串)。**敏感凭据,强制忽略,不随远端配置覆盖**。与 `api-keys` **相互独立**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,可分别配置不同 key(例如 WS 节点单独换 key 不影响 HTTP 转发)
- 节点池与前端 `config/index.ts` 结构一致:`APIBaseURL` 为拨测节点池(含 `DualStack` / `IPv4` / `IPv6` 三栈),`IPLocationAPI` 为 IP 归属地 / ASN 节点池(纯数组,无栈);节点加 `"ws": true` 可让该节点走 WS 通道通信,详见 [中间件部署](/guide/deploy-middleware) 的「WS 通道」一节
### WS 通道配置一览
WS 通道涉及**中间件(服务端)**与**后端节点(客户端)**两侧配置:
| 角色 | 配置项 | 环境变量 | 说明 |
|------|--------|----------|------|
| 中间件(服务端) | `ws-port` | `WS_PORT` | WS 监听端口,默认 `8092`,`0`=关闭;节点连 `ws(s)://<中间件>:<ws-port>/ws` |
| 中间件(服务端) | `ws-keys` | `WS_KEYS` | WS 注册校验表:`{"<node-id>": "<key>"}`,节点 `register` 的 key 必须匹配 |
| 节点(客户端) | `ws-url` | `WS_URL` | 中间件 WS 完整地址(含 `/ws` 路径),逗号分隔多个则**同时连接全部(多活)** |
| 节点(客户端) | `node-id` | `NODE_ID` | 节点 id,与中间件 `api-keys`/`ws-keys` 键一致(建议 UUID) |
| 节点(客户端) | `node-key` | `NODE_KEY` | 注册 key,与中间件 `ws-keys[节点id]` 一致,**必填** |
**数据上报通道**(节点把自身观测的统计/拨测上报到收集中心,归属规则防双算——中间件转发的请求由中间件上报,节点只报直连/前端/边缘流量):
| 角色 | 配置项 | 环境变量 | 说明 |
|------|--------|----------|------|
| 节点(客户端) | `report-url` | `REPORT_URL` | 收集中心(ipw-boce 中间件)HTTP 基址;WS 在线时优先走 WS 广播,掉线时 POST `<report-url>/report` 兜底 |
| 节点(客户端) | `report-token` | `REPORT_TOKEN` | `/report` 鉴权 token,与收集中心 `report-token` 一致;属凭据类键,快照遮蔽为 `***`,**不随远端配置覆盖** |
| 节点(客户端) | `report-interval-seconds` | `REPORT_INTERVAL_SECONDS` | 上报周期秒,缺省 15 |
**链路**:中间件收到 `ws:true` 节点的拨测请求 → 经 WS 发 `probe` → 节点执行探针(直调 webtest 函数 + 缓存)→ `probe_result` 回传 → 中间件返回 HTTP 响应(`Content-Type: application/json`)。节点侧接入步骤见 [后端节点部署](/guide/deploy-node) 的「WS 通道接入」一节。
---
## 远端配置(REMOTE_CONFIG_URL)
**什么是远端配置**:把配置文件放到一个可访问的 URL 上(如 Gist、对象存储、静态托管、任意 HTTP 服务),部署时只需设置一个环境变量,之后改配置**不用重新部署、不用重启**,下次启动自动拉取最新配置。适合多节点统一管理、批量更新节点配置的场景。
**支持的组件**(三个组件均已支持):
| 组件 | 触发方式 | 远端配置格式 |
|------|----------|--------------|
| 后端(主线 `src/main.go`) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 后端 `setting.json` 格式 |
| 后端(`serverless/edgeone/`) | `REMOTE_CONFIG_URL` | 后端 `setting.json` 格式 |
| 独立中间件(middleware-go) | `REMOTE_CONFIG_URL` 或 setting.json 的 `remote-config-url` | 中间件 `setting.json` 格式 |
**合并规则**(重要,逐字段生效):
- **优先级:远端配置 > 环境变量 > setting.json**
- 远端 JSON 中**非空的字段才覆盖**本地值:字符串非空、数组非空、数字非 0(`rate-limit` 为指针语义,缺省=不覆盖,显式 0=不限流)
- 部分字段缺省不会冲掉本地配置——远端只需包含要覆盖的字段
- **敏感凭据强制忽略,永远不被远端覆盖**(代码写死,无需配置):
- 后端 `access-token`:不随远端覆盖(保持 环境变量 > setting.json),远端里的 `access-token` 会被忽略
- 后端 `report-token`:同上。收集中心的托管配置里写了也不生效,节点会在日志与控制台应答的 `protectedIgnored` 里列出被跳过的键
- 中间件 `api-keys` / `ws-keys`:不随远端覆盖,远端里的 `api-keys`/`ws-keys` 会被忽略
为什么把这两项写死:它们被远端改错时的故障形态都是「远端一错、本地失联、且无法再远程修回」——`report-token` 被改 → 上报被收集中心 `/report` 拒收,控制台上节点仍显示在线、数据却静默断了;`access-token` 被改 → 控制台再也连不上该节点的管理面,连改回去的通道都没了。所以这层解耦必须由节点自己兜底,不能依赖「配置源别写错」。`node-key` 有意**不在**保护名单里(托管部署下常由远端统一下发节点身份),需要一并锁死时把它写进 `remote-ignore-config`。保护名单只增不减,且**只约束远端下发**,本地 `PATCH` 不受影响。
- `remote-ignore-config`(后端键名 `remote-ignore-config` / env `REMOTE_IGNORE_CONFIG`):额外指定**不被远端覆盖的配置项列表**,数组中的键即使远端下发也不生效。适用于 Node 身份类与其它不想被远端左右的项(如 `node-key`、`dns-server` 等),也可覆盖"非空才覆盖"规则
**拉取行为**:
- 在**进程启动时的配置加载阶段**一次性拉取
- HTTP 超时 **10 秒**,仅接受 **200** 响应(404/5xx 视为失败)
- 拉取或 JSON 解析失败:打印警告日志 `WARN failed to fetch remote config, fallback to local: ...`,**自动回退本地配置**,服务正常启动,不会崩溃
- 拉取成功:启动日志打印 `remote config applied from <url>`
- **启动时拉取一次,运行中可手动刷新**:进程启动的配置加载阶段拉取一次;此后不会自动轮询远端,但可经 `POST /v1/config/refresh`(或 WS `config` + `action=refresh`)**主动重新拉取并应用**,无需重启——收集中心控制台的「刷新远端」按钮即走这条路
**示例一:后端**(主线,远端 JSON 放在 `https://example.com/ipw-config.json`):
```json
{
"port": 8080,
"single-stack": "ipv4",
"dns-server": "119.28.28.28:53",
"cors": "https://ipw.wsmdn.top",
"remote-config-url": ""
}
```
部署时:
```bash
REMOTE_CONFIG_URL=https://example.com/ipw-config.json ./lemonipw
```
**示例二:独立中间件**(middleware-go,远端 JSON 放在 `https://example.com/mw-config.json`):
```json
{
"port": "8091",
"cors": "",
"trusted-proxies": "",
"rate-limit": 120,
"api-base-url": {
"DualStack": [ { "label": "中国 江苏 移动", "id": "cn-jiangsu", "url": "https://cn-jiangsu.api-ipw.wsmdn.top/" } ],
"IPv4": [],
"IPv6": []
}
}
```
> [!WARNING]
> 敏感凭据不得上传远端:`api-keys`/`ws-keys`(及后端 `access-token`、节点 key 等密钥)**禁止**写入远端配置——即使写了也会被强制忽略。凭据只放本地 setting.json 或环境变量。
部署时:
```bash
REMOTE_CONFIG_URL=https://example.com/mw-config.json ./middleware-go-linux-amd64
```
systemd 中同样用 `Environment="REMOTE_CONFIG_URL=..."` 配置。
**如何验证生效**:
- 启动日志出现 `remote config applied from <url>` 即拉取成功
- 用 `curl -i <远端URL>` 确认远端返回 `200` 且是合法 JSON
- 实际操作验证:在远端把 `port` 改成另一个值,重启进程,观察监听端口是否变化
**安全与排查**:
- **敏感凭据不得上传远端**:远端配置(Gist / 对象存储 / 静态托管等)**禁止包含任何密钥**——`api-keys`/`ws-keys`、`access-token`、节点 key(`node-key`)等。这些值会被强制忽略,但远端文件本身一旦泄露等于直接暴露凭据;凭据一律放本地 `setting.json` 或环境变量注入
- 远端 URL 必须能被节点访问;优先使用 **HTTPS** 地址
- 远端配置文件如果放在公开可访问的位置(公开 Gist、无鉴权对象存储),即便不含密钥,也等于把节点拓扑、节点 id 等敏感信息公之于众,建议放在私有仓库 / 带鉴权的对象存储 / 内部服务上
- 常见失败排查:
| 现象 | 原因 | 排查 |
|------|------|------|
| 日志 `WARN ... status 404` | URL 路径错误或远端文件不存在 | `curl -i <URL>` 检查返回码 |
| 日志 `WARN ... timeout` / 连接失败 | 网络不通或超时 | 确认节点可访问该 URL |
| 日志 `WARN ... invalid ... JSON` | 远端内容不是合法 JSON 或格式不符 | 检查 JSON 合法性、字段名与组件格式一致 |
| 启动日志无 remote 相关输出 | `REMOTE_CONFIG_URL` 未设置 | `env \| grep REMOTE` 确认环境变量已注入 |
+109
View File
@@ -0,0 +1,109 @@
# 前端部署
前端(`frontend-ssr/`)是全项目的核心,负责数据可视化与接口调用。基于 **Nuxt 4 SSR** 构建。
## 架构与部署目标
| 项 | 说明 |
|----|------|
| 技术栈 | Nuxt 4 + Vue 3 + Element Plus + VueUse |
| 运行时 | Node.js 22(见 `.node-version`)、包管理器 pnpm 10 |
| 构建产物 | `.output/server/index.mjs` + `.output/public/`(静态资源) |
| 构建命令 | `pnpm build`(Nitro SSR 构建) |
## 准备工作
```bash
# 1. 进入前端目录
cd frontend-ssr
# 2. 安装依赖(需要 Node 22 + pnpm 10)
pnpm install
# 3. 本地开发
pnpm dev
```
## 配置说明
前端所有配置集中在 `frontend-ssr/config/index.ts`,完整配置项见 [配置文件](/guide/config) 的「前端」一节:
| 配置项 | 说明 |
|--------|------|
| `siteUrl` | 站点对外地址 |
| `siteName` | 站点名称(页面标题 / 描述 / 页脚品牌) |
| `APIBaseURL` | 拨测上游节点池(whois / ssl / detail / dns / dnssec / tcping / speed),含 `DualStack` / `IPv4` / `IPv6` 三栈 |
| `IPLocationAPI` | IP 归属地 / ASN 上游节点池(纯数组,无栈区分) |
| `Middleware` | 外部独立中间件 base URL 列表,`/middleware/*` 请求依次尝试、失败重试下一个 |
| `EnableInternalMiddleware` | 是否启用前端内置中间件(本地转发,作为候选列表最后一位兜底),默认 `true` |
| `rateLimitPerMinute` | 内置中间件单 IP 限流次数(次/分钟),默认 `120`,`0` 表示不限流 |
| `v4OnlyAPI` / `v6OnlyAPI` / `DualStackAPI` | 出站 IP 检测接口(页面直连建议使用带 CORS 的 wsmdn.top 接口) |
| `umamiHost` 等 | Umami 统计配置 |
| `ICP` / `GongAn` | 网站备案号(页脚展示) |
| `noindex` | 是否禁止搜索引擎索引 |
> 生产环境建议通过运行时的环境变量或部署平台配置覆盖敏感项,不要把密钥写进 `config/index.ts` 提交到仓库。
## 部署方案
### 方案一:EdgeOne
腾讯云 EdgeOne 原生支持 Nuxt,点击下方按钮即可通过 **EdgeOne Makers** 一键部署(无需手动配置构建):
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=frontend-ssr&install-command=pnpm%20install&build-command=pnpm%20run%20build&output-directory=.output)
### 方案二:Vercel
点击下方按钮一键导入并部署到 Vercel(自动识别 Nuxt 项目,`root-directory` 已预置为 `frontend-ssr`):
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=frontend-ssr&install-command=pnpm%20install&build-command=pnpm%20run%20build&output-directory=.output)
### 方案三:Cloudflare Workers
使用 wrangler 手动部署:
```bash
cd frontend-ssr
pnpm build
pnpm deploy # 等价于 pnpm build && wrangler deploy
```
部署前需要配置 wrangler 认证(二选一):
```bash
# 方式一:环境变量(CI 中使用)
export CLOUDFLARE_API_TOKEN=your_api_token
export CLOUDFLARE_ACCOUNT_ID=your_account_id
# 方式二:wrangler login(本地交互登录)
npx wrangler login
```
> Cloudflare 部署按钮不支持子目录,而前端位于仓库 `frontend-ssr/` 子目录,因此不提供一键部署按钮;使用上方 wrangler 命令或仓库 CI/CD 工作流部署。
### 方案四:直接运行(Node 自托管)
前端是 Node.js 应用,构建产物为 JS 脚本 + 静态资源(非二进制),可直接用 Node 运行:
```bash
cd frontend-ssr
pnpm build
node .output/server/index.mjs # 默认监听 3000 端口
```
生产环境建议使用进程守护(systemd / pm2 等)保持常驻。
## CI/CD 自动部署
仓库内置工作流 `.github/workflows/frontend-ssr.yml`:
- **触发条件**:push 到 `main` 且改动路径为 `frontend-ssr/**`;或手动 `workflow_dispatch`
- **步骤**:checkout → Node 22 + pnpm 10 → `pnpm install` → `pnpm run deploy --keep-vars`
- **所需 Secrets**:`CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID`
- **注意**:工作流设置了 `NITRO_PRESET: cloudflare_module`
## 常见问题
- **部署后接口 403 / 跨域**:检查后端 `cors` 配置(独立中间件见 `middleware-go/setting.json` 的 `cors` 字段,逗号分隔允许域名);服务端转发请求不带 `Origin`,浏览器直接调用才受 CORS 限制。
- **出站 IP 检测接口被浏览器拦截**:页面直连的 IP 检测接口需要 CORS,详见 [配置文件](/guide/config) 的「出站 IP 检测接口」一节。
+124
View File
@@ -0,0 +1,124 @@
# 中间件部署
独立中间件(middleware-go)只负责**请求转发和 Key 注入**,不参与业务计算。前端将 `/middleware/*` 请求转发到中间件,由中间件转发到对应后端节点并注入 `Authorization: Bearer <key>`。
> [!IMPORTANT]
> 项目只提供配置示例,**使用前必须先复制为 `setting.json`**(GitHub Releases 中对应文件为 `middleware.setting.json.example`):
>
> ```bash
> # 仓库内
> cp middleware-go/setting.json.example middleware-go/setting.json
>
> # 从 Release 下载后
> cp middleware.setting.json.example setting.json
> ```
>
> 复制后按需修改 `api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` 等配置项。
## 方案一:Docker
`middleware-go/` 目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine,支持多架构):
```bash
cd middleware-go
docker build -t middleware-go .
docker run -d -p 8091:8091 \
-v $(pwd)/setting.json:/home/appuser/setting.json \
middleware-go
```
> 配置挂载注意:容器工作目录是 `/home/appuser`(非 root 用户运行),`setting.json` 要挂到这个路径下。
**多架构**:Dockerfile 已适配 buildx 交叉编译,本机构建其他架构:
```bash
cd middleware-go
docker buildx build --platform linux/arm64 -t middleware-go:arm64 --load .
```
打 `v*` 版本标签发版时,CI 会自动构建 `linux/amd64` / `linux/arm64` / `linux/arm/v7` 三架构镜像(amd64 原生构建,arm64/armv7 在 GitHub 原生 ARM 实例上交叉构建,无 QEMU),推送 GHCR(`ghcr.io/<owner>/<repo>/lemon-ipw-middleware`;Docker Hub 推送在 workflow 中暂未启用,预留了注释可随时开启),Docker 按宿主机架构自动拉取对应变体。
配置通过挂载 `setting.json` 提供(`api-base-url` / `ip-location-api` / `api-keys` / `ws-keys` / `cors` / `trusted-proxies` / `rate-limit` / `remote-config-url` 等;`rate-limit` 为单 IP 每分钟限流次数,默认 120,0 表示不限流,可用环境变量 `RATE_LIMIT` 覆盖;`remote-config-url` 为远端配置地址,可用环境变量 `REMOTE_CONFIG_URL` 覆盖;**`api-keys` / `ws-keys` 为敏感凭据,不随远端配置覆盖**——`api-keys` 管 HTTP 转发鉴权,`ws-keys` 管 WS 注册校验,两者相互独立)。
## 方案二:二进制
```bash
cd middleware-go
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o middleware-go-linux-amd64 .
./middleware-go-linux-amd64 # 运行(与 setting.json 同目录)
```
**OTA 自更新**(可选,默认关闭):配置 `"ota": "true"`(或环境变量 `OTA=true`)后,中间件定期检查本仓库 Release,下载与当前平台匹配的 `middleware-go-*` 资产并替换自身重启。下载加速可配 `"gh-proxy": ""`(或 `GH_PROXY` 环境变量)。交接流程与后端节点一致:预检 → 原子替换 → 优雅停机 → 拉起新进程 → 健康检查确认;失败自动回滚 `.old` 备份。
所有配置可用环境变量覆盖(`API_BASE_URLS` / `IP_LOCATION_APIS` / `CORS` / `TRUSTED_PROXIES` / `API_KEYS` / `WS_KEYS` / `RATE_LIMIT` 等,数组/对象用 JSON 字符串),优先级:**环境变量 > setting.json > 默认值**(远端配置 `REMOTE_CONFIG_URL` 高于两者,详见 [配置文件](/guide/config) 的「远端配置」一节)。
需要守护运行时,参考 [后端节点部署](/guide/deploy-node) 的「方案五:一键安装」一节的 systemd 管理方式(`ExecStart` 指向中间件二进制,`WorkingDirectory` 指向 `middleware-go/setting.json` 所在目录)。
## WS 通道(拨测数据经 WebSocket 传输)
middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入,拨测数据上传改用 WS 通道传输(HTTP 转发保留,双通道并存)。**现有 HTTP 接口(`/v1/*` / `/middleware/*`)完全不变**。
### 架构
```
前端(HTTP) → middleware-go(8091,HTTP 转发)→ 后端节点(ws:false 时)
↘ middleware-go(8092,WS 服务端)→ 后端节点(ws:true 时,WS 客户端连入)
```
- WS 服务端跑在**独立端口**(配置 `ws-port`,默认 `8092`,路径 `/ws`;`ws-port: 0` 或环境变量 `WS_PORT=0` 关闭),与 fiber HTTP 服务完全分离;端口走统一配置管道(远端配置 > 环境变量 > setting.json)
- 节点配置加 `"ws": true`(兼容 `"ws": "true"` 字符串写法)→ 该节点的拨测请求改走 WS 通道;缺省 `false` → 原 HTTP 转发不变
- ws:true 节点未连接或拨测超时 → 返回 502,不影响其他节点
### 配置示例
```json
{
"port": "8091",
"APIBaseURL": {
"DualStack": [ { "label": "上海 腾讯云 BGP", "id": "tencent-sh", "url": "", "ws": true } ],
"IPv4": [],
"IPv6": [ { "label": "河北 秦皇岛 联通", "id": "cn-hebei-qinhuangdao", "url": "https://cn-hebei-qinhuangdao.api-ipw.wsmdn.top/", "ws": true } ]
}
}
```
启动后日志会打印 `ws channel enabled on port 8092`。
### 消息协议
所有消息为 JSON 文本帧,信封:`{ "type": "...", "nodeId": "...", "ts": <unix秒>, "data": {...} }`
| 方向 | type | data 说明 |
|------|------|-----------|
| 节点 → middleware | `register` | `{ "nodeId": "...", "key": "...", "version": "...", "capabilities": [...] }`,连接后首条消息,同 id 新连接顶掉旧连接;`key` 为注册凭证,与 setting.json `ws-keys` 配置比对 |
| 节点 → middleware | `probe_result` | `{ "requestId": "...", "status": 200, "body": <JSON 值> }`(body 为 JSON 字符串时按原文透传) |
| 节点 → middleware | `ping` / `pong` | 心跳:节点每 10s 发 `ping`(middleware 回 `pong`);`pong` 为对 middleware `ping` 的应答 |
| 节点 → middleware | `report` | 统计 + 拨测明细的周期上报(同 `/report` body)。节点会**向所有已连接中间件广播**(多活),消费者是收集中心 ipw-boce;middleware-go 只做转发与拨测代理、不在转发路径采集,收到后**静默丢弃**(不打"未知消息"日志) |
| 节点 → middleware | `config_result` / `ota_result` | 运行时配置 / OTA 指令的应答。只有对端下发过 `config` / `ota` 才会出现;middleware-go 不下发这类指令(远程运维归收集中心),正常收不到,收到同样静默丢弃 |
| middleware → 节点 | `register_ok` | `{ "heartbeatSeconds": 20 }` |
| middleware → 节点 | `register_error` | `{ "code": 401, "command": "invalid key" }`(注册凭证错误时返回并断开连接) |
| middleware → 节点 | `probe` | `{ "requestId": "...", "apiType": "tcping", "raw": "qq.com", "query": {"port":"443"} }`,拨测请求(HTTP/WS 双通道时前端请求仍走 HTTP,middleware 内部转 WS) |
| middleware → 节点 | `ping` | 心跳(每 20s),节点回 `pong` |
| middleware → 节点 | `status` | 状态/统计上报:`{ "uptimeSeconds": ..., "totalRequests": ..., "errorRequests": ... }`(每 20s) |
### 节点侧接入(WS 客户端)
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署](/guide/deploy-node) 的「WS 通道接入」一节,配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册;`WS_URL` 支持逗号分隔多个中间件地址,节点会**同时连接全部(多活)**,任一断开只重连自己)
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `ws-keys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
3. 收到 `probe` → 执行拨测 → 回 `probe_result`(必须携带原 `requestId`,支持乱序返回,同一连接可并发多个拨测)
4. 收到 `ping` → 回 `pong`;`status` 可忽略或记录
5. 断线后重连并重新 `register` 即可
### 注册 key 校验
节点注册时中间件按 `ws-keys` 配置校验 key(与 `api-keys` HTTP 转发鉴权相互独立):
- 节点在 `ws-keys` 配置了 key(非空)→ `register` 必须携带正确 key,不传或传错返回 `register_error { "code": 401, "command": "invalid key" }` 并**断开连接**,不进入数据阶段
- 节点未配置 key(`ws-keys` 无该项或为空)→ 开放注册,不传 key 即可
与 HTTP 通道的鉴权语义一致(配置了 key 就必须带凭据,留空 = 开放)。
### 说明
- 注册阶段按节点 `ws-keys` 配置校验 key(配了 key 必须传对,否则 401 断开;未配置则开放,详见上文「注册 key 校验」);拨测请求经 WS 转发时不携带 `Authorization` 头(后续由节点侧自行决定是否需要额外鉴权)
- `ws:true` 且 WS 未启用(`WS_PORT=0`)时,自动回退原 HTTP 转发
+231
View File
@@ -0,0 +1,231 @@
# 后端节点部署
后端节点提供全部检测 API(IP 归属地 / ASN / SSL / DNS / DNSSEC / Whois / TCPing / 测速 / 截图等),可在任意地区部署,组成去中心化测试网络。
## 方案一:EdgeOne
腾讯云 EdgeOne 支持 Go 边缘函数,点击下方按钮通过 **EdgeOne Makers** 一键部署 `serverless/edgeone/` 版本(无需本地构建):
[![使用 EdgeOne Makers 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/makers/new?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=serverless%2Fedgeone)
也可以使用 EdgeOne CLI 手动部署:
```bash
cd serverless/edgeone
npx edgeone pages deploy -n ipw-cn -t $EDGEONE_API_TOKEN
```
`serverless/edgeone/` 版本的配置来源为 环境变量 + 远端配置(`REMOTE_CONFIG_URL`)。
### 数据上报(可选)
`serverless/edgeone/` 版本**没有 WS 客户端**(边缘函数不维持长连接),因此上报只走 HTTP 一条路:按周期把统计与拨测明细 `POST <report-url>/report` 到收集中心。下列键同样支持环境变量与远端配置两种来源(远端配置优先):
| 配置键 | 环境变量 | 说明 |
| --- | --- | --- |
| `node-id` | `NODE_ID` | 上报身份(写入 `probe_results.origin`);留空回退 hostname |
| `report-url` | `REPORT_URL` | 收集中心基址;留空则不上报 |
| `report-token` | `REPORT_TOKEN` | `/report` 鉴权,请求头 `Authorization: Bearer <token>`;收集中心未配 token 时可留空 |
| `report-interval-seconds` | `REPORT_INTERVAL_SECONDS` | 上报间隔秒,缺省 15 |
记账口径与主节点完全一致(节点是唯一记录者、`stats` 为增量累加、上报 at-most-once),报文格式见 [节点 API 与协议](/guide/node-api) 的「数据上报协议」一节。
> [!NOTE]
> 上报依赖进程存活期内的内存计数。若所用平台会把实例缩容到零,缓冲区会随实例回收丢失——上报只在实例存活期内累积。长驻部署(Docker / 二进制)不受此限。
## 方案二:Vercel
点击下方按钮一键导入仓库(部署目标为 `serverless/edgeone/cloud-functions` 目录,即 Go 边缘函数版本):
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnomdn%2Fipw-cn&root-directory=serverless%2Fedgeone%2Fcloud-functions)
> [!IMPORTANT]
> **Serverless 节点均为 IPv4-only**(EdgeOne、Vercel 等平台的出站网络只提供 IPv4)。部署后需要:
>
> 1. 给节点配置 `SINGLE_STACK=ipv4`(声明节点为 IPv4 单栈),跳过 IPv6 相关测试,避免无意义的错误日志与超时
> 2. 在前端配置 `config/index.ts` 中,**该节点只能填入 `APIBaseURL.IPv4`**(拨测节点池的 IPv4 栈),不要放进 `DualStack` / `IPv6` 栈
## 方案三:Docker
`src/` 目录提供 `Dockerfile`(多阶段构建,产物基于 Alpine,支持多架构):
```bash
cd src
docker build -t lemon-ipw .
docker run -d --restart unless-stopped -p 8080:8080 \
-v $(pwd)/setting.json:/home/appuser/setting.json \
lemon-ipw
```
> `--restart unless-stopped`:主进程意外退出时 Docker 自动拉起容器;手动 `docker stop` 不会触发重启。
配置通过挂载 `setting.json` 提供(注意挂载到容器工作目录 `/home/appuser`),也可用环境变量覆盖(`PORTS` / `CORS` / `TRUSTED_PROXIES` / `ACCESS_TOKEN` 等)。
> [!TIP]
> 容器内 OTA 替换二进制意义有限(重建容器即回滚到镜像版本),如需彻底关闭可设 `NODE_OTA=false`;镜像**不含 IP 库数据**,首次启动若 `ipdb` 未关闭会现场拉取约 450MB 到容器可写层,轻量部署建议直接 `IPDB=false`。
**多架构**:Dockerfile 已适配 buildx——构建阶段固定在构建机原生平台交叉编译(`TARGETOS`/`TARGETARCH`),目标架构无需 QEMU 模拟。本机为其他架构构建:
```bash
cd src
docker buildx build --platform linux/arm64 -t lemon-ipw:arm64 --load .
```
打 `v*` 版本标签发版时,CI(`.github/workflows/docker.yml`)会自动构建 `linux/amd64` / `linux/arm64` / `linux/arm/v7` 三架构镜像(amd64 在 x64 实例上原生构建,arm64/armv7 在 GitHub 原生 ARM 实例 `ubuntu-24.04-arm` 上交叉构建,全程无 QEMU),推送 GHCR 与 Docker Hub:
- GHCR:`ghcr.io/<owner>/<repo>/lemon-ipw`(自动)
- Docker Hub:`docker.io/<namespace>/lemon-ipw`(**workflow 中暂未启用**——`.github/workflows/docker.yml` 已预留注释,取消注释并配置 `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` Secrets 即可开启)
均带 `版本` 与 `major.minor` 标签。部署直接:
```bash
docker run -d -p 8080:8080 \
-v $(pwd)/setting.json:/home/appuser/setting.json \
ghcr.io/nomdn/ipw-cn/lemon-ipw:v1.2.3
```
Docker 会按宿主机架构自动拉取 manifest 中对应的镜像变体。
## 方案四:二进制
```bash
# 编译(Go 后端在 src/ 目录)
cd src && go build -o lemonipw .
# 运行(与 setting.json 同目录)
./lemonipw
```
首次启动自动下载 IP 数据库(约 450MB),之后每 24 小时自动更新一次。需要守护运行时,可参考下方「方案五」生成的 systemd 服务,或手动创建 service 文件(`ExecStart` 指向二进制,`WorkingDirectory` 指向 `setting.json` 所在目录)。
## 方案五:一键安装(install.sh)
仓库根目录提供 `install.sh` 一键安装脚本:自动检测架构 → 下载最新 release 二进制 → **交互式输入配置**(无需准备 setting.json,配置以环境变量注入)→ 生成 systemd 服务并守护进程。
```bash
# 先下载脚本到本地(勿用 curl | bash 管道执行,避免下载中断导致语法解析错误)
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh
sudo bash install.sh
```
**交互式配置项**(直接回车使用默认值):
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
| 监听端口 | `8080` | `PORTS` |
| 单栈模式 | 双栈 | `SINGLE_STACK`,`ipv4` / `ipv6` / 留空 |
| access-token | 留空 | 留空 = 不启用鉴权 |
| DNS 服务器 | 留空 | 主从逗号分隔(`119.28.28.28:53,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
| IP 数据库 | `Y` | `IPDB`,首次启动下载约 450MB |
| CORS | 留空 | 逗号分隔允许来源 |
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
| WS 通道接入 | `N` | 选 `y` 后继续输入 WS_URL / NODE_ID / NODE_KEY |
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束。如需可信代理在此输入 `TRUSTED_PROXIES=<IP/CIDR 逗号分隔>`(配置后归属地接口只信任这些代理转发的 X-Forwarded-For,防伪造) |
**WS 接入特殊规则**:
- `WS_URL` 留空时自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址时须为**完整路径**(含 `wss://` 前缀与 `/ws` 路径,如 `wss://host:8092/ws`),逗号分隔多个则**同时连接全部(多活)**
- `NODE_ID` **强制自动生成 UUID**(无需输入,如 `4580ea9d-2a16-4b8e-8090-5f2e7f0a2229`),适合作为节点唯一标识
- `NODE_KEY` **必填**:不加 key 禁止启用 WS(中间件 `ws-keys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `ws-keys`
**安装完成后自动生成并启动 systemd 服务**(`lemon-ipw.service`),配置以 `Environment="K=V"` 注入,无需 setting.json。常用管理命令:
```bash
systemctl status lemon-ipw # 状态
journalctl -u lemon-ipw -f # 日志
sudo systemctl restart lemon-ipw # 重启(改配置后)
```
修改配置:`sudo systemctl edit --full lemon-ipw` 编辑环境变量,保存后 `sudo systemctl daemon-reload && sudo systemctl restart lemon-ipw` 生效。
> [!WARNING]
> **版本兼容提醒**:v1.1.0 及更早的 release 二进制读的是旧环境变量名 `IPW_WS_URL` / `IPW_NODE_ID` / `IPW_NODE_KEY`(8-20 及之前构建)。install.sh 生成的 service 用的是新名 `WS_URL` / `NODE_ID` / `NODE_KEY`——**旧二进制 + 新 service 会因读不到配置而完全不启用 WS**(启动日志无任何 ws 输出)。升级到包含改名后的新 release 即可;急用可临时把 service 环境变量改回 `IPW_` 前缀。
## 配置
后端配置见 [配置文件](/guide/config):`setting.json` 或环境变量,支持远端配置(`REMOTE_CONFIG_URL`,优先级:远端 > 环境变量 > setting.json)。
## WS 通道接入(可选)
后端节点可作为 WS 客户端接入 [独立中间件](/guide/deploy-middleware) 的 WS 通道,拨测请求经 WebSocket 转发,节点本地执行探针后回传。不配置则完全走原 HTTP 接口。
**配置三个环境变量**(或 setting.json 的 `ws-url` / `node-id` / `node-key`,env 优先;也支持远端配置覆盖,除非列入 `remote-ignore-config`):
```bash
WS_URL=ws://<中间件IP>:8092/ws \
NODE_ID=<节点id,与中间件 api-base-url / ip-location-api 池的节点 id 一致> \
NODE_KEY=<注册key,与中间件 ws-keys[节点id] 一致;节点未配置 key 可留空> \
./lemonipw
```
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://中间件1:8092/ws,ws://中间件2:8092/ws`),**同时连接全部(多活)**:每个地址独立注册并保持连接,任一断开只重连自己,不影响其他中间件;注意多个地址指向同一中间件实例时,同 `nodeId` 后注册的连接会顶掉先前的
- **双向心跳**:节点每 10s 发 ping,中间件回 pong;心跳发送连续失败 3 次判定中间件不可用,断开后 3s 重试(注册被拒 30s 重试)
- 收到 `probe` 后节点直调探针函数(带缓存)并回 `probe_result`,结果与 HTTP 通道一致
- 节点注册带 `NODE_KEY`:中间件 `ws-keys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
**注意**:`NODE_ID` 必须与中间件 `APIBaseURL` / `IPLocationAPI` 池中的节点 `id` 一致,且该节点需配置 `"ws": true` 才会走 WS 通道。未连接中间件时,`ws:true` 节点的拨测会返回 502。
## 运行时配置管理(可选)
节点暴露 `/v1/config`,供收集中心读写该节点**当前生效**的配置(内存值),是「控制台改配置不用登机器」的基础:
| 动作 | HTTP | WS | 说明 |
|------|------|----|------|
| 读取 | `GET /v1/config` | `config` + `action=get` | 返回生效配置快照 + `secretKeys` + `restartRequiredKeys` |
| 修改 | `PATCH /v1/config`(`?persist=1` 写回本地文件) | `config` + `action=patch` | 只应用传入的键,其余不动 |
| 刷新远端 | `POST /v1/config/refresh` | `config` + `action=refresh` | 重新拉 `remote-config-url` 并应用 |
三条重要约定:
- **凭据类键不回显明文**:`access-token` / `node-key` / `report-token` 在快照中为 `***`。调用方下发配置时必须剔除这些键,否则会把真值覆盖成三个星号。收集中心控制台已自动剔除。
- **节点不自行重启**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 这些启动期固定的键,改动后在应答的 `restartRequired` 中如实列出,但**不会自动重启进程**——重启会中断在途服务,时机由运维 / 编排层掌握。`ws-url` 是例外:热生效,控制器按新地址多退少补。
- **只 PATCH 内存的改动会被重启顶掉**:节点侧优先级是 远端 > 环境变量 > `setting.json`,长期生效需写回托管配置或本地文件,详见 [配置文件](/guide/config) 的「运行时配置管理」一节。
> [!NOTE]
> **HTTP 管理面需要凭据**:节点未配置 `access-token` 时,`/v1/config` 与 `/v1/ota` 整组返回 `403`(此时业务接口也无鉴权,开放配置读写等于公网可改配置)。**WS 通道不受此限制**——节点是 WS 客户端,指令只来自已通过中间件 `ws-keys` 校验的连接。
## OTA 升级(收集中心下发,可选)
节点支持由收集中心下发**一次性升级任务**:下载新二进制 → 校验 → 预检 → 原子替换 → 重启,全程无需登录节点主机。
**触发方式**:收集中心经 WS `ota` 消息下发(HTTP 回退 `POST /v1/ota`)。请求体三选一指定下载源:
| 字段 | 说明 |
|------|------|
| `version` | 按版本号下发。节点按自身平台拼资产名 `lemonipw-{goos}-{goarch}[.exe]`,到资产基址 `{assetBase}/{tag}/` 下取;`assetBase` 缺省为 GitHub Releases,自建镜像源可覆盖 |
| `url` | 直发下载地址(完全自定义) |
| `sha256` | 可选,提供即强校验(hex64,兼容 `sha256:` 前缀) |
**执行流程**:下载到与目标同分区的临时文件(保证 `rename` 原子)→ 体积下限 1MB(防错误页)→ sha256 校验 → **预检**(试运行新二进制 `-v`,确认架构匹配、可执行,在停机前拦下损坏文件)→ 当前二进制改名 `.old` → 新文件就位 → 重启。任一步失败都会回滚 `.old`。
**进度回传**:经 WS `ota_result` 依次回报 `accepted` → `downloading` → `verifying` → `installing` → `restarting`,失败时带 `error`。重启后连接必然断开,**最终结果由收集中心以重连注册上报的新版本号判定**(15 分钟未观测到新版本即判超时失败)。
**并发与开关**:
- 同一时刻只允许一个 OTA 任务,重复下发直接拒绝。
- `node-ota: false`(env `NODE_OTA`)可完全禁用:节点拒绝一切 OTA 指令并回传原因。**只读文件系统 / 编排托管**(Docker Swarm、K8s 等自行管理镜像)的部署建议关闭。
- 部署方式受限导致替换失败(如容器只读层)时,升级会失败并回滚,此时仍按传统方式人工升级。
手动更新(OTA 不可用时):
```bash
# Docker:拉取新镜像后重建容器
docker pull lemon-ipw && docker stop lemon-ipw && docker run ... lemon-ipw
# systemd:替换二进制后重启服务
systemctl restart lemon-ipw
```
## 版本与能力上报
节点无论是否接入 WS,都会上报自身版本与支持的管理能力:
- **版本号**来自构建时注入的 `VERSION`,随 WS `register` 报文上报(字段 `version`);纯 HTTP 节点(无 WS 连接)由收集中心探活 `GET /` 时一并取回(响应体含 `version`)。
- **能力清单**(`capabilities`)取值为 `probe` / `report` / `config` / `ota`,用于让收集中心在下发管理指令前判断该节点能否理解——老版本节点的消息循环没有对应分支,收到管理消息会**静默忽略**,只能靠超时暴露。
- 收集中心「节点状态」页展示每个节点的版本号,并据此判断哪些节点需要升级;节点升级部署后重新注册即为新版本。
能力清单的取值与三态判定规则见 [节点 API 与协议参考](/guide/node-api) 的「能力清单」一节。
+76
View File
@@ -0,0 +1,76 @@
# 快速开始
> 本章节将指导您以最低成本部署一个功能完整的测试站
## 尝试一下
我们的开发团队已经部署了一个功能完整的预览站:
[预览站点](https://ipw.wsmdn.top/)
## 前置准备
- 一台 Linux 服务器(需 root 权限 + systemd)
- 服务器上可访问 GitHub(下载 release 二进制)
- 无需本地编译环境,无需准备配置文件
## 开始部署
### 交互式一键部署(推荐)
```bash
# 获取安装脚本(任选其一)
git clone https://github.com/nomdn/ipw-cn && cd ipw-cn # 或
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o install.sh
# 执行安装(root 权限)
sudo bash install.sh
```
> [!WARNING]
> 请勿使用 `curl ... | sudo bash` 管道方式执行:脚本经管道边下载边执行,网络慢/抖动时 bash 会读到不完整的脚本内容,报 `syntax error near unexpected token` 类错误(脚本本身无语法问题)。务必**先下载完整文件再执行**。
脚本会自动完成:检测架构 → 下载最新 release 二进制 → **交互式输入配置** → 生成并启动 systemd 守护进程。
**交互式配置项**(直接回车使用默认值):
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| 安装目录 | `/opt/lemon-ipw` | 二进制与工作目录 |
| 监听端口 | `8080` | 后端 HTTP 端口 |
| 单栈模式 | 双栈 | `SINGLE_STACK`,`ipv4` / `ipv6` / 留空 |
| access-token | 留空 | 留空 = 不启用鉴权 |
| DNS 服务器 | 留空 | 主从逗号分隔(如 `119.28.28.28:53,223.5.5.5:53`);留空 = 启动时自动探测系统 DNS |
| DNSSEC 专用 DNS | 留空 | 留空 = 沿用 dns-server;都留空 = 自动探测系统 DNS |
| IP 数据库 | `Y` | 首次启动下载约 450MB |
| CORS | 留空 | 逗号分隔允许来源 |
| 远端配置地址 | 留空 | `REMOTE_CONFIG_URL` |
| WS 通道接入 | `N` | 选 `y` 后输入 WS_URL / NODE_ID / NODE_KEY |
| 其他环境变量 | 无 | 每行一个 `K=V`,空行结束。如需可信代理在此输入 `TRUSTED_PROXIES=<IP/CIDR 逗号分隔>`(配置后归属地接口只信任这些代理转发的 X-Forwarded-For,防伪造) |
**WS 通道接入规则**(选 `y` 时):
- `WS_URL` 留空自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址须为完整路径(`wss://host:8092/ws` 形式);逗号分隔多个则**同时连接全部(多活)**
- `NODE_ID` **强制自动生成 UUID**(无需输入)
- `NODE_KEY` **必填**:不加 key 禁止启用 WS;安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 `ws-keys`
### 验证安装
```bash
systemctl status lemon-ipw # 服务状态(active running)
curl http://127.0.0.1:8080/ # 应返回 {"status":"ok"}
journalctl -u lemon-ipw -f # 实时日志
```
### 修改配置
```bash
sudo systemctl edit --full lemon-ipw # 编辑环境变量
sudo systemctl daemon-reload && sudo systemctl restart lemon-ipw
```
## 下一步
- 部署**独立中间件**(WS 通道 / 多节点转发):见 [中间件部署](/guide/deploy-middleware)
- 部署**前端**(Cloudflare Workers / EdgeOne Makers 等):见 [前端部署](/guide/deploy-frontend)
- 完整**配置说明**(后端 / 前端 / 中间件 / 远端配置):见 [配置文件](/guide/config)
- 手动**编译部署**(源码构建三个组件):见 [后端节点部署](/guide/deploy-node) / [中间件部署](/guide/deploy-middleware) / [前端部署](/guide/deploy-frontend)
+27
View File
@@ -0,0 +1,27 @@
# 部署指南
### 本章节将一步步指导您自部署柠檬味IPW
Lemon IPW 采用前后端分离架构,**后端节点、独立中间件、前端**三个组件可各自独立部署,并按需组合:
| 组件 | 职责 | 部署方案 |
|------|------|----------|
| [后端节点](/guide/deploy-node) | 提供全部检测 API | EdgeOne / Vercel / Docker / 二进制 |
| [独立中间件](/guide/deploy-middleware) | 请求转发 + Key 注入 | Docker / 二进制 |
| [前端](/guide/deploy-frontend) | 数据可视化与接口调用 | EdgeOne / Vercel / Cloudflare Workers / 直接运行 |
组件之间通过 `/v1/*`(直连后端)与 `/middleware/*`(经中间件转发)两类接口通信,前端按节点列表轮询、失败自动重试下一个节点。
## 快速开始
想用最低成本部署一套功能完整的测试站,直接看 [快速入门](/guide/getting-started):交互式一键部署(install.sh)→ systemd 守护。
## CI/CD
仓库内置 GitHub Actions 流水线,push 到 `main` 时按改动路径自动部署:
| 工作流 | 部署目标 |
|--------|----------|
| `frontend-ssr.yml` | SSR 前端 → Cloudflare Workers |
| `edgeone-backend.yml` | `serverless/edgeone/` → EdgeOne Pages |
| `build_and_release.yml` | 后端多平台构建与发布(Release 附带配置文件) |
+207
View File
@@ -0,0 +1,207 @@
# 节点 API 与协议参考
后端节点(`src/`)对外暴露的全部接口与协议。配置项说明见 [配置文件](/guide/config),部署方式见 [后端节点部署](/guide/deploy-node)。
> 本文描述 `src/` 主节点。边缘函数版本(`serverless/edgeone/`)是**精简形态**:只有 `/v1/*` 探测端点与 `GET /`(仅返回 `{"status":"ok"}`,不回报版本与能力),没有 WS 通道、管理接口与 OTA;但同样支持 HTTP 数据上报,见下文「数据上报协议」。
## 鉴权总则
| 通道 | 鉴权方式 | 未配置凭据时 |
|------|----------|--------------|
| 业务探测 `/v1/*` | `access-token` 非空时校验 `Authorization: Bearer <access-token>`,不符返回 `401` | 不启用鉴权,公开可访问 |
| 健康检查 `/` | 无 | — |
| 管理接口 `/v1/config`、`/v1/ota`(HTTP) | 同业务接口,复用 `tokenCheck` | **整组关闭**,一律返回 `403`(节点未配 `access-token` 时业务接口本身也无鉴权,暴露配置读写等于公网可改配置) |
| WS 通道 | 中间件 `ws-keys` 校验注册报文 | WS **常开**:节点是客户端,指令只来自已通过中间件校验的连接,与节点是否配 `access-token` 无关 |
> 结论:**HTTP 管理面是"有凭据才开"的**;只想用 WS 通道管理节点时,节点无需配置 `access-token`。
---
## 一、健康检查
```
GET / → {"status":"ok","version":"v1.2.3","capabilities":["probe","report","config","ota"]}
```
无鉴权、不计入统计。除存活外同时回报**版本号**与**能力清单**:收集中心对纯 HTTP 节点(无 WS 连接)探活时一并取回,用于节点状态页展示版本、以及下发管理指令前判断该节点能否理解对应指令。
---
## 二、业务探测接口
全部挂在 `/v1` 组下,共用 CORS + 可选 `tokenCheck` + 统计中间件(`nodeReportMiddleware`,见下文「数据上报」)。
| 方法 | 路径 | 参数 | 说明 |
|------|------|------|------|
| GET | `/v1/detail/*url` | 目标 URL(路径传入,自动补全协议) | 网站探测明细:DNS/TCP/HTTP 各阶段耗时、状态码、页面体积、下载速度、可达性;双栈分别返回 |
| GET | `/v1/ssl/*url` | 目标 URL | SSL 证书检查:剩余有效天数、颁发/主体、TLS 版本、是否过期;双栈分别返回 |
| GET | `/v1/speed/:version/*url` | `version` = `v4` / `v6`,目标 URL | 下载测速 |
| GET | `/v1/tcping/:ip` | `port`(默认 80)、`count`(默认 4,上限 20) | TCP 连通性与 RTT 统计 |
| GET | `/v1/dns/:type/*domain` | `type` ∈ `a`/`aaaa`/`cname`/`mx`/`ns`/`ptr`/`srv`/`txt`/`caa` | DNS 记录查询(`dns-server` 支持主从 failover 与 DoH) |
| GET | `/v1/dnssec/:domain` | 域名 | DNSSEC 链式验证(`dnssec-server`,留空沿用 `dns-server`) |
| GET | `/v1/whois/:domain` | 域名 | Whois 注册信息 |
| GET | `/v1/location/:ip` | IP | 多源 IP 归属地聚合(见 [功能总览](/info/features)) |
| GET | `/v1/location` | 无(取请求方 IP) | 查询访问者自身归属地;配置 `trusted-proxies` 后只信任这些代理转发的 `X-Forwarded-For` |
| GET | `/v1/asn/:ip` | IP | ASN 查询(三套 ASN 库 + ASN Whois 解析) |
**条件注册**:`/v1/location/*`、`/v1/asn/:ip` 仅在 `ipdb` 未关闭时注册——IP 库未加载时这些接口无意义。
**单栈模式**:`single-stack` 配为 `ipv4` / `ipv6` 时只跑对应栈,另一栈直接返回 `Skipped due to SINGLE_STACK=...`,不发起无意义的探测与超时。
**SSRF 防护**:`block-private-ips` 开启时,`detail` / `ssl` 对解析到内网/私有地址的目标直接返回伪造结果,出站客户端同时拒绝连接私有 IP、禁止跨跳重定向。
---
## 三、管理接口
### 3.1 运行时配置 `/v1/config`
读写节点**当前生效**的配置(内存值),与收集中心的「托管配置」是两回事——后者是节点启动时拉的远端配置,见 [配置文件](/guide/config) 的「运行时配置管理」一节。
| 方法 | 路径 | 作用 |
|------|------|------|
| GET | `/v1/config` | 返回当前生效配置快照 + `secretKeys` + `restartRequiredKeys` + `remoteProtectedKeys` |
| PATCH | `/v1/config` | 按下发 map 逐键应用;`?persist=1` 时同时写回本地 `setting.json` |
| POST | `/v1/config/refresh` | 重新拉取 `remote-config-url` 并应用(远端是持久来源,不写回本地文件) |
行为要点:
- **凭据遮蔽**:`access-token` / `node-key` / `report-token` 在 GET 快照里回显 `***`,不泄露明文;下发时原样提交会把真值覆盖成三个星号,因此调用方必须**剔除这些键**(收集中心控制台已自动剔除)。
- **`access-token` / `report-token` 不随远端配置覆盖**:这两项硬编码在保护名单 `configRemoteProtectedKeys` 里,`remote-config-url`(含收集中心托管配置)下发时会被跳过,并在 `refresh` 应答的 `protectedIgnored` 与节点日志里列出被跳过的键。**只约束远端下发**,本地 `PATCH` 照常可改。理由与清单见 [配置文件](/guide/config) 的「远端配置」一节。
- **未持久化的改动会被重启顶掉**:节点侧优先级是 远端 > 环境变量 > `setting.json`,只 PATCH 内存时重启即回到旧值。需要长期生效应 `refresh`(改远端配置)或把改动合并进托管配置——受保护凭据例外,只能落到本地 ENV / `setting.json`。
- **`restartRequired` 只如实回报**:`port` / `cors` / `ipdb` / `report-interval-seconds` / `trusted-proxies` / `node-id` / `node-key` / `access-token` 这些启动期固定的键,改动后节点**不会自行重启**,只在应答里列出键名,重启时机交给运维。
- **`ws-url` 例外,热生效**:WS 客户端控制器按新地址"多退少补",无需重启。 `node-ota` 同样即时生效:该开关在每次下发 OTA 指令时才读取,不属需重启类键。
### 3.2 OTA 升级 `/v1/ota`
收集中心 HTTP 回退通道(WS 在线时优先走 WS `ota` 消息)。
```
POST /v1/ota
{
"requestId": "可选,回执关联用",
"version": "v1.4.0", // 二选一:按版本号下发
"url": "https://.../lemonipw-linux-amd64", // 二选一:直发下载地址
"assetBase": "https://.../releases/download", // 可选,配合 version 使用
"sha256": "可选,hex64(兼容 sha256: 前缀),提供则强校验"
}
→ 202 {"ok":true,"started":true}
```
响应仅为**受理回执**(下载可能数分钟,HTTP 无进度通道)。执行阶段见 [后端节点部署](/guide/deploy-node) 的「OTA 升级」一节。
---
## 四、WebSocket 协议
节点作为 **WS 客户端**连接收集中心(或独立中间件)的 `/ws` 端点:`ws(s)://<中间件>:<ws-port>/ws`。`ws-url` 支持逗号分隔多个地址,**同时连接全部(多活)**,由 per-URL 连接控制器统一收敛。
### 4.1 消息信封
```json
{ "type": "...", "nodeId": "...", "ts": 1710000000, "data": { } }
```
### 4.2 消息类型
**节点 → 中间件**
| type | data 字段 | 作用 |
|------|-----------|------|
| `register` | `nodeId` / `key`(配了 ws-keys 时必填)/ `version` / `capabilities[]` | 注册接入,建立身份 |
| `ping` | — | 心跳,每 10s |
| `pong` | — | 应答中间件 ping |
| `probe_result` | `requestId` / `status` / `body` | 拨测结果回传 |
| `report` | 统计 + 拨测明细(同 `/report` body) | 周期数据上报 |
| `config_result` | `requestId` / `action` / `ok` / `error` / 配置快照 / `applied` / `unknown` / `restartRequired` / `persisted` | 配置指令应答 |
| `ota_result` | `requestId` / `ok` / `stage` / `error` | OTA 进度与失败原因 |
**中间件 → 节点**
| type | data 字段 | 作用 |
|------|-----------|------|
| `register_ok` | `heartbeatSeconds` | 注册通过,告知心跳周期 |
| `register_error` | `code` / `command` | 注册被拒(如 key 不匹配),30s 后重试 |
| `probe` | `requestId` / `apiType` / `raw` / `query` / `scheduler` | 下发拨测 |
| `config` | `requestId` / `action` / `config` / `persist` | 下发配置指令 |
| `ota` | `requestId` / `url` 或 `version`+`assetBase` / `sha256` | 下发升级任务 |
| `ping` | — | 中间件心跳(节点回 `pong`) |
| `status` | 统计字段 | 中间件状态,节点仅记录日志 |
### 4.3 心跳与重连
- 节点每 **10s** 发 `ping`;发送连续失败 **3 次**判定通道不可用,主动断开并在 **3s** 后重连。
- 注册被拒(key 错误等)时 **30s** 后重试。
- 中间件侧每 20s 发 `ping` + `status`,空闲 **75s** 未收到任何报文即剔除该节点并记离线事件。
### 4.4 OTA 进度阶段
节点经 `ota_result` 依次回报:`accepted` → `downloading` → `verifying` → `installing` → `restarting`;任一阶段失败即终止并带 `error`。重启后连接必然断开,"最终结果"由收集中心以**重连注册上报的新版本号**判定。
---
## 五、数据上报协议
统计与拨测明细**由节点自己上报**,收集中心不在转发路径上采集——节点是唯一记录者,不存在双算。
| 通道 | 条件 | 形式 |
|------|------|------|
| WS `report` 消息 | 已连接中间件 | 向所有在线中间件广播 |
| HTTP `POST <report-url>/report` | WS 未启用或全部掉线 | 携带 `Authorization: Bearer <report-token>` |
```json
{
"instance": "cn-jiangsu",
"stats": [
{ "nodeId": "cn-jiangsu", "apiType": "tcping",
"total": 1, "errors": 0, "latencySumMs": 12, "latencyMaxMs": 12, "minute": 29821676 }
],
"probes": [
{ "nodeId": "cn-jiangsu", "apiType": "tcping", "raw": "qq.com",
"status": 200, "latencyMs": 33, "body": { } }
]
}
```
- 上报周期由 `report-interval-seconds` 控制(缺省 15s)。
- `stats` 是**增量**,收集器按 `(分钟 × 节点 × apiType)` 累加;上报为 at-most-once(失败丢弃并记日志,不重试——重试会双算)。
- 单次报文上限 500 条 stats / 500 条 probes / 4MB;超出或队列(缓冲 2048)打满时丢弃并计数。
- **只上报第一方观测**:节点自己处理的请求才记账,不转播从别处收到的数据。
- `minute` 是 unix 分钟桶(按节点时钟);上报方传 `0` 表示"由收集器按自身时钟入桶",收集端另有"不接受未来 1 小时以外"的漂移容忍。
- **部署形态差异**:`serverless/edgeone/` 版本不维持长连接(无 WS 客户端),只走 HTTP `POST /report` 一条通道,配置方式见 [后端节点部署](/guide/deploy-node) 的 EdgeOne 一节。
---
## 六、能力清单(capabilities)
节点在 `register` 报文与 `GET /` 中上报自身支持的管理能力,取值为 `probe` / `report` / `config` / `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 小时 |
- **失败结果 30s 后删除**:探测失败(如目标不可达)不缓存整 5 分钟,避免故障恢复后长时间返回旧结论。
- **singleflight 合并**:`detail` / `ssl` / `tcping` / `speed` 的并发同 key 请求合并为一次真实探测。
- **定时清扫**:每 10 分钟清理过期缓存条目与 Bilibili 缓存,防止只靠"同 key 重访才淘汰"时内存无限增长。
+361
View File
@@ -0,0 +1,361 @@
# 公开 API(/api/v1)
收集中心(`ipw-boce` 中间件)对外提供的**程序化接口**,资源按 REST 规范组织:名词复数、裸资源 + HTTP 状态码、无信封。
- **面向任何持有凭据的调用方**:控制台用户生成一枚个人 API Token 即可用,适合脚本、巡检、看板、CI 取数。
- 与内部 `/admin/*` 共用**同一套鉴权与可见性**,只是对外形态更规范。
- 拨测**节点**自身的接口不在这里,见 [节点 API 与协议](/guide/node-api)。
> 下文 `<collector>` 指收集中心实例的 origin,例如 `https://collector.example.com`。
## 端点一览
| 方法 | 路径 | 作用 |
|------|------|------|
| GET | `/api/v1/tasks` | 任务列表(分页 + 标签过滤) |
| GET | `/api/v1/tasks/:id` | 单任务详情 |
| GET | `/api/v1/tasks/:id/sla` | 任务 SLA 聚合(顶层汇总 + 按节点明细) |
| GET | `/api/v1/probes` | 拨测明细(分页 + 过滤) |
| POST | `/api/v1/probes` | 一键拨测(同步聚合;本层唯一的写操作) |
| GET | `/api/v1/nodes` | 节点池简表(脱敏) |
| GET | `/api/v1/usage` | 用量聚合 |
---
## 快速开始
```bash
# 1) 取一枚个人 API Token(控制台「个人资料 → 个人 API Token」同效)
curl -sX POST "https://<collector>/admin/me/token" -H "Authorization: Bearer <你的 JWT>"
# → {"token":"ipt_9f3c...e1a7","hint":"e1a7","createdAt":"2026-09-13T07:00:00Z"}
# 2) 调用公开 API
curl -s -H "Authorization: Bearer ipt_9f3c...e1a7" \
"https://<collector>/api/v1/tasks?page=1&pageSize=20"
```
- Token **明文只在生成时返回一次**(`token` 字段);库里只存 bcrypt 哈希,`hint` 是末 4 位供列表辨识。
- 再次 `POST /admin/me/token` 会**立即作废旧 Token**;`DELETE /admin/me/token` 吊销。
- 静态 `admin-token` 没有账号,不能生成个人 Token(返回 400)。
---
## 鉴权
三轨,与 `/admin/*` 完全一致:
| 凭据 | 特征 | 身份 | 适用场景 |
|------|------|------|----------|
| 个人 API Token | `ipt_` 前缀 | 其账号(角色随账号) | 程序化访问的推荐方式,可随时吊销 |
| JWT | 无前缀 | 其账号(角色随账号) | `POST /admin/login` 签发,有过期时间 |
| 静态 `admin-token` | 无前缀 | 恒为 admin(`uid=0`) | 向后兼容;无归属,脚本慎用 |
请求头固定 `Authorization: Bearer <凭据>`。失败一律 `401`:
| message | 场景 |
|---------|------|
| `missing bearer token` | 未携带 `Authorization: Bearer ` 前缀 |
| `invalid or revoked token` | `ipt_` 开头但库中查不到(未生成 / 已吊销 / 被轮换) |
| `invalid credentials` | 其它凭据无效 |
> **API Token 等价于其账号登录**:权限随账号角色。普通用户只看得见自己的资源。
> 若收集中心既没配 `admin-token` 也没启用 JWT,则管理面**不鉴权**(仅限内网/联调部署,勿用于公网)。
---
## 通用约定
### 错误体
非 2xx 统一为:
```json
{ "error": { "code": "not_found", "message": "task not found" } }
```
| code | HTTP | 含义 |
|------|------|------|
| `unauthorized` | 401 | 缺凭据 / 凭据无效或已吊销 |
| `forbidden` | 403 | 凭据有效但无权访问该资源(如别人的任务) |
| `not_found` | 404 | 资源不存在(任务、分享令牌) |
| `bad_request` | 400 | 参数非法 |
| `rate_limited` | 429 | 请求过于频繁(仅免登录的公开状态接口) |
| `internal` | 500 | 服务端错误 |
### 分页
两套参数,**互不通用**:
| 端点 | 参数 | 缺省 | 边界处理 | 返回字段 |
|------|------|------|----------|----------|
| `/tasks` | `page`(1 起)、`pageSize`(别名 `limit`) | 1 / 50 | `page<1` → 1;`pageSize` 夹在 1~200 | `{items,total,page,pageSize}` |
| `/probes` | `limit`、`offset` | 50 / 0 | `limit` 夹在 1~500;`offset<0` → 0 | `{items,total,limit,offset}` |
越界值是**夹到边界**而非报错(`pageSize=9999` → 200,`page=0` → 1)。`total` 为符合过滤条件的总数,与当前页无关。两个列表均按 `id` 倒序(最新在前)。
### 可见性
| 角色 | 任务 | 拨测明细 |
|------|------|----------|
| admin / 静态 token | 全量 | 全量 |
| 普通用户 | 仅 `ownerId` = 本人 | 定时样本按「本人任务」归属、业务拨测按发起者 |
访问**单个**他人的任务返回 `403`(而非 404),便于区分"不存在"与"不是你的"。
### 限流
`/api/v1` **不挂限流**——限流只作用于转发口 `/v1` 与 `/middleware`。唯一例外是免登录的公开状态接口(见文末)。
---
## 一、任务
### 1.1 `GET /api/v1/tasks`
| 参数 | 必填 | 说明 |
|------|------|------|
| `page` | 否 | 页码,1 起,缺省 1 |
| `pageSize` | 否 | 每页条数,缺省 50,上限 200(`limit` 为别名) |
| `tag` | 否 | 按标签**子串**过滤(标签以逗号分隔存储) |
```json
{
"items": [
{
"id": 5, "name": "官网可用性", "ownerId": 2, "ownerUsername": "alice",
"enabled": true, "apiType": "detail", "target": "example.com", "stack": "",
"recordType": "", "nodeScope": "all", "nodeIds": "", "intervalSec": 60,
"slowMs": 800, "expectStatus": "2xx", "bothProtocols": false,
"requireAllStacks": true, "certExpiredDown": false, "notifyRecover": true,
"quietHours": "23:00-07:00", "hideTarget": false, "tags": "prod,官网",
"updatedAt": "2026-09-13T06:12:00Z"
}
],
"total": 1, "page": 1, "pageSize": 50
}
```
任务对象字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | number | 任务 id |
| `name` | string | 任务名 |
| `ownerId` | number | 所有者用户 id;`0` = 无归属(静态 token 建的旧任务) |
| `ownerUsername` | string | 派生字段:所有者用户名(无归属为空串) |
| `enabled` | bool | 是否启用调度 |
| `apiType` | string | `tcping` / `speed` / `ssl` / `detail` / `dns` |
| `target` | string | `detail`/`ssl`/`dns` = 域名;`tcping` = `host[:port]`;`speed` = `v4/`或`v6/`+URL |
| `stack` | string | `""`(按类型) / `v4` / `v6`,仅 `speed` 想固定单栈时用 |
| `recordType` | string | `dns` 专用:`a`/`aaaa`/`cname`/`mx`/`ns`/`ptr`/`srv`/`txt`/`caa`(缺省 `a`) |
| `nodeScope` | string | `all`(全池)/ `custom`(指定) |
| `nodeIds` | string | `custom` 时的逗号分隔节点 id;`all` 忽略 |
| `intervalSec` | number | 调度间隔(秒),最小 10 |
| `slowMs` | number | 慢阈值(ms),超过计一次"未达标";0 = 不启用 |
| `expectStatus` | string | 期望状态码:`"2xx"` 或 `"200"` / `"200,301,302"` |
| `bothProtocols` | bool | `detail`:http 与 https 都要命中才算成功 |
| `requireAllStacks` | bool | 双栈全通才算该节点可用 |
| `certExpiredDown` | bool | `ssl`:证书过期视为不可用 |
| `notifyRecover` | bool | 从 down 恢复时也通知一次 |
| `quietHours` | string | 免打扰时段 `"HH:MM-HH:MM"`(服务器本地时区,支持跨零点) |
| `hideTarget` | bool | 公开状态页隐藏探测目标 |
| `tags` | string | 逗号分隔标签 |
| `updatedAt` | string | 最后更新时间(RFC3339) |
> 分享令牌 `shareToken` **不在 JSON 里**(`json:"-"`),只经管理接口读写。
### 1.2 `GET /api/v1/tasks/:id`
返回单个任务对象(字段同 1.1)。
| 状态 | 场景 |
|------|------|
| `404 not_found` | `task not found` |
| `403 forbidden` | `not your task`(普通用户访问他人任务) |
### 1.3 `GET /api/v1/tasks/:id/sla?hours=24`
| 参数 | 必填 | 说明 |
|------|------|------|
| `hours` | 否 | 统计窗口(小时),缺省 24,夹在 1 ~ 2160(90 天) |
```json
{
"taskId": 5, "hours": 24,
"window": { "from": "2026-09-12T07:00:00Z", "to": "2026-09-13T07:00:00Z" },
"samples": 1440, "up": 1432, "down": 8,
"availability": 99.44, "avgMs": 128, "maxMs": 2103, "p95Ms": 402,
"byNode": [
{ "nodeId": "cn-jiangsu", "up": 720, "down": 0, "availability": 100,
"avgMs": 96, "p95Ms": 180, "latestMs": 88 }
]
}
```
- **只统计 `source=sched` 的定时样本**:节点自主上报与手动一键拨测不进 SLA(详见 [节点 API 与协议](/guide/node-api) 的「数据上报协议」一节)。
- 判定配置(`expectStatus` / `bothProtocols` / `requireAllStacks` / `certExpiredDown`)在**读取时**生效,改配置立刻作用于历史样本,无需回填重算。
- `availability` 是**百分数**(0~100,两位小数)。两处口径略有差异:
- 顶层 `availability` = `up/(up+down)`;
- `byNode[].availability` = `up/samples`(分母含无法判定的 `invalid` 样本,故通常略低)。
- `byNode[].latestMs` 是该节点窗口内最新一条可达样本的延迟。
---
## 二、拨测明细
### 2.1 `GET /api/v1/probes`
| 参数 | 必填 | 说明 |
|------|------|------|
| `node` | 否 | 节点 id 精确匹配 |
| `type` | 否 | `apiType` 精确匹配(`detail`/`ssl`/`dns`/`tcping`/`speed` …) |
| `source` | 否 | `sched`(定时)或 `biz`(业务拨测);**其它取值返回 400** |
| `since` | 否 | 起始时间,**RFC3339**(如 `2026-09-13T00:00:00Z`),非法返回 400 |
| `limit` | 否 | 缺省 50,上限 500 |
| `offset` | 否 | 缺省 0 |
```json
{
"items": [
{ "nodeId": "cn-jiangsu", "apiType": "tcping", "raw": "example.com:443",
"status": 200, "latencyMs": 33, "source": "sched", "error": "",
"createdAt": "2026-09-13T07:01:12Z" }
],
"total": 1440, "limit": 50, "offset": 0
}
```
- 为控制体积,`items` **精简且不含 `body`**(原始响应体)。需要看原始 body 请用控制台「拨测明细」页。
- 不传 `source` 时按可见性返回全部来源:admin 含节点上报的 `ws`/`http` 样本,普通用户仅自己的 `sched` + `biz`。
- **`source=biz` 的边界**:admin 身份下等价于「非 `sched` 的全部来源」(含节点上报的 `ws`/`http`);普通用户下严格等于「自己发起的 `biz`」。要精确取节点上报样本,请按 `node` / `type` 过滤。
### 2.2 `POST /api/v1/probes`
一键拨测:对节点池(或指定节点)并发发起一轮探测,**同步**聚合后一次性返回。这是公开 API 上唯一的写操作——结果会落库(`source=biz`)并归属 Token 主人。
请求体:
```json
{
"apiType": "detail",
"raw": "example.com",
"query": { "port": "443" },
"nodes": ["cn-jiangsu", "cn2-sichuan"]
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `apiType` | 是 | 探测类型;`location`/`asn` 走 IP 定位池,其余走 API 节点池 |
| `raw` | 是 | 探测目标;前导 `/` 会被去掉 |
| `query` | 否 | 透传给节点的查询参数对象(空值项被忽略) |
| `nodes` | 否 | 限定节点 id 列表;缺省 = 池内全部节点 |
```json
{
"apiType": "detail", "raw": "example.com",
"targeted": 2, "ok": 2, "failed": 0, "unknown": [], "persisted": 2,
"results": [
{ "nodeId": "cn-jiangsu", "label": "中国 江苏 移动", "channel": "http",
"status": 200, "latencyMs": 412, "body": { "ipv4": { "http_status_code": 200 } } },
{ "nodeId": "cn2-sichuan", "label": "中国 四川 电信", "channel": "ws",
"status": 0, "latencyMs": 15000, "error": "probe timeout" }
]
}
```
- **HTTP 200 即便部分节点失败**:单节点成败看 `results[i].status` 与 `results[i].error`。`results[i].channel` 为 `ws` / `http`,表示该节点实际走的通道。
- `unknown`:`nodes` 里指定了但不在池中的节点 id。
- `persisted`:实际落库条数。仅 `detail`/`ssl`/`dns`/`tcping`/`speed` 这类拨测型入库;`whois`/`dnssec`/`location`/`asn` 等诊断型不入库。
- **400** 的几种情况:池为空、`nodes` 全部不在池中(错误信息里会列出已知节点 id)、`apiType` 与 `raw` 缺失。
- 这是**并发同步**调用,耗时取决于最慢的节点(WS 通道有独立超时)。客户端超时应放宽到 **30s 以上**。
---
## 三、节点与用量
### 3.1 `GET /api/v1/nodes`
节点池简表,**脱敏**——只有启用中的节点,不含上游地址。
```json
{
"items": [
{ "nodeId": "cn-jiangsu", "label": "中国 江苏 移动",
"ws": false, "pools": "api,location", "stack": "", "online": true, "version": "v1.4.0" }
],
"total": 1
}
```
| 字段 | 说明 |
|------|------|
| `ws` | 该节点拨测是否走 WS 通道 |
| `pools` | 归属池,逗号分隔:`api` / `location` / `api,location` |
| `stack` | 仅 `api` 池有意义:`DualStack` / `IPv4` / `IPv6` |
| `online` | 是否在线(来自节点在线快照) |
| `version` | 节点上报的版本号;从未上线则为 `""` |
### 3.2 `GET /api/v1/usage?hours=24`
| 参数 | 必填 | 说明 |
|------|------|------|
| `hours` | 否 | 统计窗口(小时),缺省 24,夹在 1 ~ 2160 |
```json
{
"hours": 24, "total": 1440, "up": 1432, "down": 8, "invalid": 0,
"byType": [
{ "apiType": "detail", "total": 1200, "up": 1194, "down": 6, "invalid": 0, "avgMs": 128 }
]
}
```
- 口径:admin = 全站(`sched` + `biz` 样本);普通用户 = 自己任务的定时样本 + 自己发起的业务拨测。
- `byType` 按 `total` 倒序;`invalid` = 无法判定的样本,不计入成败。
---
## 附:免登录的公开状态接口
SLA 任务的**分享组**对外开放状态页,无需任何凭据。它不属于 `/api/v1`(不挂鉴权),但因同样面向公众而并列在此。
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/s/:token` | 只读状态页(Vue SPA,30s 自刷新;需托管侧提供 SPA fallback) |
| GET | `/api/public/status/:token` | 同数据的 JSON(页面用),参数 `hours` 同上 |
```json
{
"token": "a1b2c3", "hours": 24,
"tasks": [
{ "taskId": 5, "name": "官网可用性", "apiType": "detail", "target": "example.com",
"hours": 24, "samples": 1440, "up": 1432, "down": 8, "availability": 99.44,
"avgMs": 128, "maxMs": 2103, "p95Ms": 402,
"byNode": [ { "nodeId": "cn-jiangsu", "up": 720, "down": 0,
"availability": 100, "avgMs": 96, "p95Ms": 180, "latestMs": 88 } ],
"series": [ { "time": "2026-09-13T07:00:00Z", "minute": 29758260, "samples": 10,
"up": 10, "down": 0, "availability": 100, "avgMs": 120.5 } ] }
]
}
```
- 同一令牌的任务**同页展示**(令牌即"分享组");令牌为 3~32 位小写字母/数字/连字符,可自定义,管理接口见控制台「SLA 监控 → 分享」。
- **暴露面刻意收窄**:只有任务名/类型/目标 + 窗口聚合与延迟时序(`series` 为每轮多节点平均;多节点时另给 `nodeSeries` 分节点曲线);不含 owner、上游地址、内部字段。任务勾选 `hideTarget` 时,`target` 字段**整体不出现在 JSON 里**。
- 令牌未知或已关闭分享 → `404 not_found`(`invalid share link`)。
- **防枚举限流**:每 IP 60 次/分,超限 `429 rate_limited`(随机令牌仅 24-bit 熵,必须有这层保护);响应带 `Cache-Control: no-store`。
---
## 排错
| 现象 | 原因与处理 |
|------|------------|
| `401 missing bearer token` | 没带 `Authorization: Bearer ` 前缀(注意 `Bearer` 后有一个空格) |
| `401 invalid or revoked token` | Token 被重新生成过或已吊销 → 重新生成一枚 |
| `403 not your task` | 用普通用户的 Token 去读别人的任务 |
| `400 source 只能是 sched / biz` | `?source=` 传了其它取值 |
| `400 since 须为 RFC3339 时间` | 时间必须带时区,如 `2026-09-13T00:00:00Z` |
| `POST /probes` 400 且提示 known nodes | `nodes` 里的 id 都不在节点池 → 先用 `GET /api/v1/nodes` 取真实 id |
| `POST /probes` 耗时很久 | 同步聚合要等最慢节点 → 客户端超时设到 30s 以上 |
| 列表查不到自己刚建的任务 | 列表按 `id` 倒序;确认用的是同一个账号的 Token(普通用户只见自己的) |
+35
View File
@@ -0,0 +1,35 @@
---
# 首页(VitePress 标准布局,已在 config 中设置 vpHome: true / teekHome: false)
layout: home
hero:
name: "LEMON IPW"
text: "LEMON IPW官方文档"
tagline: IP 查询 · 网站检测 · SSL · DNS · DNSSEC · Whois · ASN · 截图
actions:
- theme: brand
text: 快速开始
link: /guide/getting-started
- theme: alt
text: GitHub
link: https://github.com/nomdn/ipw-cn
target: _blank
features:
- title: 多源 IP 查询
details: 支持 IPv4/IPv6,集成 ip2region、qqwry、MaxMind GeoIP、GeoCN、DbIP、Bilibili 等多数据源,覆盖国内外归属地。
- title: ASN 与 Whois
details: 查询 IP 所属自治系统号(多数据源 + WHOIS 解析),以及域名注册信息、注册商、到期时间等。
- title: 网站检测全家桶
details: HTTP 状态码与响应时间、SSL 证书有效期与颁发机构、TCPing 延迟、下载测速、在线页面截图一站式完成。
- title: DNS 解析
details: 多节点并发查询,支持 A / AAAA / CNAME / MX / TXT / NS / SRV / PTR / CAA 等记录类型,并提供 DNSSEC 链式信任验证。
- title: SSRF 防护
details: 自动拦截对内网 / 私有 IP 的出站请求,校验安全跳转,避免服务端请求伪造风险。
- title: 分布式节点与 WS 通道
details: 节点可作为客户端接入中间件,拨测请求经 WebSocket 下发与回传;支持多地址同时连接(多活)、断线自动重连,`ws-url` 改动热生效无需重启。
- title: 远程运维
details: 节点上报版本号与能力清单,收集中心展示版本、远程读写运行时配置,并可下发一次性 OTA 升级任务(下载 → 校验 → 替换 → 重启,失败自动回滚)。
- title: 多平台部署
details: 支持二进制,Docker,乃至AWS Lambda,EdgeOne Makers,Vercel和阿里云函数计算
---
+79
View File
@@ -0,0 +1,79 @@
# 功能总览
后端节点(`src/`,Go + Gin)承担全部检测能力,可在任意地区部署组成去中心化测试网络。接口清单见 [节点 API 与协议参考](/guide/node-api)。
## 探测能力矩阵
| 能力 | 路由 | 依赖 | 说明 |
|------|------|------|------|
| 网站探测明细 | `/v1/detail/*url` | 出站 HTTP(v4/v6 双客户端) | DNS 解析、TCP 连接、TLS 握手、首字节、总耗时、状态码、页面体积、下载速度、可达性 |
| SSL 证书检查 | `/v1/ssl/*url` | TLS 握手 | 证书有效期/剩余天数、颁发机构、主体、TLS 版本、是否过期 |
| 网站测速 | `/v1/speed/:version/*url` | 出站下载 | 指定栈的下载速度测试 |
| TCPing | `/v1/tcping/:ip` | TCP 拨号 | 端口连通性 + RTT 统计(次数可配) |
| DNS 解析 | `/v1/dns/:type/*domain` | `dns-server` | 9 种记录类型;DNS 服务器支持主从 failover 与 DoH |
| DNSSEC 验证 | `/v1/dnssec/:domain` | `dnssec-server` | DNSKEY / RRSIG / DS 链式信任校验 |
| Whois | `/v1/whois/:domain` | 上游 whois 服务器 | 域名注册商、注册/到期时间等 |
| IP 归属地 | `/v1/location`、`/v1/location/:ip` | IP 数据库(10 源) | 多源聚合,含国家/省/市/ISP/经纬度/时区 |
| ASN 查询 | `/v1/asn/:ip` | 3 套 ASN 库 + Whois | 自治系统号与所属组织 |
**双栈**:除 IP 归属地/ASN 外的能力均按 IPv4 / IPv6 分别执行并同时返回(`single-stack` 可声明单栈节点以跳过另一栈)。
**统计口径**:所有 `/v1/*` 请求经统计中间件计数,由节点按周期上报收集中心(见 [节点 API · 数据上报](/guide/node-api))。
## IP 数据源
`ipdb` 未关闭时启动加载,共 10 个源;任一本地文件缺失即从远端拉取全部数据库(首次约 450MB,之后每 24h + 随机抖动更新一次)。
| 源 | 用途 | 形式 |
|----|------|------|
| ip2region | 国家 / 省 / 市 / ISP | 本地 xdb(v4 + v6) |
| IP2Location LITE DB11 | 国家 / 省 / 市 / ISP / 经纬度 / 时区 | 本地 BIN |
| IP2Location LITE ASN | ASN | 本地 BIN |
| qqwry | 国家 / 省 / 市 / ISP / 国家码 | 本地 ipdb |
| MaxMind GeoLite2 City | 国家 / 省 / 市 / 经纬度 | 本地 MMDB |
| MaxMind GeoLite2 ASN | ASN / 组织 | 本地 MMDB |
| DbIP City Lite | 国家 / 省 / 市 / 经纬度 | 本地 MMDB |
| DbIP ASN Lite | ASN / 组织 | 本地 MMDB |
| GeoCN | 中国行政区划码 + ISP + 类型 | 本地 MMDB + 行政区划表 |
| Bilibili | 国家 / 省 / 市 / ISP / 经纬度 | **在线 API**,24h 缓存 |
- `/v1/location` 聚合全部 10 源(结果按源分组返回,便于交叉比对);`/v1/asn` 只查三套 ASN 库并对 MaxMind ASN 做 Whois 解析。
- `gh-proxy` 可给 GitHub 下载加代理前缀加速;GeoCN 走 jsDelivr CDN。
- `ipdb=false` 时整体关闭:不加载数据库,`/v1/location`、`/v1/asn` 路由不注册,适合只做网络拨测的轻量节点。
## 可靠性设计
- **结果缓存**:7 类缓存(6 个业务缓存 5 分钟 + Bilibili 24 小时),失败结果 30s 后即失效;每 10 分钟定时清扫过期条目。详见 [节点 API · 缓存与并发](/guide/node-api)。
- **singleflight**:`detail` / `ssl` / `tcping` / `speed` 的并发同 key 请求合并为一次真实探测。
- **SSRF 防护**(`block-private-ips`):拒绝出站连接私有/内网地址,探测目标命中私有网段时直接返回伪造结果,禁止跨跳重定向;`trusted-proxies` 控制 `X-Forwarded-For` 信任范围,防止访客伪造来源 IP。
- **DNS 兜底**:`dns-server` / `dnssec-server` 留空时自动探测系统 DNS,避免容器环境下解析不可用。
## 与收集中心 / 中间件的关系
```
┌───────────────────────────────┐
浏览器 / 前端 ───▶│ 中间件(ipw-cn/middleware-go │
│ 或 ipw-boce 收集中心) │
└───────┬───────────────┬───────┘
HTTP │ │ WS(/ws)
转发 ▼ ▼
┌───────────────────────────────┐
│ 后端节点(本仓库) │
│ 执行探测 → 结果原路回传 │
│ 统计/明细 → 周期上报收集中心 │
└───────────────────────────────┘
```
- **转发链路**:前端不直连节点,而是经中间件按池轮询转发;节点也可以直接对外提供 `/v1/*`(自建纯节点模式)。
- **WS 通道**:节点作为客户端接入中间件,拨测请求经 WS 下发、结果经 WS 回传;同时承载配置管理(`config`)与升级(`ota`)指令。
- **数据上报**:统计与拨测明细只由节点上报,中间件不在转发路径上采集,避免双算。
- **版本与能力上报**:节点上报版本号与能力清单,收集中心据此展示版本、判断能否下发管理指令。
## 部署形态
| 形态 | 说明 |
|------|------|
| 二进制 / systemd | `install.sh` 一键安装,配置以环境变量注入 |
| Docker | `src/Dockerfile` 多阶段构建,多架构(amd64 / arm64 / armv7);镜像不含 IP 库,首次运行现场拉取 |
| Serverless | EdgeOne Makers、Vercel、AWS Lambda、阿里云函数计算等(均为 IPv4-only,需声明 `SINGLE_STACK=ipv4`) |
各形态的详细步骤见 [后端节点部署](/guide/deploy-node)。
+44
View File
@@ -0,0 +1,44 @@
# 柠檬味IPW
柠檬味ipw是一个基于Golang的分布式站点测试平台,旨在构建一个去中心化的,共建贡献的测试平台。
> [!TIP]
> 尝试一下?这里是官方站点:[链接](https://ipw.wsmdn.top)
## 简介
Lemon IPW(柠檬味 ipw.cn 替代品)是一个开源的分布式站点测试平台。与中心化测试服务不同,本项目鼓励社区成员部署自己的测试节点,前端多节点轮询、后端多地区部署,组成一张去中心化的测试网络,单一节点故障不影响整体可用性。
## 功能
- IP 地址查询(IPv4/IPv6,集成 ip2region、qqwry、MaxMind、GeoCN、DbIP 等多数据源)
- ASN 自治系统查询(MaxMind、DbIP,集成 WHOIS 解析)
- 网站检测(HTTP 状态码、响应时间)
- SSL 证书检查(有效期、颁发机构)
- DNS 解析(A/AAAA/CNAME/MX/TXT/NS/SRV/PTR/CAA 等,多节点并发)
- DNSSEC 验证(DNSKEY、RRSIG、DS 链式信任)
- Whois 查询
- TCPing(IPv4/IPv6 双栈)
- 网站测速
- 网站截图
- 远程运维(版本 / 能力上报、运行时配置下发、一次性 OTA 升级)
完整的能力矩阵、IP 数据源与可靠性设计见 [功能总览](/info/features)。
## 架构
前后端分离,可独立部署、自由组合:
- 前端(Nuxt 4 SSR)负责数据可视化与接口调用
- 后端(Go)提供全部检测 API,可在任意地区部署测试节点,是分布式查询的核心
- 独立中间件(middleware-go)使用Fiber技术栈 负责请求转发和 Key 注入
后端支持二进制、Docker 自托管,也支持 AWS Lambda、EdgeOne Makers、Vercel、阿里云函数计算等 Serverless 平台部署。
## 技术栈
Go、Gin、Nuxt 4、Vue 3、Element Plus。
## 下一步
- 想自己部署一套?前往 [部署指南](/guide/)
- 想了解前端如何部署?阅读 [前端部署](/guide/deploy-frontend)
+11
View File
@@ -0,0 +1,11 @@
{
"scripts": {
"docs:dev": "vitepress dev",
"docs:build": "vitepress build",
"docs:preview": "vitepress preview"
},
"devDependencies": {
"vitepress": "^1.6.4",
"vitepress-theme-teek": "^1.6.2"
}
}
+1858
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
File renamed without changes.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 264 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 941 B

After

Width:  |  Height:  |  Size: 941 B

+29
View File
@@ -0,0 +1,29 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
# cloudflare
.wrangler
.dev.vars*
!.dev.vars.example
+75
View File
@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+352
View File
@@ -0,0 +1,352 @@
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
import { useDark, useToggle } from '@vueuse/core';
import { Moon, Sunny, Expand } from '@element-plus/icons-vue';
import { config } from '../config/index';
const isNarrow = ref(false);
let mediaQueryList: MediaQueryList | null = null;
const drawer = ref(false);
const isDark = useDark();
const toggleDark = useToggle(isDark);
function cleanChineseCharacters(str:string) {
return str.replace(/[\u4e00-\u9fa5]/g, '');
}
// ==================== 响应式折叠菜单 ====================
// 映射表:菜单按显示顺序排在一个数组里,每项自带 minWidth(px)折叠阈值。
// 规则:视口宽度 >= minWidth 显示,低于则折叠进「更多」子菜单。
// 断点从右往左递减(最右侧 index 9 先折叠),数值可按需调整。
// divider 也带 minWidth(取其后第一项的值):所在段全部折叠时,分隔线一并隐藏。
interface NavItem {
kind: 'item'
index: string
label: string
to: string
minWidth: number
}
interface NavGroup {
kind: 'group'
index: string
label: string
minWidth: number
children: { index: string; label: string; to: string }[]
}
interface NavDivider {
kind: 'divider'
minWidth: number
}
type MenuEntry = NavItem | NavGroup | NavDivider
const menu: MenuEntry[] = [
// 断点设计基准(从右往左折叠):全显示约 1480px,1366 屏可用约 1350px,
// 故 1366 屏折叠 index 7/8/9(显示 1-6 +「更多」,约 1220px 宽裕);1440 折叠 8/9;1500+ 全显示。
{ kind: 'item', index: '1', label: 'IPv6 网站检测', to: '/ipv6webcheck', minWidth: 980 },
{ kind: 'item', index: '2', label: 'IPv6/IPv4 地址查询', to: '/location', minWidth: 1040 },
{ kind: 'item', index: '3', label: 'IPv6 TCPing测试', to: '/ipv6tcping', minWidth: 1100 },
{ kind: 'divider', minWidth: 1160 }, // 与 index 4 相同:4-6 段全折叠时隐藏
{ kind: 'item', index: '4', label: 'IPv6 DNS解析', to: '/dns', minWidth: 1160 },
{ kind: 'item', index: '5', label: 'IPv6 SSL检查', to: '/ssl', minWidth: 1220 },
{ kind: 'item', index: '6', label: 'IPv6 网站测速', to: '/ipv6speedtest', minWidth: 1280 },
{ kind: 'divider', minWidth: 1380 }, // 与 index 7 相同:7-9 段全折叠时隐藏
{
kind: 'group', index: '7', label: 'IPv4工具箱', minWidth: 1380,
children: [
{ index: '7-0', label: 'IPv4 网站测速', to: '/speedtest' },
{ index: '7-1', label: 'IPv4 TCPing测试', to: '/tcping' },
],
},
{
kind: 'group', index: '8', label: '其他工具', minWidth: 1420,
children: [
{ index: '8-0', label: '网站截图', to: '/screenshot' },
{ index: '8-1', label: 'Whois查询', to: '/whois' },
{ index: '8-3', label: 'ASN查询', to: '/asn' },
{ index: '8-4', label: 'DNSSEC验证', to: '/dnssec' },
],
},
{ kind: 'item', index: '9', label: '文档', to: '/doc', minWidth: 1500 },
]
// SSR 阶段无 window,默认按宽屏输出全部菜单项;onMounted 后更新为真实视口宽度
const viewportWidth = ref(1920)
// 可见项(含分隔线):视口宽度达标即显示
const visible = computed<MenuEntry[]>(() =>
menu.filter((entry) => viewportWidth.value >= entry.minWidth),
)
// 折叠项(不含分隔线):视口宽度不足即收进「更多」。
// 用类型谓词收窄为 NavItem | NavGroup,模板里才能安全访问 entry.index
const hidden = computed<Array<NavItem | NavGroup>>(
() => menu.filter((entry): entry is NavItem | NavGroup =>
entry.kind !== 'divider' && viewportWidth.value < entry.minWidth),
)
// 实时同步视口宽度(resize 触发,驱动折叠)
function updateViewport() {
viewportWidth.value = window.innerWidth
}
let umamiScript: HTMLScriptElement | null = null
useHead({
meta: config.noindex
? [
{ name: 'robots', content: 'noindex, nofollow' },
{ name: 'googlebot', content: 'noindex, nofollow' },
{ name: 'bingbot', content: 'noindex, nofollow' },
{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'}
]
: [{ name:'baidu-site-verification', content: 'codeva-xzdbvF8gQu'}],
});
onMounted(() => {
mediaQueryList = window.matchMedia('(max-width: 768px)');
isNarrow.value = mediaQueryList.matches;
const handler = (e: MediaQueryListEvent) => {
isNarrow.value = e.matches;
};
mediaQueryList.addEventListener('change', handler);
updateViewport();
window.addEventListener('resize', updateViewport);
onBeforeUnmount(() => {
mediaQueryList?.removeEventListener('change', handler);
window.removeEventListener('resize', updateViewport);
});
if (!umamiScript && config.umamiScriptUrl) {
umamiScript = document.createElement('script')
umamiScript.src = config.umamiScriptUrl
umamiScript.async = true
umamiScript.setAttribute('data-website-id', config.umamiWebsiteId)
document.head.appendChild(umamiScript)
}
})
</script>
<template>
<el-drawer v-if="isNarrow" v-model="drawer" direction="ltr" style="height: 100%;" size="50%">
<router-link to="/ipv6webcheck">
<p class="menu-item-text">IPv6 网站检测</p>
</router-link>
<router-link to="/location">
<p class="menu-item-text">IPv6/IPv4 地址查询</p>
</router-link>
<router-link to="/ipv6tcping">
<p class="menu-item-text">IPv6 TCPing</p>
</router-link>
<router-link to="/dns"><p class="menu-item-text">IPv6 DNS解析</p></router-link>
<router-link to="/ssl">
<p class="menu-item-text">IPv6 SSL检查</p>
</router-link>
<router-link to="/ipv6speedtest"><p class="menu-item-text">IPv6 网站测速</p></router-link>
<router-link to="/speedtest"><p class="menu-item-text">IPv4 网站测速</p></router-link>
<router-link to="/tcping"><p class="menu-item-text">IPv4 TCPing</p></router-link>
<router-link to="/screenshot"><p class="menu-item-text">网站截图</p></router-link>
<router-link to="/whois"><p class="menu-item-text">Whois查询</p></router-link>
<router-link to="/asn"><p class="menu-item-text">ASN查询</p></router-link>
<router-link to="/dnssec"><p class="menu-item-text">DNSSEC验证</p></router-link>
</el-drawer>
<el-menu
mode="horizontal"
:ellipsis="false"
>
<el-menu-item index="0">
<el-icon v-if="isNarrow" @click="drawer = !drawer"><Expand /></el-icon>
<router-link to="/">
<el-image src="/favicon.svg" style="margin-top: 20px;" />
<h2 style="display: inline-block; margin-left: 10px" v-if="!isNarrow">{{ config.siteName }}</h2>
</router-link>
</el-menu-item>
<template v-if="!isNarrow">
<!-- 可见菜单项:按映射表顺序渲染 item / group / divider -->
<template v-for="entry in visible" :key="entry.kind === 'divider' ? 'div-' + entry.minWidth : entry.index">
<el-menu-item v-if="entry.kind === 'item'" :index="entry.index">
<router-link :to="entry.to"><p class="menu-item-text">{{ entry.label }}</p></router-link>
</el-menu-item>
<el-sub-menu v-else-if="entry.kind === 'group'" :index="entry.index">
<template #title>{{ entry.label }}</template>
<el-menu-item v-for="child in entry.children" :key="child.index" :index="child.index">
<router-link :to="child.to"><p class="menu-item-text">{{ child.label }}</p></router-link>
</el-menu-item>
</el-sub-menu>
<el-divider v-else style="margin-top: 20px;height: 1.2em;" direction="vertical"/>
</template>
<!-- 折叠项:统一收进「更多」子菜单(分组用 el-menu-item-group 保留组名) -->
<el-sub-menu v-if="hidden.length" index="more">
<template #title>更多</template>
<template v-for="entry in hidden" :key="entry.index">
<el-menu-item v-if="entry.kind === 'item'" :index="'m-' + entry.index">
<router-link :to="entry.to"><p class="menu-item-text">{{ entry.label }}</p></router-link>
</el-menu-item>
<el-menu-item-group v-else :title="entry.label">
<el-menu-item v-for="child in entry.children" :key="child.index" :index="'m-' + child.index">
<router-link :to="child.to"><p class="menu-item-text">{{ child.label }}</p></router-link>
</el-menu-item>
</el-menu-item-group>
</template>
</el-sub-menu>
</template>
<el-menu-item index="10">
<ClientOnly>
<el-icon @click="toggleDark()" v-if="isDark" style="cursor: pointer;"><Moon style="height: 20px; width: 20px;"/></el-icon>
<el-icon @click="toggleDark()" v-else style="cursor: pointer;"><Sunny style="height: 20px; width: 20px;"/></el-icon>
</ClientOnly>
</el-menu-item>
</el-menu>
<NuxtLoadingIndicator />
<main id="main-content" role="main">
<NuxtPage />
</main>
<footer>
<div class="one-line">
Copyright © nomdn & IP 查询 2026 | <img src="/ipv6-s1.svg" alt="IPv6 相关标识"/> | <img src="/ssl-s1.svg" alt="SSL 相关标识"/> | All right reserved
</div>
<div class="one-line">
<a v-if="config.ICP" href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer" >{{ config.ICP }}</a>
<span v-if="config.ICP">&nbsp;|&nbsp;</span>
<el-image v-if="config.GongAn" style="height: 1em; width: 1em;" src="/备案图标.png" />
<a :href="'https://beian.mps.gov.cn/#/query/webSearch?code=' + cleanChineseCharacters(config.GongAn)" target="_blank" rel="noreferrer" >{{ config.GongAn }}</a>
<span v-if="config.GongAn">&nbsp;|&nbsp;</span>
<a href="https://www.china-ipv6.cn/" target="_blank" rel="noreferrer" >国家IPv6发展监测平台</a>
&nbsp;|&nbsp;请遵守中国法律法规&nbsp;|&nbsp;
<a href="https://github.com/nomdn/ipw-cn" target="_blank" rel="noreferrer" >Github</a>&nbsp;|&nbsp;
<a href="https://qm.qq.com/q/E1CGjkqgG6" target="_blank" rel="noreferrer" >QQ用户交流群</a>
</div>
<div class="one-line">
致力于普及IPv6,推进IPv6规模部署和应用,以全面推进IPv6技术创新与融合应用为主线,以提升应用广度深度为主攻方向
</div>
</footer>
</template>
<style scoped>
@import "~/style.css";
/* 菜单项统一文本样式(替代原内联 style="display: inline-block; margin-left: 10px") */
.menu-item-text {
display: inline-block;
margin-left: 10px;
}
:deep(.shiki span) {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Monaco', 'Courier New', monospace !important;
word-wrap:break-word;
}
:deep(.shiki){
padding: 20px;
border-radius: 10px;
}
:deep(.el-menu-item a) {
font-size: 1em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.el-menu-item a p) {
font-size: 1em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.el-menu-item a img) {
width: 50px;
margin-bottom: 20px;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
/* Drawer 内部链接占满一行 */
.el-drawer__body {
overflow-x: hidden;
}
.el-drawer__body a {
display: block !important;
width: 100% !important;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 1em;
}
.el-drawer__body a p {
display: block !important;
width: 100% !important;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 窄屏(≤768px,与 script 中 isNarrow 的 matchMedia 断点一致):
在水合前由 CSS 兜底隐藏宽屏菜单项,避免窄屏设备闪现宽屏布局 */
@media (max-width: 768px) {
.el-menu--horizontal > .el-divider {
display: none !important;
}
.el-menu--horizontal > .el-menu-item[index="1"],
.el-menu--horizontal > .el-menu-item[index="2"],
.el-menu--horizontal > .el-menu-item[index="3"],
.el-menu--horizontal > .el-menu-item[index="4"],
.el-menu--horizontal > .el-menu-item[index="5"],
.el-menu--horizontal > .el-menu-item[index="6"],
.el-menu--horizontal > .el-sub-menu[index="7"],
.el-menu--horizontal > .el-sub-menu[index="8"],
.el-menu--horizontal > .el-menu-item[index="9"],
.el-menu--horizontal > .el-sub-menu[index="more"] {
display: none !important;
}
}
.el-menu--horizontal {
--el-menu-hover-bg-color: transparent !important;
--el-menu-active-color: var(--el-text-color-primary) !important;
--el-menu-bg-color: transparent !important;
}
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
/* 去除选中强调和下划线 */
.el-menu--horizontal > .el-menu-item.is-active {
color: var(--el-text-color-primary) !important;
background-color: transparent !important;
}
.el-menu--horizontal > .el-menu-item:hover {
color: var(--el-text-color-primary) !important;
background-color: transparent !important;
}
/* 去除所有可能的边框和下划线 */
.el-menu--horizontal::after {
display: none !important;
}
.el-menu--horizontal > .el-menu-item {
border-bottom: none !important;
transition: none !important;
}
.el-menu--horizontal > .el-menu-item.is-active::after {
display: none !important;
}
/* 覆盖选中、悬停和聚焦状态的高亮样式 */
</style>
+78
View File
@@ -0,0 +1,78 @@
/* 当 html 有 .dark 类名时,切换为暗色主题 */
html.dark {
/* 强制覆盖所有颜色变量 */
color-scheme: dark;
}
/* 暗色模式下的背景色和文字颜色 */
html.dark .markdown-body {
/* GitHub 暗色主题的核心颜色 */
--color-fg-default: #e6edf3;
--color-fg-muted: #8b949e;
--color-fg-accent: #3EAF7C;
--color-bg-default: #242424;
--color-bg-muted: #080707;
--color-bg-accent: #3EAF7C;
--color-border-default: #242424;
--color-border-muted: #242424;
background-color: var(--color-bg-default);
color: var(--color-fg-default);
}
/* 暗色模式下的标题颜色 */
html.dark .markdown-body h1,
html.dark .markdown-body h2,
html.dark .markdown-body h3,
html.dark .markdown-body h4,
html.dark .markdown-body h5,
html.dark .markdown-body h6 {
color: var(--color-fg-default);
}
/* 暗色模式下的链接颜色 */
html.dark .markdown-body a {
color: var(--color-fg-accent);
}
/* 暗色模式下的代码块 */
html.dark .markdown-body pre {
background-color: var(--color-bg-muted);
border: 1px solid var(--color-border-default);
}
html.dark .markdown-body code {
background-color: var(--color-bg-muted);
color: var(--color-fg-default);
}
html.dark .markdown-body pre.shiki,
html.dark .markdown-body pre.shiki code {
background-color: transparent;
}
/* 暗色模式下的引用块 */
html.dark .markdown-body blockquote {
border-left-color: var(--color-border-muted);
color: var(--color-fg-muted);
}
/* 暗色模式下的表格 */
html.dark .markdown-body table tr {
background-color: var(--color-bg-default);
border-top-color: var(--color-border-muted);
}
html.dark .markdown-body table th,
html.dark .markdown-body table td {
border-color: var(--color-border-muted);
}
html.dark .markdown-body table tr:nth-child(2n) {
background-color: var(--color-bg-muted);
}
/* 暗色模式下的分割线 */
html.dark .markdown-body hr {
border-color: var(--color-border-muted);
}
+272
View File
@@ -0,0 +1,272 @@
<script setup lang="ts">
import { useMiddlewareFetch } from '../../utils/middleware';
import { ref, onMounted, computed, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index'
import { isIPv6 } from 'is-ip'
const route = useRoute()
useHead({
title: `ASN查询 | IP自治系统号查询 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP、IP2Location 多数据源对比,同时集成WHOIS解析获取ASN详细信息,包括组织名称、国家、注册日期等,助力网络运维和路由分析' },
{ name: 'keywords', content: 'asn查询,自治系统号,asn lookup,ip asn,asn whois,网络自治系统,运营商asn,ip归属asn' },
{ property: 'og:title', content: 'ASN自治系统查询工具 - IP ASN号与组织信息查询' },
{ property: 'og:description', content: '多数据源ASN查询,支持Maxmind GEOLite2、DB-IP、IP2Location,集成WHOIS解析获取ASN详细信息' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'ASN自治系统查询工具',
description: '专业的ASN自治系统查询工具,支持IP地址ASN号查询,提供Maxmind GEOLite2、DB-IP、IP2Location 多数据源对比,集成WHOIS解析。',
url: config.siteUrl + 'asn',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
})
interface ASNResult {
ip: string
geolite2_asn?: { asn: string; org: string }
dbip_asn?: { asn: string; org: string }
ip2location_asn?: { asn: string; as: string }
whois?: any
}
const tmpIP = ref('1.1.1.1')
const loading = ref(false)
const result = ref<ASNResult | null>(null)
const error = ref('')
const userIP = ref('')
const apiList = config.IPLocationAPI
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const { data: asnData, error: asnError, execute: executeASN } = useMiddlewareFetch<ASNResult>(() => '/middleware/' + backendID.value + '/asn/' + encodeURIComponent(tmpIP.value), {
key: 'asn-fetch',
immediate: false,
watch: false,
deep: false,
server: false,
lazy: true,
})
watch(asnData, (newData) => {
if (newData) {
result.value = newData
loading.value = false
error.value = ''
}
})
watch(asnError, async (newError) => {
if (newError) {
console.log(newError)
if (currentApiIndex.value < apiList.length - 1) {
const nextIndex = currentApiIndex.value + 1
error.value = `${(newError as any).message || '请求失败'},正在重试 ${apiList[nextIndex]?.label || ''}...`
currentApiIndex.value = nextIndex
await nextTick()
executeASN()
} else {
error.value = '请求失败,请检查IP或网络'
loading.value = false
}
}
})
async function queryASN() {
currentApiIndex.value = 0
loading.value = true
error.value = ''
result.value = null
tmpIP.value = tmpIP.value.trim()
if (!tmpIP.value) {
error.value = '请输入IP地址'
loading.value = false
return
}
await nextTick()
executeASN()
}
onMounted(() => {
getUserIP()
const ipParam = route.query.ip as string
if (ipParam) {
tmpIP.value = ipParam
queryASN()
}
})
async function getUserIP() {
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
function getStatusClass(status: string): string {
if (status.includes('prohibited') || status.includes('lock')) return 'status-warning'
return 'status-success'
}
</script>
<template>
<div class="title">
<header>
<h1>ASN 自治系统查询</h1>
<p>查询 IP 地址所属的自治系统号(ASN)和组织信息</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpIP"
placeholder="请输入IP地址(如:1.1.1.1)"
/>
<el-button
@click="queryASN()"
type="primary"
:loading="loading"
>
查询
</el-button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="result" class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">项目</th>
<th class="table-header">信息</th>
</tr>
</thead>
<tbody>
<tr>
<td class="table-label">IP 地址</td>
<td class="table-value">{{ result.ip }}</td>
</tr>
<tr v-if="result.geolite2_asn?.asn">
<td class="table-label">Maxmind ASN</td>
<td class="table-value">
AS{{ result.geolite2_asn.asn }}
<span v-if="result.geolite2_asn.org" style="color: #999; font-size: 0.9em;">{{ result.geolite2_asn.org }}</span>
</td>
</tr>
<tr v-if="result.dbip_asn?.asn">
<td class="table-label">DB-IP ASN</td>
<td class="table-value">
AS{{ result.dbip_asn.asn }}
<span v-if="result.dbip_asn.org" style="color: #999; font-size: 0.9em;">{{ result.dbip_asn.org }}</span>
</td>
</tr>
<tr v-if="result.ip2location_asn?.asn">
<td class="table-label">IP2Location ASN</td>
<td class="table-value">
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>
<tr v-if="result.whois?.asNumber">
<td class="table-label">ASN</td>
<td class="table-value">AS{{ result.whois.asNumber }}</td>
</tr>
<tr v-if="result.whois?.asName">
<td class="table-label">AS 名称</td>
<td class="table-value">{{ result.whois.asName }}</td>
</tr>
<tr v-if="result.whois?.orgName">
<td class="table-label">组织名称</td>
<td class="table-value">{{ result.whois.orgName }}</td>
</tr>
<tr v-if="result.whois?.orgId">
<td class="table-label">组织 ID</td>
<td class="table-value">{{ result.whois.orgId }}</td>
</tr>
<tr v-if="result.whois?.country">
<td class="table-label">国家</td>
<td class="table-value">{{ result.whois.country }}</td>
</tr>
<tr v-if="result.whois?.regDate">
<td class="table-label">注册日期</td>
<td class="table-value">{{ result.whois.regDate }}</td>
</tr>
<tr v-if="result.whois?.updated">
<td class="table-label">更新日期</td>
<td class="table-value">{{ result.whois.updated }}</td>
</tr>
<tr v-if="result.whois?.abuseEmail">
<td class="table-label">Abuse 邮箱</td>
<td class="table-value">{{ result.whois.abuseEmail }}</td>
</tr>
<tr v-if="result.whois?.abusePhone">
<td class="table-label">Abuse 电话</td>
<td class="table-value">{{ result.whois.abusePhone }}</td>
</tr>
</tbody>
</table>
</div>
<blockquote>
ASN(Autonomous System Number)是互联网中每个自治系统的唯一标识符。<br/>
数据来源:Maxmind GEOLite2、DB-IP,部分节点集成 WHOIS 进一步解析 ASN 详情。<br/>
<a href="/location" target="_blank">IP 归属地查询</a> | <a href="/whois" target="_blank">Whois 查询</a><br/>
访客IP: {{ userIP || '获取中...' }},您的网络{{ isIPv6(userIP) ? 'IPv6' : 'IPv4' }}优先<br/>
</blockquote>
</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
.result-table .table-label {
width: 200px;
}
.error-text {
color: #F56C6C;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
+306
View File
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { isIPv6 } from 'is-ip';
import { formatTime } from '../../utils/tools';
const route = useRoute()
useHead({
title: `DNS查询工具 | 多节点域名解析检测 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的多节点DNS查询工具,支持A记录、AAAA记录、CNAME记录、MX记录、NS记录、TXT记录、SRV记录、CAA记录等多种DNS解析记录查询,提供全国多节点并发检测,快速返回DNS解析结果,助力域名解析问题排查与优化' },
{ name: 'keywords', content: 'dns查询,dns解析,域名解析,a记录查询,aaaa记录,cname记录,mx记录,ns记录,txt记录,srv记录,dns服务器,域名dns检测' },
{ property: 'og:title', content: 'DNS查询工具 - 多节点域名解析记录检测' },
{ property: 'og:description', content: '多节点DNS查询工具,支持多种DNS记录类型查询,快速检测域名解析状态' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'DNS查询与解析检测工具',
description: '专业的多节点DNS查询工具,支持A、AAAA、CNAME、MX、NS、TXT、SRV、CAA等多种记录类型,全国多节点并发检测。',
url: config.siteUrl + 'dns',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
const tmpDomain = ref('www.zakoflare.com')
const domain = ref('')
const recordType = ref('a')
const loading = ref(false)
const results = ref<any>([])
const isloading = ref(false)
const nowRecordType = ref('')
const userIP = ref('')
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
async function getUserIP(){
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
const recordTypes = [
{ value: 'a', label: 'A 记录' },
{ value: 'aaaa', label: 'AAAA 记录' },
{ value: 'cname', label: 'CNAME 记录' },
{ value: 'mx', label: 'MX 记录' },
{ value: 'ns', label: 'NS 记录' },
{ value: 'txt', label: 'TXT 记录' },
{ value: 'srv', label: 'SRV 记录' },
{ value: 'caa', label: 'CAA 记录' },
{ value: 'ptr', label: 'PTR 记录' }
]
const dnsServerFetches = [
...config.APIBaseURL.DualStack,
...config.APIBaseURL.IPv4,
...config.APIBaseURL.IPv6
].map((server) => {
const url = computed(() => "/middleware/" + server.id + "/dns/" + recordType.value + "/" + encodeURIComponent(domain.value));
const { data, error, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error, execute };
});
async function queryDNS() {
isloading.value = true
domain.value = tmpDomain.value
await nextTick()
const id = ++queryId
// 初始化结果数组,保持响应式结构
results.value = dnsServerFetches.map(fetch => ({
server: fetch.label,
loading: true,
data: null,
error: null
}));
const promises = dnsServerFetches.map(async (fetch, index) => {
try {
await fetch.execute();
if (id !== queryId) return null
// execute() 不会抛错,全部候选失败时以 error.value 为准(否则故障节点渲染成空白)
if (fetch.error.value) {
results.value[index] = {
server: fetch.label,
loading: false,
data: null,
error: fetch.error.value
};
return { server: fetch.label, error: fetch.error.value };
}
// 直接更新对应索引的结果,保持响应式
results.value[index] = {
server: fetch.label,
loading: false,
data: fetch.data.value,
error: null
};
return {
server: fetch.label,
data: fetch.data.value
};
} catch (err) {
if (id !== queryId) return null
// 错误时也更新对应索引
results.value[index] = {
server: fetch.label,
loading: false,
data: null,
error: err
};
return {
server: fetch.label,
error: err
};
}
});
nowRecordType.value = recordType.value
const promiseResults = (await Promise.all(promises)).filter(Boolean)
if (id === queryId) {
isloading.value = false
}
return promiseResults
}
onMounted(() => {
const domainParam = route.query.domain as string
const typeParam = route.query.type as string
if (domainParam) {
tmpDomain.value = domainParam
}
if (typeParam && recordTypes.some(t => t.value === typeParam)) {
recordType.value = typeParam
}
if (domainParam) {
queryDNS()
}
getUserIP()
})
const { data: page, error } = await useAsyncData('dns-page', () =>
$fetch('/api/markdown/dns')
)
const doc = page.value;
</script>
<template>
<div class="title">
<header>
<h1>DNS查询</h1>
<p>多节点 DNS 查询,检测域名解析记录</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
/>
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select">
<el-option
v-for="item in recordTypes"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button
@click="queryDNS()"
type="primary"
:loading="isloading"
>
查询
</el-button>
</div>
<div class="result-section">
<table class="result-table" v-if="results.length > 0">
<thead>
<tr>
<th class="table-header">服务器</th>
<th class="table-header">类型</th>
<th class="table-header">记录</th>
<th class="table-header">记录数</th>
<th class="table-header">耗时</th>
<th class="table-header">TTL (S)</th>
</tr>
</thead>
<tbody>
<tr v-for="(result) in results" :key="result.server">
<td class="table-label">{{result.server}}</td>
<td class="table-value">{{nowRecordType.toUpperCase() || '--'}}</td>
<td class="table-value" v-if="result.loading" colspan="4" style="text-align: left;">加载中...</td>
<td class="table-value" v-else-if="result.error" colspan="4" style="color: #F56C6C;">
{{ result.error?.message || '请求失败' }}
</td>
<td class="table-value" v-else-if="!result.loading">
<template v-if="result && result.data?.record">
<div v-for="(ip, index) in result.data.record.slice(0, 5)" :key="index" class="ip-address">
<span>{{ ip }}</span>
</div>
</template>
<span v-else-if="!result.loading && result.data?.record?.length === 0" class="status-code" style="color: #F56C6C; background: #fef0f0;">
解析失败
</span>
</td>
<td class="table-value" v-if="!result.loading">{{result.data?.record?.length || 0}}</td>
<td class="table-value" v-if="!result.loading">{{formatTime(result.data?.duration)}}</td>
<td class="table-value" v-if="!result.loading">{{result.data?.ttl}}</td>
</tr>
</tbody>
</table>
</div>
<blockquote>
<span class="quate">A</span> 将域名指向一个 IPv4 地址,如 <span class="quate">106.55.75.123</span>。 A记录归属地是柠檬超绝大杂烩免费接口,随便用。<br/>
<span class="quate">AAAA</span> 将域名指向一个 IPv6 地址,如 <span class="quate">2402:4e00:1013:e500:0:9671:f018:4947</span>。同一个主机名可以同时解析到 IPv4(A记录)地址 和 IPv6(AAAA 记录)地址上,当只有IPv4 地址的用户会解析到 IPv4 地址,一般情况下有 IPv6 地址的用户会优先解析到 IPv6 地址。<br/>
<span class="quate">CNAME</span> 将域名指向另一个域名地址,与其保持相同解析,如 <span class="quate">ipw.wsmdn.top</span> 别名到 <span class="quate">ipw.wsmdn.top.eo.dnse1.com</span>.<br/>
<span class="quate">MX</span> 用于邮件服务器,一般由邮件注册商提供,如 <span class="quate">mxbiz1.qq.com</span>。如果邮箱格式为 test@<span class="quate">wsmdn.top</span> 则输入 <span class="quate">wsmdn.top</span> 查询。如果邮箱格式为 test@<span class="quate">mail.wsmdn.top</span>则输入<span class="quate">mail.wsmdn.top</span>查询。推荐2个免费的企业邮箱:腾讯企业邮、网易免费企业邮。<br/>
<span class="quate">TXT</span> 附加文本信息,常用于域名所有权验证,如在申请 HTTPS 证书时需要增加记录、<br/>
<span class="quate">PTR</span> IP 的反向解析记录,例如 <span class="quate">159.75.190.197</span> 反解析到 <span class="quate">wsmdn.top</span>,一般用于提升自建域名邮件服务器的可信度,可提单找云服务商添加。<br/>
<span class="quate">NS</span> 域名的 DNS 服务器地址,例如 <span class="quate">ns3.dnsv2.com</span>,推荐 华为云DNS.<br/>
<a href="/ipv6webcheck" target="_blank">网站开启IPv6检测</a> | <a href="/ssl" target="_blank">SSL证书在线检查</a><br/>
访客IP: {{userIP }},您的网络 {{ isIPv6(userIP) ? 'IPv6' : 'IPv4'}} 访问优先<br/>
</blockquote>
<div class="markdown">
<div v-html="doc"></div>
</div>
</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
.markdown :deep(a) {
color: #3EAF7C !important;
font-size: 1.3em;
text-decoration: none
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon {
font-size: 1.3em;
}
</style>
+295
View File
@@ -0,0 +1,295 @@
<script setup lang="ts">
import { useMiddlewareFetch } from '../../utils/middleware';
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index'
import { isIPv6 } from 'is-ip'
const route = useRoute()
useHead({
title: `DNSSEC查询 | 域名DNSSEC验证检测 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的DNSSEC查询工具,支持域名DNSSEC验证检测,检查域名是否启用DNSSEC签名,验证DNSKEY、RRSIG、DS记录链式信任关系,检测域名DNS数据完整性和真实性,防止DNS欺骗和中间人攻击,保障域名解析安全' },
{ name: 'keywords', content: 'dnssec查询,dnssec验证,dnskey,rrsig,ds记录,域名安全,链式信任,dns欺骗防护,域名解析安全,dns签名验证' },
{ property: 'og:title', content: 'DNSSEC查询工具 - 域名DNSSEC验证与安全检测' },
{ property: 'og:description', content: 'DNSSEC验证检测工具,检查域名DNSSEC签名状态,验证DNSKEY、RRSIG、DS记录链式信任关系' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'DNSSEC验证查询工具',
description: '专业的DNSSEC验证查询工具,检测域名DNSSEC签名状态,验证DNSKEY、RRSIG、DS记录链式信任关系,保障域名解析安全。',
url: config.siteUrl + 'dnssec',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
})
interface DNSSECResult {
domain: string
enabled: boolean
valid: boolean
has_rrsig: boolean
has_dnskey: boolean
has_ds: boolean
algorithm: number
key_tag: number
signer_name: string
validation: string
duration: number
}
interface ServerResult {
label: string
loading: boolean
error?: string
data?: DNSSECResult
}
const tmpDomain = ref('www.zakoflare.com')
const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
const userIP = ref('')
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
const apiList = [
...config.APIBaseURL.DualStack,
...config.APIBaseURL.IPv4,
...config.APIBaseURL.IPv6
]
const dnssecFetches = apiList.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/dnssec/" + encodeURIComponent(tmpDomain.value.trim()))
const { data, error, execute } = useMiddlewareFetch<DNSSECResult>(url, {
immediate: false,
watch: false,
})
return { label: server.label, data, error, execute }
})
function initServerResults() {
serverResults.value = apiList.map((server) => ({
label: server.label,
loading: false,
}))
}
async function checkDNSSEC() {
const domain = tmpDomain.value.trim()
if (!domain) return
loading.value = true
const id = ++queryId
serverResults.value.forEach((result) => {
result.loading = true
result.data = undefined
})
const promises = dnssecFetches.map(async (fetch, index) => {
try {
await fetch.execute()
if (id !== queryId) return
const result = serverResults.value[index]
if (result) {
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
if (fetch.error.value) {
result.error = (fetch.error.value as any)?.message || '请求失败'
} else {
result.data = fetch.data.value
}
}
} catch (err) {
console.error(err)
const result = serverResults.value[index]
if (result) {
result.error = (err as any)?.message || '请求失败'
}
} finally {
const result = serverResults.value[index]
if (result && id === queryId) {
result.loading = false
}
}
})
Promise.allSettled(promises).finally(function () {
if (id === queryId) {
loading.value = false
}
})
}
function getValidationClass(result: DNSSECResult): string {
if (result.valid) return 'status-success'
if (result.has_dnskey || result.has_rrsig) return 'status-warning'
return 'status-error'
}
function formatDuration(ms?: number): string {
return typeof ms === 'number' && isFinite(ms) ? ms.toFixed(2) + ' ms' : '-'
}
onMounted(() => {
initServerResults()
getUserIP()
const domainParam = route.query.domain as string
if (domainParam) {
tmpDomain.value = domainParam
checkDNSSEC()
}
})
async function getUserIP() {
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
</script>
<template>
<div class="title">
<header>
<h1>DNSSEC 验证查询</h1>
<p>检测域名 DNSSEC 签名状态和链式信任验证</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
/>
<el-button
@click="checkDNSSEC()"
type="primary"
:loading="loading"
>
验证
</el-button>
</div>
<div class="result-section" v-if="serverResults.some(r => r.data)">
<table class="result-table">
<thead>
<tr>
<th class="table-header">DNS 服务器</th>
<th class="table-header">DNSSEC</th>
<th class="table-header">DNSKEY</th>
<th class="table-header">RRSIG</th>
<th class="table-header">DS</th>
<th class="table-header">验证结果</th>
<th class="table-header">耗时</th>
</tr>
</thead>
<tbody>
<template v-for="(server, index) in serverResults" :key="index">
<tr v-if="server.data">
<td class="table-label">{{ server.label }}</td>
<td class="table-value">
<span :class="server.data.enabled ? 'status-success' : 'status-error'" class="status-code">
{{ server.data.enabled ? '已启用' : '未启用' }}
</span>
</td>
<td class="table-value">
<span :class="server.data.has_dnskey ? 'status-success' : 'status-error'" class="status-code">
{{ server.data.has_dnskey ? '有' : '无' }}
</span>
</td>
<td class="table-value">
<span :class="server.data.has_rrsig ? 'status-success' : 'status-error'" class="status-code">
{{ server.data.has_rrsig ? '有' : '无' }}
</span>
</td>
<td class="table-value">
<span :class="server.data.has_ds ? 'status-success' : 'status-error'" class="status-code">
{{ server.data.has_ds ? '有' : '无' }}
</span>
</td>
<td class="table-value">
<span :class="getValidationClass(server.data)" class="status-code">
{{ server.data.valid ? '验证通过' : '验证失败' }}
</span>
</td>
<td class="table-value">{{ formatDuration(server.data.duration) }}</td>
</tr>
<tr v-else-if="server.loading">
<td class="table-label">{{ server.label }}</td>
<td class="table-value" colspan="6">加载中...</td>
</tr>
<tr v-else-if="server.error">
<td class="table-label">{{ server.label }}</td>
<td class="table-value error-text" colspan="6">{{ server.error }}</td>
</tr>
<tr v-else-if="!server.data">
<td class="table-label">{{ server.label }}</td>
<td class="table-value" colspan="6">-</td>
</tr>
</template>
</tbody>
</table>
</div>
<blockquote>
DNSSEC 原理介绍即将上线<br/>
DNSSEC 通过 DNSKEY、RRSIG、DS 记录为 DNS 数据提供完整性验证,防止 DNS 欺骗攻击。<br/>
如果显示 "验证失败",可能原因:域名未配置 DNSSEC、DNSKEY 密钥不匹配、DS 记录缺失等。<br/>
<a href="/dns" target="_blank">DNS 解析查询</a> | <a href="/whois" target="_blank">Whois 查询</a><br/>
访客IP: {{ userIP || '获取中...' }},您的网络{{ isIPv6(userIP) ? 'IPv6' : 'IPv4' }}优先<br/>
</blockquote>
</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
.result-table .table-label {
width: 180px;
}
.error-text {
color: #F56C6C;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { getDocMeta } from '../../../config/doc'
const route = useRoute();
const path = computed(() => route.path)
const { data: page } = await useFetch(() => `/api/markdown${path.value}`)
const activeIndex = computed(() => route.path)
const meta = computed(() => getDocMeta(route.path))
useHead({
title: computed(() => meta.value.title),
meta: [
{ name: 'description', content: computed(() => meta.value.description) }
]
})
</script>
<template>
<div class="box">
<el-menu
:default-active="activeIndex"
class="sidebar-menu"
router
>
<!-- 1. 独立菜单项 -->
<el-menu-item index="/doc">
<span>IPv6 工具箱使用文档</span>
</el-menu-item>
<!-- 2. IPv6 用户端 -->
<el-sub-menu index="IPv6 用户端">
<template #title>
<span>IPv6 用户端</span>
</template>
<el-menu-item index="/doc/user/enable_ipv6">个人宽带如何开启IPv6网络访问</el-menu-item>
<el-menu-item index="/doc/user/cmd_bash_disable_ipv6">命令行禁用/启用IPv6本地网络</el-menu-item>
<el-menu-item index="/doc/user/cmd_getip">命令行(curl)获取 IPv4 和 IPv6 地址</el-menu-item>
<el-menu-item index="/doc/user/view_ipv6_adress_url">浏览器访问 IPv6 地址</el-menu-item>
<el-menu-item index="/doc/user/ipv4_ipv6_prefix_precedence">Windows 10/11 设置 IPv4/IPv6 访问优先级</el-menu-item>
<el-menu-item index="/doc/user/ipv6_daohang">国内 IPv6 资源导航</el-menu-item>
<el-menu-item index="/doc/user/pure_ipv6_website">国内纯 IPv6 网站导航</el-menu-item>
<el-menu-item index="/doc/user/dns">全国各省 DNS 服务器列表</el-menu-item>
<el-menu-item index="/doc/user/ipv6_dns">IPv6 DNS 地址列表</el-menu-item>
<!-- 嵌套子菜单:最佳实践 -->
<el-sub-menu index="最佳实践">
<template #title>
<span>最佳实践</span>
</template>
<el-menu-item index="/doc/user/AliyunAuthorizeSecurityGroup">阿里云自动化添加安全组</el-menu-item>
<el-menu-item index="/doc/user/TencentCloudAddSecurityGroup">腾讯云自动化添加安全组</el-menu-item>
</el-sub-menu>
</el-sub-menu>
<!-- 3. 云服务器配置 IPv6 -->
<el-sub-menu index="云服务器配置 IPv6">
<template #title>
<span>云服务器配置 IPv6</span>
</template>
<el-menu-item index="/doc/server/website_enable_ipv6">网站开启 IPv6 的三种方式</el-menu-item>
<el-menu-item index="/doc/server/tencent_cloud_cvm_ipv6">腾讯云 cvm 开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/nginx_ipv6">Nginx 开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/ipv6webcheck">如何确认一个网站是否开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/ipv6_sign">网站增加支持IPv6访问标识</el-menu-item>
<el-menu-item index="/doc/server/ipv6_domain_record">如何为域名添加 IPv6 解析记录</el-menu-item>
<el-menu-item index="/doc/server/http2">网站如何开启 HTTP/2</el-menu-item>
</el-sub-menu>
<!-- 4. IPv6 协议规范 -->
<el-sub-menu index="IPv6 协议规范">
<template #title>
<span>IPv6 协议规范</span>
</template>
<el-menu-item index="/doc/rfc/rfc8200">IPv6 RFC8200 解读</el-menu-item>
<el-menu-item index="/doc/rfc/ipv6_address_format">IPv6 地址标识方法</el-menu-item>
<el-menu-item index="/doc/user/tcpdump_ipv6">tcpdump 分析IPv6包</el-menu-item>
<el-menu-item index="/doc/user/wireshark_ipv6">WireShark 分析IPv6包头</el-menu-item>
<el-menu-item index="/doc/user/ipv6_ping">IPv6 Ping 原理</el-menu-item>
</el-sub-menu>
</el-menu>
<div class="content">
<div class="markdown-body" v-if="page">
<div v-html="page" />
</div>
</div>
</div>
</template>
<style scoped>
/* 核心布局容器 */
.box {
display: flex;
flex-direction: row; /* 改为 row,实现左右排列 */
height: 100vh; /* 占满整个屏幕高度,防止出现页面级滚动条 */
width: 100%;
background-color: #fff; /* 可选:设置整体背景色 */
}
html.dark .box {
background-color: #242424; /* 可选:暗黑模式下的整体背景色 */
}
/* 左侧侧边栏样式 */
.sidebar-menu {
width: 260px; /* 固定侧边栏宽度 */
height: 100%; /* 高度撑满父容器 */
flex-shrink: 0; /* 防止被右侧内容挤压变形 */
overflow-y: auto; /* 菜单项过多时,侧边栏内部出现滚动条 */
border-right: 1px solid #e4e7ed; /* 添加右侧分割线(可选) */
}
.content {
flex: 1; /* 右侧内容区域占据剩余空间 */
padding: 20px; /* 内边距,避免内容贴边 */
overflow-y: auto; /* 内容过多时,右侧区域出现滚动条 */
background-color: #fff; /* 可选:设置右侧内容区域背景色 */
}
html.dark .content {
background-color: #242424; /* 可选:暗黑模式下的背景色 */
}
@media (max-width: 768px) {
.sidebar-menu {
width: 100px;
}
.content {
padding: 10px;
}
}
</style>
<style>
@import "github-markdown-css/github-markdown-light.css";
@import "../../github-markdown-dark.css";
</style>
+134
View File
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { getDocMeta } from '../../../config/doc'
const route = useRoute();
const { data: page } = await useAsyncData('doc-index', () =>
$fetch('/api/markdown/doc/index')
)
const doc = page.value;
const activeIndex = computed(() => route.path)
const meta = getDocMeta('/doc')
useHead({
title: meta.title,
meta: [
{ name: 'description', content: meta.description }
]
})
</script>
<template>
<div class="box">
<el-menu
:default-active="activeIndex"
class="sidebar-menu"
router
>
<!-- 1. 独立菜单项 -->
<el-menu-item index="/doc">
<span>IPv6 工具箱使用文档</span>
</el-menu-item>
<!-- 2. IPv6 用户端 -->
<el-sub-menu index="IPv6 用户端">
<template #title>
<span>IPv6 用户端</span>
</template>
<el-menu-item index="/doc/user/enable_ipv6">个人宽带如何开启IPv6网络访问</el-menu-item>
<el-menu-item index="/doc/user/cmd_bash_disable_ipv6">命令行禁用/启用IPv6本地网络</el-menu-item>
<el-menu-item index="/doc/user/cmd_getip">命令行(curl)获取 IPv4 和 IPv6 地址</el-menu-item>
<el-menu-item index="/doc/user/view_ipv6_adress_url">浏览器访问 IPv6 地址</el-menu-item>
<el-menu-item index="/doc/user/ipv4_ipv6_prefix_precedence">Windows 10/11 设置 IPv4/IPv6 访问优先级</el-menu-item>
<el-menu-item index="/doc/user/ipv6_daohang">国内 IPv6 资源导航</el-menu-item>
<el-menu-item index="/doc/user/pure_ipv6_website">国内纯 IPv6 网站导航</el-menu-item>
<el-menu-item index="/doc/user/dns">全国各省 DNS 服务器列表</el-menu-item>
<el-menu-item index="/doc/user/ipv6_dns">IPv6 DNS 地址列表</el-menu-item>
<!-- 嵌套子菜单:最佳实践 -->
<el-sub-menu index="最佳实践">
<template #title>
<span>最佳实践</span>
</template>
<el-menu-item index="/doc/user/AliyunAuthorizeSecurityGroup">阿里云自动化添加安全组</el-menu-item>
<el-menu-item index="/doc/user/TencentCloudAddSecurityGroup">腾讯云自动化添加安全组</el-menu-item>
</el-sub-menu>
</el-sub-menu>
<!-- 3. 云服务器配置 IPv6 -->
<el-sub-menu index="云服务器配置 IPv6">
<template #title>
<span>云服务器配置 IPv6</span>
</template>
<el-menu-item index="/doc/server/website_enable_ipv6">网站开启 IPv6 的三种方式</el-menu-item>
<el-menu-item index="/doc/server/tencent_cloud_cvm_ipv6">腾讯云 cvm 开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/nginx_ipv6">Nginx 开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/ipv6webcheck">如何确认一个网站是否开启 IPv6</el-menu-item>
<el-menu-item index="/doc/server/ipv6_sign">网站增加支持IPv6访问标识</el-menu-item>
<el-menu-item index="/doc/server/ipv6_domain_record">如何为域名添加 IPv6 解析记录</el-menu-item>
<el-menu-item index="/doc/server/http2">网站如何开启 HTTP/2</el-menu-item>
</el-sub-menu>
<!-- 4. IPv6 协议规范 -->
<el-sub-menu index="IPv6 协议规范">
<template #title>
<span>IPv6 协议规范</span>
</template>
<el-menu-item index="/doc/rfc/rfc8200">IPv6 RFC8200 解读</el-menu-item>
<el-menu-item index="/doc/rfc/ipv6_address_format">IPv6 地址标识方法</el-menu-item>
<el-menu-item index="/doc/user/tcpdump_ipv6">tcpdump 分析IPv6包</el-menu-item>
<el-menu-item index="/doc/user/wireshark_ipv6">WireShark 分析IPv6包头</el-menu-item>
<el-menu-item index="/doc/user/ipv6_ping">IPv6 Ping 原理</el-menu-item>
</el-sub-menu>
</el-menu>
<div class="content">
<div class="markdown-body" v-html="doc"></div>
</div>
</div>
</template>
<style scoped>
/* 核心布局容器 */
.box {
display: flex;
flex-direction: row; /* 改为 row,实现左右排列 */
height: 100vh; /* 占满整个屏幕高度,防止出现页面级滚动条 */
width: 100%;
background-color: #fff; /* 可选:设置整体背景色 */
}
html.dark .box {
background-color: #242424; /* 可选:暗黑模式下的整体背景色 */
}
/* 左侧侧边栏样式 */
.sidebar-menu {
width: 260px; /* 固定侧边栏宽度 */
height: 100%; /* 高度撑满父容器 */
flex-shrink: 0; /* 防止被右侧内容挤压变形 */
overflow-y: auto; /* 菜单项过多时,侧边栏内部出现滚动条 */
border-right: 1px solid #e4e7ed; /* 添加右侧分割线(可选) */
}
.content {
flex: 1; /* 右侧内容区域占据剩余空间 */
padding: 20px; /* 内边距,避免内容贴边 */
overflow-y: auto; /* 内容过多时,右侧区域出现滚动条 */
background-color: #fff; /* 可选:设置右侧内容区域背景色 */
}
html.dark .content {
background-color: #242424; /* 可选:暗黑模式下的背景色 */
}
@media (max-width: 768px) {
.sidebar-menu {
width: 100px;
}
.content {
padding: 10px;
}
}
</style>
<style>
@import "github-markdown-css/github-markdown-light.css";
@import "../../github-markdown-dark.css";
@import "/style.css"
</style>
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
import { isIPv6 } from 'is-ip';
import { config } from '../../config/index';
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
import { highlightCode } from '../../utils/shiki';
import { isIPv4 } from '../../utils/tools';
const route = useRoute();
const canonicalUrl = computed(() => new URL(route.path, config.siteUrl).toString());
useHead({
title: `${config.siteName} | IP查询工具 | IPv4/IPv6地址查询与网络测试平台`,
titleTemplate: '%s',
link: [
{ rel: 'canonical', href: canonicalUrl.value }
],
meta: [
{ name: 'description', content: `${config.siteName}提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台` },
{ name: 'keywords', content: 'ipv6,ipv4,ip查询,ipv6查询,ipv4查询,ipv6地址查询,ipv4地址查询,网络测速,DNS查询,SSL检测,TCPing,IP归属地,IPv6优先' },
{ property: 'og:title', content: `${config.siteName} - 专业IP查询与网络测试工具平台` },
{ property: 'og:description', content: '提供IPv4/IPv6地址查询、网络测速、DNS解析、SSL检测等全方位网络诊断工具,助力IPv6普及与部署' },
{ property: 'og:image', content: `${config.siteUrl}favicon.svg` },
{ property: 'og:type', content: 'website' },
{ property: 'og:url', content: canonicalUrl.value },
{ name: 'twitter:card', content: 'summary_large_image' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: config.siteName,
description: `${config.siteName}提供专业的IP查询服务,支持IPv4和IPv6地址在线查询、归属地定位、网络测速、DNS解析、SSL证书检测、TCPing测试等多种网络工具,致力于推进IPv6规模部署和应用,打造去中心化的IP查询平台`,
url: canonicalUrl.value,
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Any',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
featureList: 'IPv4地址查询,IPv6地址查询,IP归属地定位,网络测速,DNS解析,SSL证书检测,TCPing测试,IPv6优先检测',
about: [
{
'@type': 'Thing',
name: 'IP地址查询',
description: '通过特定的IP地址获取相关的地理位置、运营商、网络类型等信息的技术服务。IPv4地址是32位地址格式,IPv6地址是128位地址格式,IPv6能够提供更大的地址空间,解决IPv4地址枯竭问题。'
},
{
'@type': 'Thing',
name: 'IPv6优先检测',
description: '当访问一个同时支持IPv4和IPv6的双栈网站时,如果网络IPv6优先,系统会优先使用IPv6地址进行连接。通过访问双栈测试域名来判断网络优先级。'
},
{
'@type': 'Thing',
name: 'IPv6部署',
description: 'IPv6是全球下一代互联网协议标准,相比IPv4具有更大的地址空间、更好的安全性、更高的网络效率。国家正在大力推进IPv6规模部署和应用,以适应未来互联网发展需求。'
}
]
})
}
]
});
const code = `
# 请勿用于商业用途,仅供个人测试学习之用,请遵守中国法律法规
# 查询本机外网 IPv4 地址
curl ${config.v4OnlyAPI}
# 查询本机外网 IPv6 地址
curl ${config.v6OnlyAPI}
# 测试网络是 IPv4 还是 IPv6 访问优先
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
curl ${config.DualStackAPI}
`.trim(); // 关键:去掉首尾多余的空行
const highlightedCode = ref('');
const ipAddress = ref('');
const yourIPv4 = ref('');
const yourIPv6 = ref('');
onMounted(async () => {
try {
highlightedCode.value = await highlightCode(code, 'bash');
} catch {
highlightedCode.value = '';
}
const [dualStack, ipV4, ipV6] = await Promise.allSettled([
$fetch<string>(config.DualStackAPI),
$fetch<string>(config.v4OnlyAPI),
$fetch<string>(config.v6OnlyAPI)
]);
if (dualStack.status === 'fulfilled') {
ipAddress.value = dualStack.value;
}
if (ipV4.status === 'fulfilled') {
yourIPv4.value = ipV4.value;
}
if (ipV6.status === 'fulfilled') {
yourIPv6.value = ipV6.value;
}
});
</script>
<template>
<div class="title">
<header>
<h1>IP查询</h1>
<p>致力于IP查询去中心化,推进 IPv6 规模部署和应用</p>
</header>
</div>
<div class="content">
<div class="one-line">
<b>IPv4</b>&nbsp<p>{{ yourIPv4 }} </p>&nbsp<RouterLink :to="`/location?ip=${yourIPv4}`" target="_blank">查询归属地</RouterLink>
</div>
<div class="one-line">
<b>IPv6</b>&nbsp<p v-if="yourIPv6">{{ yourIPv6 }}</p><RouterLink :to="`/location?ip=${yourIPv6}`" target="_blank" v-if="yourIPv6">&nbsp查询归属地</RouterLink><RouterLink v-else to="/doc/user/enable_ipv6" target="_blank">没有IPv6地址,查看如何开启IPv6</RouterLink>
</div>
<div style="font-size: 1.5em;">
<h3 v-if="ipAddress && isIPv6(ipAddress)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
<h3 v-else-if="ipAddress && isIPv4(ipAddress)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
<h3 v-else><el-icon><CircleCloseFilled /></el-icon>查询中,请稍后</h3>
</div>
<blockquote>
手机默认开启 IPv6,宽带开启 IPv6 请参阅<a href="/doc/user/enable_ipv6" target="_blank">文档</a>
</blockquote>
<div v-if="highlightedCode" v-html="highlightedCode" class="code-block"></div>
<div v-else class="code-block code-block--fallback">{{ code }}</div>
</div>
</template>
<style scoped>
@import "../style.css";
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
.code-block {
margin-top: 1rem;
padding: 1rem;
border-radius: 0.75rem;
overflow-x: auto;
max-width: 100%;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.code-block--fallback {
background: rgb(48, 46, 46);
border: 1px solid rgba(62, 175, 124, 0.18);
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Monaco', 'Courier New', monospace !important;
color: rgb(255, 255, 255);
}
@media (max-width: 768px) {
.code-block {
padding: 0.75rem;
font-size: 0.8em;
}
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
onMounted(async () => {
const urlParam = route.query.ip;
// ip 参数缺失时不要拼出 ?ip=undefined
await navigateTo(urlParam ? { path: '/location', query: { ip: urlParam } } : { path: '/location' });
});
</script>
<template>
<div class="redirecting">正在跳转到 IP 归属地查询…</div>
</template>
<style scoped>
.redirecting {
display: flex;
align-items: center;
justify-content: center;
min-height: 50vh;
color: #999;
}
</style>
+283
View File
@@ -0,0 +1,283 @@
<script setup lang="ts">
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';
const route = useRoute()
useHead({
title: `IPv6网站测速工具 | 全国多节点并发测速 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv6网站测速工具,提供全国多节点并发测速服务,1秒内快速返回网站响应时间、下载速度、页面大小、DNS解析时间、HTTP连接时间等详细信息,支持IPv6网站性能检测与优化,助力IPv6网站性能监控与用户体验提升' },
{ name: 'keywords', content: 'ipv6网站测速,ipv6测速,网站速度测试,ipv6性能检测,网站响应时间,ipv6下载速度,ipv6性能优化,ipv6网站监控' },
{ property: 'og:title', content: 'IPv6网站测速 - 全国多节点并发性能检测工具' },
{ property: 'og:description', content: '全国多节点IPv6网站测速,快速获取响应时间、下载速度等性能数据' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'IPv6网站性能测速工具',
description: '专业的IPv6网站测速工具,全国多节点并发测速,提供响应时间、下载速度、页面大小、DNS解析时间等详细性能指标。',
url: config.siteUrl + 'ipv6speedtest',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
const tmpDomain = ref('https://www.zakoflare.com')
const loading = ref(false)
const result = ref<any[]>([])
const error = ref('')
const domain = ref('')
/** 服务器结果接口 */
interface ServerResult {
label: string
loading: boolean
error?: string | Error
data: any
}
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
const serverResults = ref<ServerResult[]>([])
// 提前构建服务器列表(学 tcping.vue 策略)
// 在组件初始化时就创建好 useFetch 实例,
// 设置 immediate: false + watch: false,
// 只在用户点击"开始测试"时才调用 execute() 发起请求,
// 避免页面加载时自动发请求浪费资源。
// =============================================
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error: fetchError, execute };
});
/** IPv6 专用服务器:仅支持 IPv6 的节点 */
const ipv6Fetches = config.APIBaseURL.IPv6.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v6/" + encodeURIComponent(domain.value));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error: fetchError, execute };
});
/** 合并所有服务器 fetch 列表 */
const allFetches = [
...dualStackFetches.map(fetch => ({ ...fetch, type: 'DualStack' })),
...ipv6Fetches.map(fetch => ({ ...fetch, type: 'IPv6' }))
];
/**
* 初始化服务器结果列表
* 页面挂载时调用,让用户提前看到所有测速节点
*/
function initServerResults() {
serverResults.value = allFetches.map((fetch) => ({
label: fetch.label,
loading: false,
error: '',
data: null
}))
}
/**
* 执行全站测速
* 并发调用所有服务器的 execute() 方法,逐个更新对应行的结果
*/
async function SpeedTest() {
domain.value = extractHost(tmpDomain.value)
loading.value = true
error.value = ''
await nextTick()
const id = ++queryId
// 重置所有行状态为加载中
serverResults.value.forEach((row) => {
row.loading = true
row.error = ''
row.data = null
})
// 并发请求所有服务器,每个请求完成后立即更新对应行
const promises = allFetches.map(async (fetch, index) => {
const row = serverResults.value[index]
if (!row) return
try {
await fetch.execute();
if (id !== queryId) return
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成空白)
if (fetch.error.value) {
row.error = fetch.error.value?.data?.error || fetch.error.value?.message || '请求失败';
} else {
row.data = fetch.data.value;
}
} catch (err: any) {
row.error = err?.data?.error || err?.message || '请求失败';
} finally {
if (id === queryId) {
row.loading = false;
}
}
});
Promise.all(promises).finally(() => {
if (id === queryId) {
loading.value = false
}
})
}
// 页面挂载后,初始化服务器列表并检查 URL 参数
onMounted(() => {
initServerResults()
const urlParam = route.query.site as string
if (urlParam) {
tmpDomain.value = urlParam
SpeedTest()
}
})
</script>
<template>
<div class="title">
<header>
<h1>IPv6 网站测速</h1>
<p>全国并发测速,1s 内快速返回测速结果</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:https://zakoflare.com)"
/>
<el-button
@click="SpeedTest()"
type="primary"
:loading="loading"
>
开始测试
</el-button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">测速服务器</th>
<th class="table-header">解析 IP</th>
<th class="table-header">HTTP状态码</th>
<th class="table-header">HTTPS状态码</th>
<th class="table-header">总时间</th>
<th class="table-header">解析时间</th>
<th class="table-header">HTTP连接</th>
<th class="table-header">网页大小</th>
<th class="table-header">下载速度</th>
</tr>
</thead>
<tbody>
<template v-for="(row, index) in serverResults" :key="index">
<!-- 请求失败 -->
<tr v-if="row.error">
<td class="table-label">{{ row.label }}</td>
<td class="table-value" colspan="8">
<span class="status-code status-error">{{ row.error }}</span>
</td>
</tr>
<!-- 加载中 -->
<tr v-else-if="row.loading">
<td class="table-label">{{ row.label }}</td>
<td class="table-value" colspan="8">测速中...</td>
</tr>
<!-- 有结果 -->
<tr v-else-if="row.data">
<td class="table-label">{{ row.label }}</td>
<td class="table-value">{{ row.data?.host_record }}</td>
<td class="table-value">
<span class="status-code" :class="getStatusCodeClass(row.data?.http_status_code)">
{{ row.data?.http_status_code }}
</span>
</td>
<td class="table-value">
<span class="status-code" :class="getStatusCodeClass(row.data?.https_status_code)">
{{ row.data?.https_status_code }}
</span>
</td>
<td class="table-value">{{ formatTime(row.data?.total_time) }}</td>
<td class="table-value">{{ formatTime(row.data?.dns_lookup_time) }}</td>
<td class="table-value">{{ formatTime(row.data?.first_byte_time) }}</td>
<td class="table-value">{{ formatSize(row.data?.page_size) }}</td>
<td class="table-value">{{ formatSpeed(row.data?.download_speed) }}</td>
</tr>
<!-- 等待测速 -->
<tr v-else>
<td class="table-label">{{ row.label }}</td>
<td class="table-value" colspan="8">-</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
.result-table .table-header {
font-size: 1.05em;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
+300
View File
@@ -0,0 +1,300 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
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';
const route = useRoute()
useHead({
title: `IPv6 TCPing测试工具 | IPv6服务器连通性检测 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv6 TCPing测试工具,提供多节点IPv6 TCP连通性检测服务,支持自定义端口测试,实时检测IPv6服务器丢包率、平均延迟、最大最小响应时间,助力IPv6网络质量诊断与优化,推进IPv6规模部署和应用' },
{ name: 'keywords', content: 'ipv6 tcping测试,ipv6连通性检测,ipv6服务器延迟,ipv6丢包率,ipv6端口测试,ipv6网络质量,ipv6服务器测试,ipv6网络诊断' },
{ property: 'og:title', content: 'IPv6 TCPing测试 - IPv6服务器连通性与延迟检测' },
{ property: 'og:description', content: '多节点IPv6 TCPing测试,检测服务器连通性、丢包率与响应延迟' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'IPv6 TCPing连通性测试工具',
description: '专业的IPv6 TCPing测试工具,多节点检测IPv6服务器连通性和延迟,支持自定义端口测试,提供丢包率、平均延迟、最大最小响应时间等数据。',
url: config.siteUrl + 'ipv6tcping',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
interface TCPingStats {
ip: string
port: string
sent: number
success: number
loss_rate: number
max_rtt: number
min_rtt: number
avg_rtt: number
}
interface TCPingResponse {
ipv4?: TCPingStats
ipv6?: TCPingStats
}
interface ServerConfig {
id: string
label: string
url: string
}
interface ServerResult {
label: string
loading: boolean
error: string
ipv4?: TCPingStats
ipv6?: TCPingStats
}
const tmpDomain = ref('www.zakoflare.com')
const port = ref('80')
const userIP = ref('')
const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
const allServers = [
...config.APIBaseURL.DualStack.map((s: ServerConfig) => ({ ...s, type: 'DualStack' })),
...config.APIBaseURL.IPv6.map((s: ServerConfig) => ({ ...s, type: 'IPv6' }))
];
const tcpingFetches = allServers.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/tcping/" + encodeURIComponent(extractHost(tmpDomain.value)) + "?port=" + encodeURIComponent(port.value));
const { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error: fetchError, execute };
});
function initServerResults() {
const results: ServerResult[] = []
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
error: ''
})
})
config.APIBaseURL.IPv6.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
error: ''
})
})
serverResults.value = results
}
function TCPingAll() {
const host = extractHost(tmpDomain.value)
if (!host) return
loading.value = true
const id = ++queryId
serverResults.value.forEach((result) => {
result.loading = true
result.error = ''
result.ipv4 = undefined
result.ipv6 = undefined
})
const promises = tcpingFetches.map(async (fetch, index) => {
try {
await fetch.execute();
if (id !== queryId) return
const result = serverResults.value[index];
if (result) {
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
if (fetch.error.value) {
result.error = (fetch.error.value as any)?.message || '请求失败';
} else {
result.ipv4 = fetch.data.value?.ipv4;
result.ipv6 = fetch.data.value?.ipv6;
}
}
} catch (err) {
console.error(err);
const result = serverResults.value[index];
if (result) {
result.error = '请求失败';
}
} finally {
const result = serverResults.value[index];
if (result && id === queryId) {
result.loading = false;
}
}
})
Promise.all(promises).finally(function () {
if (id === queryId) {
loading.value = false
}
})
}
async function getUserIP(){
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
onMounted(() => {
getUserIP()
initServerResults()
const urlParam = route.query.site as string
if (urlParam) {
tmpDomain.value = urlParam
TCPingAll()
}
})
</script>
<template>
<div class="title">
<header>
<h1>IPv6 TCPing 测试</h1>
<p>多节点 TCPing 测试,检测服务器连通性和延迟</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
/>
<el-input
v-model="port"
placeholder="端口号(默认 80)"
style="width: 200px;"
/>
<el-button
@click="TCPingAll()"
type="primary"
:loading="loading"
>
开始测试
</el-button>
</div>
<div class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">服务器</th>
<th class="table-header">解析 IP</th>
<th class="table-header">发送包</th>
<th class="table-header">接收包</th>
<th class="table-header">丢包率(%)</th>
<th class="table-header">最长时间(ms)</th>
<th class="table-header">最短时间(ms)</th>
<th class="table-header">平均时间(ms)</th>
</tr>
</thead>
<tbody>
<template v-for="(server, index) in serverResults" :key="index">
<tr v-if="server.ipv6">
<td class="table-label">{{ server.label }}</td>
<td class="table-value">{{ server.ipv6.ip || '-' }}</td>
<td class="table-value">{{ server.ipv6.sent }}</td>
<td class="table-value">{{ server.ipv6.success }}</td>
<td class="table-value">
<span :class="(server.ipv6?.loss_rate ?? 0) > 0 ? 'loss-warning' : 'loss-ok'">
{{ server.ipv6?.loss_rate?.toFixed(1) ?? '-' }}
</span>
</td>
<td class="table-value">{{ server.ipv6?.max_rtt?.toFixed(2) ?? '-' }}</td>
<td class="table-value">{{ server.ipv6?.min_rtt?.toFixed(2) ?? '-' }}</td>
<td class="table-value">{{ server.ipv6?.avg_rtt?.toFixed(2) ?? '-' }}</td>
</tr>
<tr v-else-if="server.loading">
<td class="table-label">{{ server.label }}</td>
<td class="table-value" colspan="7">加载中...</td>
</tr>
<tr v-else-if="server.error">
<td class="table-label">{{ server.label }}</td>
<td class="table-value error-text" colspan="7">{{ server.error }}</td>
</tr>
<tr v-else>
<td class="table-label">{{ server.label }}</td>
<td class="table-value" colspan="7">-</td>
</tr>
</template>
</tbody>
</table>
</div>
<blockquote>
<a href="/doc/user/ipv6_ping" target="_blank">IPv6 Ping 原理介绍</a><br/>
<strong>注意本页是TCPing,不是ICMPv6 Ping,下列文本仅供参考</strong><br/>
<strong>1. 本地 IPv6 方式</strong><br/>
Windows: ping -6 ipw.wsmdn.top<br/>
macOS 或 Linux: ping6 ipw.wsmdn.top<br/>
<strong>2. 服务器 IPv6 Ping 失败可能原因:</strong><br/>
服务器已开启 IPv6,但防火墙(又名安全组)未对源地址是 IPv6 地址(::/0)的 ICMPv6协议 开放访问,<br/>
服务器未开启 IPv6,请参考 <a href="/doc/server/website_enable_ipv6" target="_blank">服务器开启 IPv6</a><br/>
<a href="/tcping" target="_blank">IPv4 TCPing 测试</a> | <a href="/ipv6speedtest" target="_blank">IPv6 网站测速</a> | <a href="/ipv6webcheck">网站开启IPv6检测</a> | <a href="/dns">DNS解析查询</a> <br/>
访客IP: {{ userIP }},您的网络{{ isIPv6(userIP) ? 'IPv6' : 'IPv4'}}访问优先
</blockquote>
</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon {
font-size: 1.3em;
}
</style>
+316
View File
@@ -0,0 +1,316 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { CircleCheckFilled, CircleCloseFilled,InfoFilled,Position } from '@element-plus/icons-vue';
import { extractHost, getStatusCodeClass, formatTime, formatSize, formatSpeed } from '../../utils/tools';
const route = useRoute()
const siteUrlWithSlash = computed(() => config.siteUrl.replace(/\/$/, '') + '/')
useHead({
title: `IPv6网站检测工具 | IPv6访问支持检查 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv6网站检测工具,全面检查网站是否支持IPv6访问,提供IPv4和IPv6双栈HTTP/HTTPS状态码、DNS解析时间、TCP连接时间、下载速度等详细对比数据,帮助网站管理员确认IPv6部署状态,提供IPv6徽标认证,推进IPv6规模部署和应用' },
{ name: 'keywords', content: 'ipv6网站检测,ipv6访问检测,ipv6支持检查,ipv6双栈检测,ipv6 http检测,ipv6 https检测,ipv6网站认证,ipv6徽标,ipv6部署' },
{ property: 'og:title', content: 'IPv6网站检测 - IPv6访问支持状态检查与认证' },
{ property: 'og:description', content: '全面检测网站IPv6访问支持情况,提供详细性能对比与IPv6徽标认证' },
{ property: 'og:image', content: siteUrlWithSlash.value + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'IPv6网站访问支持检测',
description: '专业的IPv6网站检测工具,检查网站是否支持IPv6访问,提供IPv4和IPv6双栈HTTP/HTTPS状态码、DNS解析时间、TCP连接时间等详细对比数据,提供IPv6徽标认证。',
url: siteUrlWithSlash.value + 'ipv6webcheck',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
interface PerformanceCheckResponse {
ipv4?: PerformanceCheckItem
ipv6?: PerformanceCheckItem
}
interface PerformanceCheckItem {
host_record: string
http_status_code: number
https_status_code: number
dns_lookup_time: number
tcp_connect_time: number
http_connect_time: number
first_byte_time: number
total_time: number
page_size: number
download_speed: number
is_reachable: boolean
}
const apiList = config.APIBaseURL.DualStack
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const tmpDomain = ref('https://www.zakoflare.com')
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);
// testDomain 是完整 URL(含 https:// 与路径斜杠),服务端 [...slug].get.ts 依赖
// 分段重组协议部分,这里刻意不做 encodeURIComponent
const { data: checkData, error: checkError, execute: executeCheck } = useMiddlewareFetch<PerformanceCheckResponse>(checkUrl, {
immediate: false,
watch: false,
});
watch(checkData, (newData) => {
if (newData) {
result.value = newData;
loading.value = false;
error.value = '';
}
});
watch(checkError, async (newError) => {
if (newError) {
console.log(newError);
if (currentApiIndex.value < apiList.length - 1) {
const nextIndex = currentApiIndex.value + 1
error.value = `${(newError as any).message || '请求失败'},正在重试 ${apiList[nextIndex]?.label || ''}...`
currentApiIndex.value = nextIndex
console.log(currentApiIndex.value,nextIndex,apiList)
await nextTick()
executeCheck()
} else {
error.value = '请求失败,请检查域名或网络';
loading.value = false;
}
}
});
function checkWeb() {
testDomain.value = tmpDomain.value
currentApiIndex.value = 0
loading.value = true
error.value = ''
result.value = null
nextTick(() => {
executeCheck();
});
}
onMounted(() => {
const urlParam = route.query.site as string
if (urlParam) {
tmpDomain.value = urlParam
checkWeb()
}
})
</script>
<template>
<div class="title">
<header>
<h1>IPv6网站检测</h1>
<p>检查网站是否开启 IPv6 访问,致力于普及IPv6</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:https://zakoflare.com)"
/>
<el-button
@click="checkWeb()"
type="primary"
:loading="loading"
>
开始测试
</el-button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="result && result.ipv4" class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">检测项目</th>
<th class="table-header">IPv4</th>
<th class="table-header">IPv6</th>
</tr>
</thead>
<tbody>
<tr>
<td class="table-label">主机记录</td>
<td class="table-value"><div class="one-line"><RouterLink :to="`/ipv6?ip=${result.ipv4!.host_record}`">{{ result.ipv4!.host_record }}</RouterLink><el-icon><Position /></el-icon></div></td>
<td class="table-value"><div class="one-line"><RouterLink v-if="result.ipv6?.host_record" :to="`/ipv6?ip=${result.ipv6!.host_record}`">{{ result.ipv6!.host_record }}</RouterLink><span v-else>-</span><el-icon><Position /></el-icon></div></td>
</tr>
<tr>
<td class="table-label">HTTP 状态码</td>
<td class="table-value">
<span :class="getStatusCodeClass(result.ipv4!.http_status_code)" class="status-code">
{{ result.ipv4!.http_status_code }}
</span>
</td>
<td class="table-value">
<span v-if="result.ipv6" :class="getStatusCodeClass(result.ipv6.http_status_code)" class="status-code">
{{ result.ipv6.http_status_code }}
</span>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">HTTPS 状态码</td>
<td class="table-value">
<span :class="getStatusCodeClass(result.ipv4!.https_status_code)" class="status-code">
{{ result.ipv4!.https_status_code }}
</span>
</td>
<td class="table-value">
<span v-if="result.ipv6" :class="getStatusCodeClass(result.ipv6.https_status_code)" class="status-code">
{{ result.ipv6.https_status_code }}
</span>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">DNS 查询耗时</td>
<td class="table-value">{{ formatTime(result.ipv4!.dns_lookup_time) }}</td>
<td class="table-value">{{ result.ipv6?.dns_lookup_time ? formatTime(result.ipv6.dns_lookup_time) : '-' }}</td>
</tr>
<tr>
<td class="table-label">TCP 连接耗时</td>
<td class="table-value">{{ formatTime(result.ipv4!.tcp_connect_time) }}</td>
<td class="table-value">{{ result.ipv6?.tcp_connect_time ? formatTime(result.ipv6.tcp_connect_time) : '-' }}</td>
</tr>
<tr>
<td class="table-label">HTTP 连接耗时</td>
<td class="table-value">{{ formatTime(result.ipv4!.http_connect_time) }}</td>
<td class="table-value">{{ result.ipv6?.http_connect_time ? formatTime(result.ipv6.http_connect_time) : '-' }}</td>
</tr>
<tr>
<td class="table-label">首字节耗时</td>
<td class="table-value">{{ formatTime(result.ipv4!.first_byte_time) }}</td>
<td class="table-value">{{ result.ipv6?.first_byte_time ? formatTime(result.ipv6.first_byte_time) : '-' }}</td>
</tr>
<tr>
<td class="table-label">总耗时</td>
<td class="table-value">{{ formatTime(result.ipv4!.total_time) }}</td>
<td class="table-value">{{ result.ipv6?.total_time ? formatTime(result.ipv6.total_time) : '-' }}</td>
</tr>
<tr>
<td class="table-label">页面大小</td>
<td class="table-value">{{ formatSize(result.ipv4!.page_size) }}</td>
<td class="table-value">{{ result.ipv6?.page_size ? formatSize(result.ipv6.page_size) : '-' }}</td>
</tr>
<tr>
<td class="table-label">下载速度</td>
<td class="table-value">{{ formatSpeed(result.ipv4!.download_speed) }}</td>
<td class="table-value">{{ result.ipv6?.download_speed ? formatSpeed(result.ipv6.download_speed) : '-' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="result && result.ipv4 && result.ipv6&& result.ipv4.is_reachable && result.ipv6.is_reachable">
<h3>结论:<el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>网站{{ extractHost(testDomain) }} 支持IPv6访问 </h3>
<p><el-icon><InfoFilled style="color: lightgreen;"/></el-icon>请把下方代码贴到网站底部,把这个好消息告诉你的用户,以便用户核验。</p>
<img src="/ipv6-s1.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s1.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ipv6-s2.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s2.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ipv6-s3.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s3.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ipv6-s4.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s4.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ipv6-s5.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s5.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ipv6-s6.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-s6.svg"&gt;&lt;/a&gt;</code></pre>
<div class="one-line">
<img src="/ipv6-certified-lite-s1.svg"/>
<img src="/ipv6-certified-lite-s2.svg"/>
<img src="/ipv6-certified-lite-s3.svg"/>
<img src="/ipv6-certified-lite-s4.svg"/>
<img src="/ipv6-certified-lite-s5.svg"/>
<img src="/ipv6-certified-lite-s6.svg"/>
</div>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-certified-lite-s1.svg"&gt;&lt;/a&gt;</code></pre>
<div class="one-line">
<img src="/ipv6-certified-s1.svg"/>
<img src="/ipv6-certified-s2.svg"/>
<img src="/ipv6-certified-s3.svg"/>
<img src="/ipv6-certified-s4.svg"/>
<img src="/ipv6-certified-s5.svg"/>
<img src="/ipv6-certified-s6.svg"/>
</div>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ipv6webcheck/?site={{ extractHost(testDomain) }}" title="本站支持 IPv6访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 IPv6访问" src="{{ siteUrlWithSlash }}ipv6-certified-s1.svg"&gt;&lt;/a&gt;</code></pre>
<p>提示:修改IPv6徽标文件名,可修改对应样式</p>
</div>
<div v-else-if="result && result.ipv4 && result.ipv6 && result.ipv4.is_reachable && !result.ipv6.is_reachable">
<h3>结论:<CircleCloseFilled style="width: 1.3em;color: red;"/>网站{{ extractHost(testDomain) }} 不支持IPv6访问 </h3>
<h2>国家正在支持IPv6发展,我建议你赶紧想办法给IPv6适配</h2>
<el-image src="/jingya.jpg"></el-image>
</div>
<div v-else-if="result && (!result.ipv6?.is_reachable && !result.ipv4?.is_reachable)">
<h3>结论:<CircleCloseFilled style="width: 1.3em;color: red;"/>网站{{ extractHost(testDomain) }} 不可达 </h3>
<h2>...</h2>
<el-image src="/jingya.jpg"></el-image>
</div>
<div v-else-if="result && result.ipv4 && result.ipv6 && !result.ipv4.is_reachable && result.ipv6.is_reachable">
<h3>结论:<CircleCloseFilled style="width: 1.3em;color: red;"/>网站{{ extractHost(testDomain) }} 不支持IPv4访问 </h3>
<h2>全球来看,越南、捷克等部分国家已提出了单栈发展目标。尽管我国网络IPv6流量占比大幅提升,但网络中仍存在大量的NAT(网络地址转换)设备和IPv4依赖应用。</h2>
<h2 style="text-align: right;">----中国《IPv6发展报告》2026</h2>
<el-image src="/jingya.jpg"></el-image>
</div>
<blockquote>
网站不支持 IPv6可能原因:<br/>
<br/>
1. 网站所在服务器未开启 IPv6,请参考 <a href="/doc/server/website_enable_ipv6" target="_blank">网站开启 IPv6 的三种方式</a><br/>
2. 网站所在服务器已开启 IPv6,但防火墙未对源地址是 IPv6 地址(::/0)的 443(HTTPS)端口开放访问<br/>
3. 网站所在服务器已开启 IPv6,但未开启SSL证书,请参考 <a href="/doc/server/nginx_ipv6" target="_blank">Nginx 开启 IPv6 SSL</a><br/>
</blockquote>
</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
+441
View File
@@ -0,0 +1,441 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watch } from 'vue';
import { isIPv6 } from 'is-ip';
import {config} from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
import { useRoute } from 'vue-router'
import { highlightCode } from '../../utils/shiki'
import { isIPv4 } from '../../utils/tools';
const route = useRoute();
const loading = ref(false);
useHead({
title: `IPv6/IPv4地址查询工具 | IP归属地定位 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv6/IPv4地址查询工具,支持IPv4和IPv6地址归属地查询,提供BiliBili Live、GeoCN、IP2Region、Maxmind等多种数据源对比,精确定位IP地理位置、运营商信息,支持中国大陆及境外地址查询,助力IPv6普及与应用' },
{ name: 'keywords', content: 'ipv6地址查询,ipv4地址查询,ip归属地,ip地理位置,ip定位,运营商查询,ipv6归属地,ipv4归属地,maxmind,ip2region,geocn' },
{ property: 'og:title', content: `IPv6/IPv4地址归属地查询工具 - ${config.siteName}` },
{ property: 'og:description', content: '多数据源IP地址归属地查询,支持IPv4和IPv6,提供地理位置、运营商等详细信息' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'IPv6/IPv4地址归属地查询工具',
description: '专业的IPv6/IPv4地址归属地查询工具,支持BiliBili Live、GeoCN、IP2Region、Maxmind、纯真社区库等多数据源对比,精确定位IP地理位置、运营商信息。',
url: config.siteUrl + 'ipv6',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
interface IPLocationType {
bilibili?: any;
ip?: string;
geocn?: any;
ip2region?: any;
ip2location?: any;
ip2location_asn?: any;
maxmind_asn?: any;
maxmind_city?: any;
qqwry?: any;
[key: string]: any;
}
const code = `
# 请勿用于商业用途,仅供个人测试学习之用,请遵守中国法律法规
# 查询本机外网 IPv4 地址
curl ${config.v4OnlyAPI}
# 查询本机外网 IPv6 地址
curl ${config.v6OnlyAPI}
# 测试网络是 IPv4 还是 IPv6 访问优先
# (访问 IPv4/IPv6 双栈站点,如果返回 IPv6 地址,则 IPv6 访问优先)
curl ${config.DualStackAPI}
`.trim();
const html = ref('');
const apiList = config.IPLocationAPI
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const ipAddress = ref('');
const IPLocation = ref<IPLocationType>({});
const UserIP = ref('');
const error = ref('')
const locationUrl = computed(() => "/middleware/" + backendID.value + "/location/" + encodeURIComponent(ipAddress.value));
const { data: locationData, error: locationError, execute: executeLocation } = useMiddlewareFetch<IPLocationType>(locationUrl, {
immediate: false,
watch: false,
});
watch(locationData, (newData) => {
if (newData) {
IPLocation.value = newData;
loading.value = false;
error.value = '';
}
});
watch(locationError, async (newError) => {
if (newError) {
console.error('Error fetching IP location:', newError);
if (currentApiIndex.value < apiList.length - 1) {
const nextIndex = currentApiIndex.value + 1
error.value = `${(newError as any).message || '请求失败'},正在重试 ${apiList[nextIndex]?.label || ''}...`
currentApiIndex.value = nextIndex
await nextTick()
executeLocation()
} else {
error.value = '请求失败,请检查网络或稍后重试'
loading.value = false;
}
}
});
function locateIP(IP: string){
currentApiIndex.value = 0
ipAddress.value = IP;
error.value = ''
loading.value = true;
nextTick(() => {
executeLocation();
});
}
onMounted(async () => {
html.value = await highlightCode(code, 'bash')
const urlParam = route.query.ip;
if (urlParam) {
ipAddress.value = urlParam as string;
locateIP(urlParam as string);
}else{
$fetch<string>(config.DualStackAPI).then(
function(data) {
ipAddress.value = data;
UserIP.value = data;
}
).catch(
function(error) {
console.error('Error fetching IP address:', error);
}
);
}
});
</script>
<template>
<div class="title">
<header>
<h1>IPv6/IPv4地址归属地查询</h1>
<p>极简的IPv6地址查询工具,致力于普及 IPv6</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="ipAddress"
placeholder="请输入IP地址"
/>
<el-button
@click="locateIP(ipAddress)"
type="primary"
:loading="loading"
>
查询
</el-button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div class="location">
<div class="ip-info" style="height: 40px;">
<b>IP</b>&nbsp;<span>{{ ipAddress }}</span>
</div>
<div v-if="IPLocation" class="result-section">
<table class="result-table">
<tbody>
<tr v-if="IPLocation.bilibili && (IPLocation.bilibili.administrative_area || IPLocation.bilibili.city)">
<td class="table-label">bilibili Live接口</td>
<td class="table-value">
<span>
{{ IPLocation.bilibili?.country }}&nbsp;{{ IPLocation.bilibili?.administrative_area }}&nbsp;{{ IPLocation.bilibili?.city }}
</span>
</td>
<td class="table-value">
<span>
{{ IPLocation.bilibili?.isp }}
</span>
</td>
</tr>
<tr v-if="IPLocation.ip2region && typeof IPLocation.ip2region === 'object' && (IPLocation.ip2region.administrative_area || IPLocation.ip2region.city)">
<td class="table-label">IP2Region</td>
<td class="table-value">{{ IPLocation.ip2region?.country }}&nbsp;{{ IPLocation.ip2region?.administrative_area }}&nbsp;{{ IPLocation.ip2region?.city }}</td>
<td class="table-value">{{ IPLocation.ip2region?.isp }}</td>
</tr>
<tr v-if="IPLocation.ip2location && typeof IPLocation.ip2location === 'object' && (IPLocation.ip2location.administrative_area || IPLocation.ip2location.city)">
<td class="table-label">IP2Location</td>
<td class="table-value">{{ IPLocation.ip2location?.country }}&nbsp;{{ IPLocation.ip2location?.administrative_area }}&nbsp;{{ IPLocation.ip2location?.city }}</td>
<td class="table-value">{{ IPLocation.ip2location_asn?.as }}</td>
</tr>
<tr v-if="IPLocation.geocn && (IPLocation.geocn.administrative_area || IPLocation.geocn.city || IPLocation.geocn.district)">
<td class="table-label">GeoCN(仅中国大陆)</td>
<td class="table-value">{{ IPLocation.geocn?.administrative_area }}&nbsp;{{ IPLocation.geocn?.city }}&nbsp;{{ IPLocation.geocn?.district }}</td>
<td class="table-value">{{ IPLocation.geocn?.isp }}&nbsp;{{IPLocation.geocn?.type}}</td>
</tr>
<tr v-if="IPLocation.maxmind_city && IPLocation.maxmind_asn && (IPLocation.maxmind_city.country || IPLocation.maxmind_city.city)">
<td class="table-label">Maxmind GEOLite2 City</td>
<td class="table-value">{{ IPLocation.maxmind_city?.country }}&nbsp;{{ IPLocation.maxmind_city?.administrative_area }}&nbsp;{{ IPLocation.maxmind_city?.city }}</td>
<td class="table-value">{{ IPLocation.maxmind_asn?.org }}</td>
</tr>
<tr v-if="IPLocation.qqwry && (IPLocation.qqwry.country || IPLocation.qqwry.administrative_area || IPLocation.qqwry.city)">
<td class="table-label">纯真社区库</td>
<td class="table-value">{{ IPLocation.qqwry?.country }}&nbsp;{{ IPLocation.qqwry?.administrative_area }}&nbsp;{{ IPLocation.qqwry?.city }}</td>
<td class="table-value">{{ IPLocation.qqwry?.isp }}</td>
</tr>
<tr v-if="IPLocation.dbip_city && (IPLocation.dbip_city.administrative_area || IPLocation.dbip_city.city)">
<td class="table-label">DB-IP City</td>
<td class="table-value">{{ IPLocation.dbip_city?.country }}&nbsp;{{ IPLocation.dbip_city?.administrative_area }}&nbsp;{{ IPLocation.dbip_city?.city }}</td>
<td class="table-value">{{IPLocation.dbip_asn?.org}}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div style="font-size: 1.5em;">
<h3 v-if="isIPv6(UserIP)"><el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>您的网络IPv6优先</h3>
<h3 v-else-if="isIPv4(UserIP)"><el-icon><CircleCloseFilled style="color: red;"/></el-icon>您的网络IPv4优先</h3>
</div>
<blockquote>
精度参照表:<br>
中国大陆:BiliBili Live > GeoCN > IP2Region > 纯真社区库 > Maxmind GEOLite2 City ≈ DB-IP<br>
中国大陆 IPv6 地址: BiliBili Live > GeoCN > IP2Region > Maxmind GEOLite2 City ≈ DB-IP > 纯真社区库<br>
境外及港澳台地址: Maxmind GEOLite2 City ≈ DB-IP > BiliBili Live > > IP2Region > 纯真社区库<br>
手机默认开启 IPv6,宽带开启 IPv6 请自行搜索<br>
访客IP: {{UserIP}},<span v-if="isIPv4(UserIP)">您的网络IPv4优先</span><span v-else-if="isIPv6(UserIP)">您的网络IPv6优先</span>
</blockquote>
<div v-html="html"></div>
</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
</style>
<style>
@import "../style.css";
:root {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
.el-input {
width: 420px;
height: 50px;
font: 1.3em sans-serif;
margin-right: 10px;
}
.el-button {
width: 165px;
height: 50px;
font: 1.3em sans-serif;
}
.result-section {
margin-top: 30px;
}
.result-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid #dcdfe6;
}
html.dark .result-table {
width: 100%;
border-collapse: collapse;
background: #2e2d2d;
border: 1px solid #2e2e2e;
}
.result-table thead tr {
background-color: #c0c4cc;
}
html.dark .result-table thead tr {
background: #2e2d2d;
}
.result-table .table-header {
padding: 12px 15px;
font-weight: 600;
color: #303133;
text-align: left;
border: 1px solid #dcdfe6;
}
html.dark .result-table .table-header {
padding: 12px 15px;
font-weight: 600;
color: #cfcfcf;
text-align: left;
border: 1px solid #1a1919;
}
.result-table tbody tr {
border-bottom: 1px solid #dcdfe6;
}
html.dark .result-table tbody tr {
border-bottom: 1px solid #1a1919;
}
.result-table tbody tr:last-child {
border-bottom: none;
}
.result-table tbody tr:hover {
background-color: #f5f7fa;
}
html.dark .result-table tbody tr:hover {
background-color: #393a3a;
}
.result-table .table-label {
padding: 12px 15px;
font-weight: 600;
color: #606266;
width: 150px;
text-align: left;
border: 1px solid #dcdfe6;
}
html.dark .result-table .table-label {
color: #c0c4cc;
border: 1px solid #1a1919;
}
.result-table .table-value {
padding: 12px 15px;
color: #303133;
border: 1px solid #dcdfe6;
}
html.dark .result-table .table-value {
color: #cfcfcf;
border: 1px solid #1a1919;
}
.valid {
color: #67C23A;
font-weight: 600;
}
.expired {
color: #F56C6C;
font-weight: 600;
}
.status-code {
font-weight: 600;
padding: 4px 12px;
border-radius: 4px;
}
.status-success {
color: #67C23A;
background: #f0f9eb;
}
.status-warning {
color: #E6A23C;
background: #fdf6ec;
}
.status-error {
color: #F56C6C;
background: #fef0f0;
}
.error-message {
margin-top: 20px;
padding: 15px;
background: #fef0f0;
color: #F56C6C;
border-radius: 6px;
text-align: center;
font-size: 1.1em;
}
pre {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
overflow-x: auto;
white-space: pre;
max-width: 100%;
}
pre code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.9em;
color: #303133;
}
html.dark pre {
background: #303133;
}
html.dark pre code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
color: #f8f9fa;
}
.badge-section {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 20px;
}
.badge-item {
background: #fff;
padding: 20px;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
.badge-item h4 {
margin: 0 0 15px 0;
color: #3EAF7C;
font-size: 1.2em;
}
.badge-item img {
display: block;
margin-bottom: 15px;
max-width: 200px;
}
</style>
+293
View File
@@ -0,0 +1,293 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index'
import { Loading } from '@element-plus/icons-vue'
import { extractHost } from '../../utils/tools'
import { isIPv6 } from 'is-ip'
const route = useRoute()
const url = ref('')
useHead({
title: '网站截图工具 - '+(route.query.site || config.siteName),
meta: [
{ name: 'description', content: '在线网站截图工具,输入网址即可获取网页快照,支持所有公开网站的实时截图,方便查看网站布局和设计效果' },
{ name: 'keywords', content: '网站截图,网页快照,在线截图,网页预览,网站布局查看,截图工具' },
{ property: 'og:title', content: '网站截图工具 - 在线网页快照' },
{ property: 'og:description', content: '输入网址即可获取网页快照,查看网站布局和设计效果' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
]
})
const tmpDomain = ref('https://www.zakoflare.com')
const loading = ref(false)
const error = ref('')
const screenshotUrl = ref('')
const imgLoaded = ref(false)
let detectionImg: HTMLImageElement | null = null
let detectionTimeout: ReturnType<typeof setTimeout> | null = null
let currentRequestId = 0
const userIP = ref('')
// WordPress mshots 默认占位图(生成失败/重定向目标)的尺寸是 1200x900,
// 与下方注释及判定逻辑一致;真实网站截图可以是任意尺寸
const DEFAULT_MSHOTS_WIDTH = 1200
const DEFAULT_MSHOTS_HEIGHT = 900
const LOADING_TIMEOUT = 15000 // 15 seconds
async function getUserIP(){
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
function cleanupDetection() {
if (detectionImg) {
detectionImg.onload = null
detectionImg.onerror = null
detectionImg = null
}
if (detectionTimeout) {
clearTimeout(detectionTimeout)
detectionTimeout = null
}
}
function showError(message: string) {
cleanupDetection()
error.value = message
loading.value = false
screenshotUrl.value = ''
imgLoaded.value = false
}
function takeScreenshot() {
url.value = tmpDomain.value.trim()
if (!url.value) {
error.value = '请输入网址'
return
}
if (!url.value.startsWith('http://') && !url.value.startsWith('https://')) {
url.value = 'https://' + url.value
}
cleanupDetection()
error.value = ''
imgLoaded.value = false
loading.value = true
screenshotUrl.value = ''
const requestId = ++currentRequestId
// Set the screenshot URL on next tick so Vue can update the displayed <img>
nextTick(() => {
// Check if this request was superseded
if (requestId !== currentRequestId) return
screenshotUrl.value = `https://s0.wp.com/mshots/v1/${encodeURIComponent(url.value)}`
// Create a hidden detection image to check for default WordPress error image
// The mshots API returns a 302 redirect to a default image (1200x900) when
// the target site is unreachable or generation times out.
detectionImg = new Image()
detectionImg.onload = () => {
if (requestId !== currentRequestId) return
// 307 Temporary Redirect to mshots default — target is still generating
if (detectionImg!.currentSrc?.endsWith('/mshots/v1/default')) {
cleanupDetection()
loading.value = false
error.value = '正在生成截图中,请稍后...'
return
}
// WordPress mshots default error images are exactly 1200x900.
// Real website screenshots can be any dimensions.
if (
detectionImg!.naturalWidth === DEFAULT_MSHOTS_WIDTH &&
detectionImg!.naturalHeight === DEFAULT_MSHOTS_HEIGHT
) {
showError('截图加载失败,可能是目标站点无法访问或生成超时')
return
}
// Passed detection - screenshot is valid
cleanupDetection()
imgLoaded.value = true
loading.value = false
}
detectionImg.onerror = () => {
if (requestId !== currentRequestId) return
showError('截图加载失败,请检查网址是否正确')
}
detectionImg.src = screenshotUrl.value
// Timeout: if detection doesn't complete within LOADING_TIMEOUT ms
detectionTimeout = setTimeout(() => {
if (requestId !== currentRequestId) return
showError('截图加载失败,可能是目标站点无法访问或生成超时')
}, LOADING_TIMEOUT)
})
}
// The displayed <img> may or may not fire its own load event depending on
// browser behavior with 302 redirects. The hidden detection image above
// handles the primary success/failure determination.
function onImageLoad() {
if (detectionImg && detectionImg.currentSrc?.endsWith('/mshots/v1/default')) {
error.value = '正在生成截图中,请稍后...'
loading.value = false
}
}
function onImageError() {
showError('截图加载失败,请检查网址是否正确')
}
onMounted(() => {
const urlParam = route.query.site as string
if (urlParam) {
tmpDomain.value = urlParam
takeScreenshot()
}
getUserIP()
})
const { data: page } = await useAsyncData('screenshot-doc', () =>
$fetch('/api/markdown/screenshot')
)
const doc = page.value
</script>
<template>
<div class="title">
<header>
<h1>网站截图</h1>
<p>输入网址,自动截图网站页面</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入网址(如:https://example.com)"
@keyup.enter="takeScreenshot"
/>
<el-button
@click="takeScreenshot"
type="primary"
:loading="loading"
>
网站截图
</el-button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="screenshotUrl" class="result-section">
<div class="screenshot-container">
<div v-if="loading && !imgLoaded" class="loading-overlay">
<el-icon class="is-loading" :size="40">
<Loading />
</el-icon>
<p>正在截图中...</p>
</div>
<img
:src="screenshotUrl"
alt="网站截图"
class="screenshot-img"
:class="{ 'img-loaded': imgLoaded }"
@load="onImageLoad"
@error="onImageError"
/>
</div>
</div>
<div v-if="doc" class="markdown">
<div v-html="doc"></div>
</div>
<blockquote>
访客IP: {{userIP }},您的网络 {{ isIPv6(userIP) ? 'IPv6' : 'IPv4'}} 访问优先<br/>
</blockquote>
</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
.screenshot-container {
position: relative;
display: inline-block;
width: 100%;
max-width: 100%;
overflow: hidden;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
background: #f5f5f5;
min-height: 200px;
}
.screenshot-img {
display: block;
max-width: 100%;
height: auto;
opacity: 0;
transition: opacity 0.3s ease;
}
.screenshot-img.img-loaded {
opacity: 1;
}
html.dark .screenshot-container {
background: #1a1919;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f5f5f5;
z-index: 1;
}
html.dark .loading-overlay {
background: #1a1919;
color: #adbac7;
}
.loading-overlay p {
margin-top: 12px;
color: #606266;
font-size: 1.1em;
}
html.dark .loading-overlay p {
color: #c0c4cc;
}
.markdown :deep(a) {
color: #3EAF7C !important;
font-size: 1.3em;
text-decoration: none
}
</style>
+283
View File
@@ -0,0 +1,283 @@
<script setup lang="ts">
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';
const route = useRoute()
useHead({
title: `IPv4网站测速工具 | 全国多节点并发测速 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv4网站测速工具,提供全国多节点并发测速服务,快速返回网站响应时间、下载速度、页面大小、DNS解析时间、HTTP连接时间等详细性能指标,支持IPv4网站性能检测与优化,助力网站性能监控与用户体验改善' },
{ name: 'keywords', content: 'ipv4网站测速,ipv4测速,网站速度测试,ipv4性能检测,网站响应时间,ipv4下载速度,ipv4性能优化,网站性能监控' },
{ property: 'og:title', content: 'IPv4网站测速 - 全国多节点并发性能检测工具' },
{ property: 'og:description', content: '全国多节点IPv4网站测速,快速获取响应时间、下载速度等性能数据' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'IPv4网站性能测速工具',
description: '专业的IPv4网站测速工具,全国多节点并发测速,提供响应时间、下载速度、页面大小、DNS解析时间等详细性能指标。',
url: config.siteUrl + 'speedtest',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
const tmpDomain = ref('https://www.zakoflare.com')
const loading = ref(false)
const result = ref<any[]>([])
const error = ref('')
const domain = ref('')
/** 服务器结果接口 */
interface ServerResult {
label: string
loading: boolean
error?: string | Error
data: any
}
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
/** 预构建的服务器列表,页面挂载即展示,用户可提前看到测速节点 */
const serverResults = ref<ServerResult[]>([])
// 提前构建服务器列表(学 tcping.vue 策略)
// 在组件初始化时就创建好 useFetch 实例,
// 设置 immediate: false + watch: false,
// 只在用户点击"开始测试"时才调用 execute() 发起请求,
// 避免页面加载时自动发请求浪费资源。
// =============================================
/** 双栈服务器:同时支持 IPv4/IPv6 的节点 */
const dualStackFetches = config.APIBaseURL.DualStack.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error: fetchError, execute };
});
/** IPv4 专用服务器:仅支持 IPv4 的节点 */
const ipv4Fetches = config.APIBaseURL.IPv4.map((server) => {
const url = computed(() => "/middleware/" + server.id + "/speed/v4/" + encodeURIComponent(domain.value));
const { data, error: fetchError, execute } = useMiddlewareFetch(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error: fetchError, execute };
});
/** 合并所有服务器 fetch 列表 */
const allFetches = [
...dualStackFetches.map(fetch => ({ ...fetch, type: 'DualStack' })),
...ipv4Fetches.map(fetch => ({ ...fetch, type: 'IPv4' }))
];
/**
* 初始化服务器结果列表
* 页面挂载时调用,让用户提前看到所有测速节点
*/
function initServerResults() {
serverResults.value = allFetches.map((fetch) => ({
label: fetch.label,
loading: false,
error: '',
data: null
}))
}
/**
* 执行全站测速
* 并发调用所有服务器的 execute() 方法,逐个更新对应行的结果
*/
async function SpeedTest() {
domain.value = extractHost(tmpDomain.value)
loading.value = true
error.value = ''
await nextTick()
const id = ++queryId
// 重置所有行状态为加载中
serverResults.value.forEach((row) => {
row.loading = true
row.error = ''
row.data = null
})
// 并发请求所有服务器,每个请求完成后立即更新对应行
const promises = allFetches.map(async (fetch, index) => {
const row = serverResults.value[index]
if (!row) return
try {
await fetch.execute()
if (id !== queryId) return
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成空白)
if (fetch.error.value) {
row.error = fetch.error.value?.data?.error || fetch.error.value?.message || '请求失败'
} else {
row.data = fetch.data.value
}
} catch (err: any) {
row.error = err?.data?.error || err?.message || '请求失败'
} finally {
if (id === queryId) {
row.loading = false
}
}
})
Promise.all(promises).finally(() => {
if (id === queryId) {
loading.value = false
}
})
}
// 页面挂载后,初始化服务器列表并检查 URL 参数
onMounted(() => {
initServerResults()
const urlParam = route.query.site as string
if (urlParam) {
tmpDomain.value = urlParam
SpeedTest()
}
})
</script>
<template>
<div class="title">
<header>
<h1>IPv4 网站测速</h1>
<p>全国并发测速,1s 内快速返回测速结果</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:https://zakoflare.com)"
/>
<el-button
@click="SpeedTest()"
type="primary"
:loading="loading"
>
开始测试
</el-button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">测速服务器</th>
<th class="table-header">解析 IP</th>
<th class="table-header">HTTP状态码</th>
<th class="table-header">HTTPS状态码</th>
<th class="table-header">总时间</th>
<th class="table-header">解析时间</th>
<th class="table-header">HTTP连接</th>
<th class="table-header">网页大小</th>
<th class="table-header">下载速度</th>
</tr>
</thead>
<tbody>
<template v-for="(row, index) in serverResults" :key="index">
<!-- 请求失败 -->
<tr v-if="row.error">
<td class="table-label">{{ row.label }}</td>
<td class="table-value" colspan="8">
<span class="status-code status-error">{{ row.error }}</span>
</td>
</tr>
<!-- 加载中 -->
<tr v-else-if="row.loading">
<td class="table-label">{{ row.label }}</td>
<td class="table-value" colspan="8">测速中...</td>
</tr>
<!-- 有结果 -->
<tr v-else-if="row.data">
<td class="table-label">{{ row.label }}</td>
<td class="table-value">{{ row.data?.host_record }}</td>
<td class="table-value">
<span class="status-code" :class="getStatusCodeClass(row.data?.http_status_code)">
{{ row.data?.http_status_code }}
</span>
</td>
<td class="table-value">
<span class="status-code" :class="getStatusCodeClass(row.data?.https_status_code)">
{{ row.data?.https_status_code }}
</span>
</td>
<td class="table-value">{{ formatTime(row.data?.total_time) }}</td>
<td class="table-value">{{ formatTime(row.data?.dns_lookup_time) }}</td>
<td class="table-value">{{ formatTime(row.data?.first_byte_time) }}</td>
<td class="table-value">{{ formatSize(row.data?.page_size) }}</td>
<td class="table-value">{{ formatSpeed(row.data?.download_speed) }}</td>
</tr>
<!-- 等待测速 -->
<tr v-else>
<td class="table-label">{{ row.label }}</td>
<td class="table-value" colspan="8">-</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
.result-table .table-header {
font-size: 1.05em;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
+397
View File
@@ -0,0 +1,397 @@
<script setup lang="ts">
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 { useMiddlewareFetch } from '../../utils/middleware';
import { extractHost, getStatusCodeClass, formatTime, formatSpeed } from '../../utils/tools';
const route = useRoute()
const siteUrlWithSlash = computed(() => config.siteUrl.replace(/\/$/, '') + '/')
useHead({
title: `SSL证书检测工具 | IPv4/IPv6证书检查 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的SSL证书检测工具,全面检查网站的IPv4和IPv6 SSL证书状态、有效期、签发机构、HTTP版本等信息,支持HTTPS状态码检测、下载速度测试,帮助网站管理员及时发现证书问题,确保网站安全访问' },
{ name: 'keywords', content: 'ssl证书检测,ssl检查,https证书,ipv6 ssl,ipv4 ssl,证书有效期,ssl状态,https检测,网站安全,证书签发机构' },
{ property: 'og:title', content: 'SSL证书检测 - IPv4/IPv6双栈证书状态检查工具' },
{ property: 'og:description', content: '全面检测网站SSL证书状态,支持IPv4和IPv6双栈检测,提供证书有效期、签发机构等详细信息' },
{ property: 'og:image', content: siteUrlWithSlash.value + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'SSL证书检测工具',
description: '专业的SSL证书检测工具,支持IPv4和IPv6 SSL证书状态、有效期、签发机构、HTTP版本等检测,提供HTTPS状态码检测、下载速度测试。',
url: siteUrlWithSlash.value + 'ssl',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
interface SSLCheckResponse {
ipv4: SSLCheckItem
ipv6: SSLCheckItem
}
interface SSLCheckItem {
cert_validity_days: number
cert_start_time: string
cert_end_time: string
http_version: string
host_record: string
https_status_code: number
total_time: number
download_speed: number
domain: string
issuer_organization: string[] | null
issuer_common_name: string
subject_common_name: string
is_expired: boolean
is_reachable: boolean
}
const apiList = config.APIBaseURL.DualStack
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const tmpDomain = ref('https://www.zakoflare.com')
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));
const { data: sslData, error: sslError, execute: executeSSL } = useMiddlewareFetch<SSLCheckResponse>(sslUrl, {
immediate: false,
watch: false,
});
watch(sslData, (newData) => {
if (newData) {
result.value = newData;
loading.value = false;
error.value = '';
}
});
watch(sslError, async (newError) => {
if (newError) {
console.log(newError);
if (currentApiIndex.value < apiList.length - 1) {
const nextIndex = currentApiIndex.value + 1
error.value = `${(newError as any).message || '请求失败'},正在重试 ${apiList[nextIndex]?.label || ''}...`
currentApiIndex.value = nextIndex
await nextTick()
executeSSL()
} else {
error.value = '请求失败,请检查域名或网络';
loading.value = false;
}
}
});
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
function checkSSL() {
testDomain.value = extractHost(tmpDomain.value)
currentApiIndex.value = 0
loading.value = true
error.value = ''
result.value = null
nextTick(() => {
executeSSL();
});
}
onMounted(() => {
const urlParam = route.query.site as string
if (urlParam) {
tmpDomain.value = urlParam
checkSSL()
}
})
</script>
<template>
<div class="title">
<header>
<h1>SSL证书检查</h1>
<p>检查网站是否开启 IPv4 和 IPv6 SSL 证书</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:https://zakoflare.com)"
/>
<el-button
@click="checkSSL()"
type="primary"
:loading="loading"
>
SSL 证书检查
</el-button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="result && result.ipv4" class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">检测项目</th>
<th class="table-header">IPv4</th>
<th class="table-header">IPv6</th>
</tr>
</thead>
<tbody>
<tr>
<td class="table-label">证书状态</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">
<span :class="result.ipv4!.is_expired ? 'expired' : 'valid'">
{{ result.ipv4!.is_expired ? '已过期' : '有效' }}
</span>
</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">
<span :class="result.ipv6.is_expired ? 'expired' : 'valid'">
{{ result.ipv6.is_expired ? '已过期' : '有效' }}
</span>
</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">常用名称</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">{{ result.ipv4!.subject_common_name || '-'}}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">{{ result.ipv6.subject_common_name || '-' }}</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">签发者</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">{{ result.ipv4!.issuer_organization?.join(', ')|| '-' }}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">{{ result.ipv6.issuer_organization?.join(', ') || '-' }}</template>
<span v-else>-</span>
</td>
</tr>
<tr v-if="result.ipv6 && result.ipv6.is_reachable && result.ipv4!.is_reachable && result.ipv6.cert_validity_days > 0 && result.ipv4.cert_validity_days > 0">
<td class="table-label">证书有效期 (天)</td>
<td class="table-value">{{ result.ipv4!.cert_validity_days || '-'}} 天</td>
<td class="table-value">{{ result.ipv6?.cert_validity_days || '-' }} 天</td>
</tr>
<tr v-else-if="result.ipv6 && result.ipv6.is_reachable && result.ipv4!.is_reachable && result.ipv6.cert_validity_days <= 0 && result.ipv4.cert_validity_days <= 0">
<td class="table-label">证书已过期(天)</td>
<td class="table-value">{{ Math.abs(result.ipv4.cert_validity_days) || '-'}}</td>
<td class="table-value">{{ Math.abs(result.ipv6.cert_validity_days) || '-' }}</td>
</tr>
<tr v-else-if="result.ipv4!.is_reachable && result.ipv4.cert_validity_days > 0 && (!result.ipv6 || !result.ipv6.is_reachable)">
<td class="table-label">证书有效期 (天)</td>
<td class="table-value">{{ result.ipv4!.cert_validity_days }} 天</td>
<td class="table-value">-</td>
</tr>
<tr v-else-if="result.ipv4!.is_reachable && result.ipv4.cert_validity_days <= 0 && (!result.ipv6 || !result.ipv6.is_reachable)">
<td class="table-label">证书已过期(天)</td>
<td class="table-value">{{ Math.abs(result.ipv4.cert_validity_days) || '-'}}</td>
<td class="table-value">-</td>
</tr>
<tr>
<td class="table-label">证书开始时间</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable && result.ipv4!.cert_start_time">{{ formatDate(result.ipv4!.cert_start_time) }}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable && result.ipv6.cert_start_time">{{ formatDate(result.ipv6.cert_start_time) }}</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">证书结束时间</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable && result.ipv4!.cert_end_time">{{ formatDate(result.ipv4!.cert_end_time) }}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable && result.ipv6.cert_end_time">{{ formatDate(result.ipv6.cert_end_time) }}</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">HTTP 版本</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">{{ result.ipv4!.http_version || '-'}}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">{{ result.ipv6.http_version || '-' }}</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">主机记录</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">{{ result.ipv4!.host_record || '-'}}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">{{ result.ipv6.host_record || '-' }}</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">HTTPS 访问返回码</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">
<span :class="getStatusCodeClass(result.ipv4!.https_status_code)" class="status-code">
{{ result.ipv4!.https_status_code }}
</span>
</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">
<span :class="getStatusCodeClass(result.ipv6.https_status_code)" class="status-code">
{{ result.ipv6.https_status_code }}
</span>
</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">总耗时</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">{{ formatTime(result.ipv4.total_time) }}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">{{ formatTime(result.ipv6.total_time) }}</template>
<span v-else>-</span>
</td>
</tr>
<tr>
<td class="table-label">下载速度</td>
<td class="table-value">
<template v-if="result.ipv4!.is_reachable">{{ formatSpeed(result.ipv4!.download_speed) }}</template>
<span v-else>-</span>
</td>
<td class="table-value">
<template v-if="result.ipv6 && result.ipv6.is_reachable">{{ formatSpeed(result.ipv6.download_speed) }}</template>
<span v-else>-</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="result && result.ipv4 && result.ipv4.is_reachable && !result.ipv4.is_expired && (!result.ipv6 || (!result.ipv6.is_expired && result.ipv6.is_reachable))">
<h3>结论:<el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>网站{{ extractHost(testDomain) }} 证书有效 </h3>
<p><el-icon><InfoFilled style="color: lightgreen;"/></el-icon>请把下方代码贴到网站底部,把这个好消息告诉你的用户,以便用户核验。</p>
<img src="/ssl-s1.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s1.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s2.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s2.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s3.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s3.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s4.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s4.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s5.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s5.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s6.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s6.svg"&gt;&lt;/a&gt;</code></pre>
</div>
<div v-if="result && result.ipv4 && result.ipv4.is_reachable && !result.ipv4.is_expired && result.ipv6 && result.ipv6.is_reachable && result.ipv6.is_expired">
<h3>结论:<el-icon><CircleCheckFilled style="color: lightgreen;"/></el-icon>网站{{ extractHost(testDomain) }} 证书有效,但不支持IPv6访问 </h3>
<p><el-icon><InfoFilled style="color: lightgreen;"/></el-icon>请把下方代码贴到网站底部,把这个好消息告诉你的用户,以便用户核验。</p>
<img src="/ssl-s1.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s1.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s2.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s2.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s3.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s3.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s4.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s4.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s5.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s5.svg"&gt;&lt;/a&gt;</code></pre>
<img src="/ssl-s6.svg"/>
<pre><code>&lt;a href="{{ siteUrlWithSlash }}ssl/?site={{ extractHost(testDomain) }}" title="本站支持 SSL 安全访问" target='_blank'&gt;&lt;img style='display:inline-block;vertical-align:middle' alt="本站支持 SSL 安全访问" src="{{ siteUrlWithSlash }}ssl-s6.svg"&gt;&lt;/a&gt;</code></pre>
</div>
<div v-else-if="result && result.ipv4 && result.ipv4.is_reachable && result.ipv4.is_expired">
<h3>结论:<CircleCloseFilled style="width: 1.3em;color: red;"/>网站{{ testDomain }} 证书无效 </h3>
<h2>都没有证书了这网站还活啥</h2>
<el-image src="/jingya.jpg"></el-image>
</div>
<div v-else-if="result && result.ipv4 && !result.ipv4.is_reachable && result.ipv6 && !result.ipv6.is_reachable">
<h3>结论:<CircleCloseFilled style="width: 1.3em;color: red;"/>网站{{ testDomain }} 不可达 </h3>
<h2>...</h2>
<el-image src="/jingya.jpg"></el-image>
</div>
<blockquote>
网站不支持 IPv6 SSL 可能原因:<br/>
<br/>
1. 网站所在服务器未开启 IPv6,请参考 <a href="/doc/server/website_enable_ipv6" target="_blank">网站开启 IPv6 的三种方式</a><br/>
2. 网站所在服务器已开启 IPv6,但防火墙未对源地址是 IPv6 地址(::/0)的 443(HTTPS)<a href="/doc/server/website_enable_ipv6" target="_blank">端口开放访问</a><br/>
3. 网站所在服务器已开启 IPv6,但未开启SSL证书,请参考 <a href="/doc/server/nginx_ipv6" target="_blank">Nginx 开启 IPv6 SSL</a><br/>
</blockquote>
</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
+284
View File
@@ -0,0 +1,284 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index';
import { useMiddlewareFetch } from '../../utils/middleware';
import { extractHost } from '../../utils/tools';
const route = useRoute()
useHead({
title: `TCPing测试工具 | IPv4服务器连通性检测 | ${config.siteName}`,
meta: [
{ name: 'description', content: '专业的IPv4 TCPing测试工具,提供多节点TCP连通性检测服务,支持自定义端口测试,实时检测服务器丢包率、平均延迟、最大最小响应时间,帮助运维人员快速诊断服务器网络质量,确保服务稳定运行' },
{ name: 'keywords', content: 'tcping测试,tcp连通性检测,ipv4 tcping,服务器延迟测试,丢包率检测,端口连通性,服务器网络测试,网络质量检测' },
{ property: 'og:title', content: 'TCPing测试工具 - IPv4服务器连通性与延迟检测' },
{ property: 'og:description', content: '多节点TCPing测试,检测服务器连通性、丢包率与响应延迟' },
{ property: 'og:image', content: config.siteUrl + 'favicon.svg' },
{ property: 'og:type', content: 'website' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'IPv4 TCPing连通性测试工具',
description: '专业的IPv4 TCPing测试工具,多节点检测服务器连通性和延迟,支持自定义端口测试,提供丢包率、平均延迟、最大最小响应时间等数据。',
url: config.siteUrl + 'tcping',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
provider: {
'@type': 'Organization',
name: config.siteName
}
})
}
]
});
interface TCPingStats {
ip: string
port: string
sent: number
success: number
loss_rate: number
max_rtt: number
min_rtt: number
avg_rtt: number
}
interface TCPingResponse {
ipv4?: TCPingStats
ipv6?: TCPingStats
}
interface ServerConfig {
id: string
label: string
url: string
}
interface ServerResult {
label: string
loading: boolean
error: string
ipv4?: TCPingStats
ipv6?: TCPingStats
}
const tmpDomain = ref('www.zakoflare.com')
const port = ref('80')
const loading = ref(false)
const serverResults = ref<ServerResult[]>([])
// 查询序号:连续点击时旧查询的结果不允许写入新查询的结果表
let queryId = 0
const allServers = [
...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 { data, error: fetchError, execute } = useMiddlewareFetch<TCPingResponse>(url, {
immediate: false,
watch: false,
});
return { label: server.label, data, error: fetchError, execute };
});
function initServerResults() {
const results: ServerResult[] = []
config.APIBaseURL.DualStack.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
error: ''
})
})
config.APIBaseURL.IPv4.forEach((server: ServerConfig) => {
results.push({
label: server.label,
loading: false,
error: ''
})
})
serverResults.value = results
}
function TCPingAll() {
const host = extractHost(tmpDomain.value)
if (!host) return
loading.value = true
const id = ++queryId
serverResults.value.forEach((result) => {
result.loading = true
result.error = ''
result.ipv4 = undefined
result.ipv6 = undefined
})
const promises = tcpingFetches.map(async (fetch, index) => {
try {
await fetch.execute();
if (id !== queryId) return
const result = serverResults.value[index];
if (result) {
// execute() 不会抛错,失败以 error.value 为准(否则故障节点渲染成 '-')
if (fetch.error.value) {
result.error = (fetch.error.value as any)?.message || '请求失败';
} else {
result.ipv4 = fetch.data.value?.ipv4;
result.ipv6 = fetch.data.value?.ipv6;
}
}
} catch (err) {
console.error(err);
const result = serverResults.value[index];
if (result) {
result.error = (err as any)?.message || '请求失败';
}
} finally {
const result = serverResults.value[index];
if (result && id === queryId) {
result.loading = false;
}
}
})
Promise.all(promises).finally(function () {
if (id === queryId) {
loading.value = false
}
})
}
onMounted(() => {
initServerResults()
const urlParam = route.query.site as string
if (urlParam) {
tmpDomain.value = urlParam
TCPingAll()
}
})
</script>
<template>
<div class="title">
<header>
<h1>IPv4 TCPing 测试</h1>
<p>多节点 TCPing 测试,检测服务器连通性和延迟</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
/>
<el-input
v-model="port"
placeholder="端口号(默认 80)"
style="width: 200px;"
/>
<el-button
@click="TCPingAll()"
type="primary"
:loading="loading"
>
开始测试
</el-button>
</div>
<div class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">服务器</th>
<th class="table-header">解析 IP</th>
<th class="table-header">发送包</th>
<th class="table-header">接收包</th>
<th class="table-header">丢包率(%)</th>
<th class="table-header">最长时间(ms)</th>
<th class="table-header">最短时间(ms)</th>
<th class="table-header">平均时间(ms)</th>
</tr>
</thead>
<tbody>
<template v-for="(server, index) in serverResults" :key="index">
<tr v-if="server.ipv4">
<td class="table-label">{{ server.label }}</td>
<td class="table-value">{{ server.ipv4?.ip || '-' }}</td>
<td class="table-value">{{ server.ipv4?.sent }}</td>
<td class="table-value">{{ server.ipv4?.success }}</td>
<td class="table-value">
<span :class="(server.ipv4?.loss_rate ?? 0) > 0 ? 'loss-warning' : 'loss-ok'">
{{ server.ipv4?.loss_rate?.toFixed(1) ?? '-' }}
</span>
</td>
<td class="table-value">{{ server.ipv4?.max_rtt?.toFixed(2) ?? '-' }}</td>
<td class="table-value">{{ server.ipv4?.min_rtt?.toFixed(2) ?? '-' }}</td>
<td class="table-value">{{ server.ipv4?.avg_rtt?.toFixed(2) ?? '-' }}</td>
</tr>
<tr v-else-if="server.loading">
<td class="table-label">{{ server.label }}</td>
<td class="table-value" colspan="7">加载中...</td>
</tr>
<tr v-else-if="server.error">
<td class="table-label">{{ server.label }}</td>
<td class="table-value error-text" colspan="7">{{ server.error }}</td>
</tr>
<tr v-else>
<td class="table-label">{{ server.label }}</td>
<td class="table-value" colspan="7">-</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
.result-table .table-label {
width: 200px;
}
.error-text {
color: #F56C6C;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon {
font-size: 1.3em;
}
</style>
+330
View File
@@ -0,0 +1,330 @@
<script setup lang="ts">
import { useMiddlewareFetch } from '../../utils/middleware';
import { ref, onMounted, computed, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { config } from '../../config/index'
import { isIPv6 } from 'is-ip'
const route = useRoute()
useHead({
title: 'Whois查询 | ' + (route.query.site || config.siteName),
titleTemplate: '%s',
link: [
{ rel: 'canonical', href: computed(() => new URL(route.path, config.siteUrl).toString()).value }
],
meta: [
{ name: 'description', content: '专业的Whois域名查询工具,支持.com、.net等国内外域名WHOIS信息查询,提供域名注册商、注册时间、到期时间、DNS服务器等详细信息,助力域名管理和交易决策' },
{ name: 'keywords', content: 'whois查询,域名whois,whois信息查询,域名注册信息,域名到期时间,域名注册商,dns服务器查询,域名whois工具' },
{ property: 'og:title', content: 'Whois域名查询工具 - 域名注册信息在线查询' },
{ property: 'og:description', content: '专业的Whois域名查询工具,支持国内外域名WHOIS信息查询,提供域名注册商、注册时间、到期时间等详细信息' },
{ property: 'og:image', content: `${config.siteUrl}favicon.svg` },
{ property: 'og:type', content: 'website' },
{ property: 'og:url', content: computed(() => new URL(route.path, config.siteUrl).toString()).value },
{ name: 'twitter:card', content: 'summary_large_image' },
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'Whois域名查询工具',
description: '专业的Whois域名查询工具,支持国内外域名WHOIS信息查询,提供域名注册商、注册时间、到期时间、DNS服务器等详细信息',
url: config.siteUrl + 'whois',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Any',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY'
},
featureList: 'Whois查询,域名注册信息,域名到期时间,域名注册商查询,DNS服务器查询',
about: [
{
'@type': 'Thing',
name: 'Whois查询',
description: '通过Whois协议查询域名的注册信息,包括注册商、注册时间、到期时间、DNS服务器等技术服务。'
},
{
'@type': 'Thing',
name: '域名管理',
description: '通过Whois查询结果,域名持有者可以了解域名到期时间,及时续费避免域名被释放或高价赎回。'
}
]
})
}
]
});
const domain = ref('')
const tmpdomain = ref('zakoflare.com')
const loading = ref(false)
const result = ref<any>(null)
const error = ref('')
const userIP = ref('')
const apiList = config.APIBaseURL.DualStack
const currentApiIndex = ref(0)
const backendID = computed(() => apiList[currentApiIndex.value]?.id || '')
const whoisUrl = computed(() => '/middleware/' + backendID.value + '/whois/' + encodeURIComponent(domain.value))
const { data: whoisData, error: whoisError, execute: executeWhois } = useMiddlewareFetch<any>(whoisUrl, {
immediate: false,
watch: false,
})
watch(whoisData, (newData) => {
if (newData) {
result.value = newData
loading.value = false
error.value = ''
}
})
watch(whoisError, async (newError) => {
if (newError) {
console.log(newError)
if (currentApiIndex.value < apiList.length - 1) {
const nextIndex = currentApiIndex.value + 1
error.value = `${(newError as any).message || '请求失败'},正在重试 ${apiList[nextIndex]?.label || ''}...`
currentApiIndex.value = nextIndex
await nextTick()
executeWhois()
} else {
error.value = '请求失败,请检查域名或网络'
loading.value = false
}
}
})
async function queryWhois() {
currentApiIndex.value = 0
loading.value = true
error.value = ''
result.value = null
domain.value = tmpdomain.value.trim()
if (!domain.value) {
error.value = '请输入域名'
loading.value = false
return
}
nextTick(() => {
executeWhois()
})
}
onMounted(() => {
getUserIP()
const whoisParam = route.query.site as string
if (whoisParam) {
tmpdomain.value = whoisParam
queryWhois()
}
})
async function getUserIP(){
try {
userIP.value = await $fetch<string>(config.DualStackAPI)
} catch {
// 获取失败保持为空,避免未处理的 Promise 拒绝
}
return userIP.value
}
const { data: page } = await useAsyncData('whois-doc', () =>
$fetch('/api/markdown/whois')
)
const doc = page.value
function formatDate(dateStr: string): string {
if (!dateStr) return '-'
const d = new Date(dateStr)
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
function getStatusClass(status: string): string {
if (status.includes('prohibited') || status.includes('lock')) return 'status-warning'
return 'status-success'
}
</script>
<template>
<div class="title">
<header>
<h1>Whois 域名查询</h1>
<p>查询域名注册信息、注册商、到期时间等 Whois 数据</p>
</header>
</div>
<div class="content">
<div class="one-line">
<el-input
v-model="tmpdomain"
placeholder="请输入域名(如:example.com)"
/>
<el-button
@click="queryWhois()"
type="primary"
:loading="loading"
>
查询
</el-button>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div v-if="result" class="result-section">
<table class="result-table">
<thead>
<tr>
<th class="table-header">项目</th>
<th class="table-header">信息</th>
</tr>
</thead>
<tbody>
<tr>
<td class="table-label">域名</td>
<td class="table-value">{{ result.domain }}</td>
</tr>
<tr v-if="result.status?.length">
<td class="table-label">域名状态</td>
<td class="table-value">
<span v-for="(s, i) in result.status" :key="i" :class="getStatusClass(s)" class="status-code" style="margin-right: 4px;">
{{ s }}
</span>
</td>
</tr>
<tr v-if="result.registrar?.name">
<td class="table-label">注册商</td>
<td class="table-value">
{{ result.registrar.name }}
<span v-if="result.registrar.ianaId" style="color: #999; font-size: 0.9em;">(IANA ID: {{ result.registrar.ianaId }})</span>
</td>
</tr>
<tr v-if="result.registrant && Object.keys(result.registrant).length">
<td class="table-label">注册人</td>
<td class="table-value">
<div v-if="result.registrant.name">{{ result.registrant.name }}</div>
<div v-if="result.registrant.org">{{ result.registrant.org }}</div>
<div v-if="result.registrant.phone">{{ result.registrant.phone }}</div>
<div v-if="result.registrant.email">{{ result.registrant.email }}</div>
<div v-if="result.registrant.province">{{ result.registrant.province }}</div>
<a v-if="result.registrant.contactUri" :href="result.registrant.contactUri" target="_blank" rel="noreferrer">查看注册信息</a>
</td>
</tr>
<tr v-if="result.technical && Object.keys(result.technical).length">
<td class="table-label">技术联系人</td>
<td class="table-value">
<div v-if="result.technical.name">{{ result.technical.name }}</div>
<div v-if="result.technical.org">{{ result.technical.org }}</div>
<div v-if="result.technical.phone">{{ result.technical.phone }}</div>
<div v-if="result.technical.email">{{ result.technical.email }}</div>
</td>
</tr>
<tr v-if="result.abuseContact && Object.keys(result.abuseContact).length">
<td class="table-label">abuse 联系人</td>
<td class="table-value">
<div v-if="result.abuseContact.name">{{ result.abuseContact.name }}</div>
<div v-if="result.abuseContact.phone">{{ result.abuseContact.phone }}</div>
<div v-if="result.abuseContact.email">{{ result.abuseContact.email }}</div>
</td>
</tr>
<tr v-if="result.dates?.registration">
<td class="table-label">注册时间</td>
<td class="table-value">{{ formatDate(result.dates.registration) }}</td>
</tr>
<tr v-if="result.dates?.expiration">
<td class="table-label">到期时间</td>
<td class="table-value">{{ formatDate(result.dates.expiration) }}</td>
</tr>
<tr v-if="result.dates?.lastChanged">
<td class="table-label">最后修改</td>
<td class="table-value">{{ formatDate(result.dates.lastChanged) }}</td>
</tr>
<tr v-if="result.nameservers?.length">
<td class="table-label">DNS 服务器</td>
<td class="table-value">
<div v-for="ns in result.nameservers" :key="ns">{{ ns }}</div>
</td>
</tr>
<tr v-if="result.whoisServer">
<td class="table-label">Whois 服务器</td>
<td class="table-value">{{ result.whoisServer }}</td>
</tr>
</tbody>
</table>
</div>
<blockquote>
数据来源:后端 WHOIS 服务,通过 IANA 注册局 bootstrap 文件自动匹配对应 WHOIS 服务器。<br/>
部分注册局对注册人信息做了隐私保护,将显示为 "Redacted for Privacy"。<br/>
如需查看完整注册信息,可点击 "查看注册信息" 链接跳转到注册局官网查询。<br/>
访客IP: {{ userIP || '获取中...' }} 您的网络{{ isIPv6(userIP) ? 'IPv6' : 'IPv4' }}优先<br/>
</blockquote>
<div v-if="doc" class="markdown">
<div v-html="doc"></div>
</div>
</div>
</template>
<style scoped>
@import "../style.css";
@import "../../assets/css/tool-common.css";
.markdown :deep(a) {
color: #3EAF7C !important;
font-size: 1.3em;
text-decoration: none
}
@media (max-width: 768px) {
.markdown :deep(a) {
font-size: 1.1em;
}
.markdown :deep(img) {
height: auto;
max-width: 100%;
}
}
.el-menu--horizontal > .el-menu-item:nth-child(1) {
margin-right: auto;
}
.status-code {
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
margin-bottom: 2px;
}
.error-message {
margin-top: 20px;
padding: 15px;
background: #fef0f0;
color: #F56C6C;
border-radius: 6px;
text-align: center;
font-size: 1.1em;
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
}
html.dark {
--el-color-primary: #3EAF7C;
}
.el-icon{
font-size: 1.3em;
}
</style>
+285
View File
@@ -0,0 +1,285 @@
body{
display: flex;
flex-direction: column;
margin: 0 auto;
}
/* 在样式开头添加 */
p {
margin-top: 0;
margin-bottom: 0;
}
.title{
display: flex;
flex-direction: column;
align-items: center;
}
h1 {
font-size: 3em;
text-align: center;
unicode-bidi: isolate;
}
html.dark h1{
color: #adbac7;
}
h2{
font-size: 1.5em;
unicode-bidi: isolate;
}
h3{
font-size: 1.3em;
}
.title p{
font-size: 25.6px;
color: #6A8BAD;
margin-right: 20px;
margin-left: 20px;
text-align: center;
}
.content{
display: flex;
flex-direction: column;
align-items: right;
width: 60%;
min-width: 400px;
margin: 0 auto;
margin-top: 40px;
}
@media (max-width: 768px) {
.content {
width: 95%;
min-width: unset;
}
}
.one-line{
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
flex-wrap: wrap;
word-break: break-all;
hyphens: none; /* 防止自动添加连字符 */
margin-top: 1em;
}
.ip-info{
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
flex-wrap: nowrap;
}
.one-line p{
font-size: 1.3em;
}
.ip-info p{
font-size: 1.3em;
}
b{
color: #2C3E50;
font-size: 1.3em;
}
html.dark b{
color: #adbac7;
}
blockquote p{
color: #999999;
}
blockquote {
background: #f9f9f9;
border-left: 10px solid #ccc;
margin: 1.5em 10px;
padding: 0.5em 10px;
quotes: "\201C""\201D""\2018""\2019";
/* 设置字间距:让文字看起来更透气 */
letter-spacing: 0.5px;
/* 设置行间距:提升多行文本的阅读体验 */
line-height: 2;
/* (可选) 设置单词间距,针对英文内容 */
word-spacing: 1px;
}
blockquote p {
display: inline;
margin: 0.5em;
}
blockquote a{
color: #3EAF7C;
font-size: 1em;
text-decoration: none
}
a {
color: #3EAF7C;
font-size: 1.3em;
text-decoration: none
}
a:hover {
color: none;
}
footer .one-line {
font-size: 1em;
text-align: center;
color: #757575;
}
footer .one-line a {
font-size: 1em;
text-align: center;
color: #3EAF7C;
text-decoration: none
}
footer {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 60px;
}
@media (max-width: 768px) {
.content {
max-width: 95%;
min-width: unset;
margin-top: 30px;
}
h1 {
font-size: 1.9em;
}
h3{
font-size: 1.1em;
}
.title p {
font-size: 0.9em;
margin-right: 10px;
margin-left: 10px;
}
footer {
margin-top: 30px;
}
footer .one-line {
font-size: 0.75em;
flex-wrap: wrap;
justify-content: center;
text-align: center;
}
footer .one-line img {
height: 1.2em;
width: auto;
vertical-align: middle;
}
footer .one-line a {
font-size: 0.9em;
}
.one-line {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.one-line p{
font-size:1.1em
}
.one-line b{
font-size:1.1em
}
.one-line a{
font-size:1.1em
}
blockquote {
font-size:0.9em;
}
blockquote p {
font-size:0.5em;
display: inline;
margin: 0.5em;
}
blockquote a{
color: #3EAF7C;
font-size: 1em;
text-decoration: none
}
}
html.dark {
/* 在暗色模式下,覆盖根元素的文字颜色和背景色 */
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
--el-color-primary: #3EAF7C;
}
html.dark blockquote {
/* 在暗色模式下,覆盖根元素的文字颜色和背景色 */
color: rgba(255, 255, 255, 0.87);
background-color: #1a1919;
border-left: 10px solid #111111;
}
:deep(.shiki span) {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Monaco', 'Courier New', monospace !important;
}
:deep(.shiki){
padding: 1rem;
border-radius: 10px;
overflow: auto;
max-width: 100%;
}
.spoiler {
position: relative; /* 使得伪元素可以定位 */
background-color: rgba(200, 200, 200, 0.3); /* 浅灰色半透明背景 */
color: rgba(0, 0, 0, 0.5); /* 半透明黑色文字 */
user-select: none; /* 禁止选中文字 */
overflow: hidden; /* 隐藏溢出的内容 */
cursor: pointer; /* 鼠标悬停时光标变为手型 */
filter: blur(3px); /* 添加模糊效果 */
padding: 5px; /* 为文本添加内边距以增强效果 */
transition: filter 0.3s ease; /* 添加平滑过渡效果 */
}
.spoiler:hover {
background-color: inherit; /* 悬停时使用父元素的背景色 */
color: inherit; /* 悬停时显示原色 */
filter: blur(0); /* 悬停消除模糊效果 */
}
.quate{
background-color: #F3F4F5;
color: #4E6E8E;
border-radius: 5px;
padding: 3px ;
}
html.dark .quate{
background-color: #2b313a;
color: #8e9eb0;
}
.header-anchor {
margin-right: .4rem;
text-decoration: none;
transition: opacity .2s;
}
h1>.header-anchor,
h2>.header-anchor,
h3>.header-anchor,
h4>.header-anchor,
h5>.header-anchor,
h6>.header-anchor {
opacity: 1;
color: #3EAF7C;
}
h1:hover>.header-anchor,
h2:hover>.header-anchor,
h3:hover>.header-anchor,
h4:hover>.header-anchor,
h5:hover>.header-anchor,
h6:hover>.header-anchor {
opacity: 1;
}
.header-anchor::before {
content: "#";
color: #3EAF7C;
}
+303
View File
@@ -0,0 +1,303 @@
/* tool-common.css - 网络工具页面公共样式 */
/* 输入框和按钮 */
.el-input {
width: 420px;
height: 50px;
font: 1.3em sans-serif;
margin-right: 10px;
}
.el-button {
width: 165px;
height: 50px;
font: 1.3em sans-serif;
}
.el-select {
margin-right: 10px;
}
:deep(.el-select__wrapper) {
height: 50px !important;
min-height: 50px !important;
padding-top: 0 !important;
}
:deep(.el-select__selected-item) {
line-height: 48px !important;
height: 48px !important;
font-size: 16px;
}
:deep(.el-select__caret) {
font-size: 18px;
line-height: 50px !important;
}
:deep(.el-input__wrapper) {
height: 50px !important;
box-shadow: 0 0 0 1px var(--el-border-color) inset !important;
}
:deep(.el-input__inner) {
height: 48px !important;
line-height: 48px !important;
font-size: 16px;
}
/* 结果区域 */
.result-section {
margin-top: 30px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* 表格样式 */
.result-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid #dcdfe6;
}
html.dark .result-table {
background: #2e2d2d;
border: 1px solid #2e2e2e;
}
.result-table thead tr {
background-color: #c0c4cc;
}
html.dark .result-table thead tr {
background: #2e2d2d;
}
.result-table .table-header {
padding: 12px 15px;
font-weight: 600;
color: #303133;
text-align: left;
border: 1px solid #dcdfe6;
}
html.dark .result-table .table-header {
color: #cfcfcf;
border: 1px solid #1a1919;
}
.result-table tbody tr {
border-bottom: 1px solid #dcdfe6;
}
html.dark .result-table tbody tr {
border-bottom: 1px solid #1a1919;
}
.result-table tbody tr:last-child {
border-bottom: none;
}
.result-table tbody tr:hover {
background-color: #f5f7fa;
}
html.dark .result-table tbody tr:hover {
background-color: #393a3a;
}
.result-table .table-label {
padding: 12px 15px;
font-weight: 600;
color: #606266;
width: 150px;
text-align: left;
border: 1px solid #dcdfe6;
}
html.dark .result-table .table-label {
color: #c0c4cc;
border: 1px solid #1a1919;
}
.result-table .table-value {
padding: 12px 15px;
color: #303133;
border: 1px solid #dcdfe6;
}
html.dark .result-table .table-value {
color: #cfcfcf;
border: 1px solid #1a1919;
}
/* 状态样式 */
.status-code {
font-weight: 600;
padding: 4px 12px;
border-radius: 4px;
}
.status-success {
color: #67C23A;
background: #f0f9eb;
}
.status-warning {
color: #E6A23C;
background: #fdf6ec;
}
.status-error {
color: #F56C6C;
background: #fef0f0;
}
.valid {
color: #67C23A;
font-weight: 600;
}
.expired {
color: #F56C6C;
font-weight: 600;
}
.loss-ok {
color: #67C23A;
font-weight: 600;
}
.loss-warning {
color: #F56C6C;
font-weight: 600;
}
/* 错误消息 */
.error-message {
margin-top: 20px;
padding: 15px;
background: #fef0f0;
color: #F56C6C;
border-radius: 6px;
text-align: center;
font-size: 1.1em;
}
/* 代码块 */
pre {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
overflow-x: auto;
white-space: pre;
max-width: 100%;
}
pre code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.9em;
color: #303133;
}
html.dark pre {
background: #303133;
}
html.dark pre code {
color: #f8f9fa;
}
/* 徽章区域 */
.badge-section {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 20px;
}
.badge-item {
background: #fff;
padding: 20px;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
.badge-item h4 {
margin: 0 0 15px 0;
color: #3EAF7C;
font-size: 1.2em;
}
.badge-item img {
display: block;
margin-bottom: 15px;
max-width: 200px;
}
/* 窄屏响应式:所有组件缩小 */
@media (max-width: 768px) {
.el-input,
.el-button {
width: 100% !important;
height: 40px !important;
font-size: 0.9em !important;
margin-right: 0 !important;
}
.el-input {
margin-bottom: 10px;
}
.el-select {
width: 100% !important;
margin-right: 0 !important;
margin-bottom: 10px;
}
.result-section {
margin-top: 20px;
}
.result-table {
font-size: 0.85em;
}
.result-table .table-label {
width: 80px;
padding: 6px 8px;
}
.result-table .table-value {
padding: 6px 8px;
}
.result-table .table-header {
padding: 8px 10px;
font-size: 0.9em;
}
pre {
padding: 10px;
font-size: 0.8em;
}
.code-block {
padding: 0.75rem;
font-size: 0.8em;
}
.error-message {
font-size: 0.9em;
padding: 10px;
}
.badge-item {
padding: 15px;
}
.badge-item h4 {
font-size: 1em;
}
}
+107
View File
@@ -0,0 +1,107 @@
export interface DocMeta {
title: string
description: string
}
export const docConfig: Record<string, DocMeta> = {
'/doc': {
title: 'IPv6 工具箱使用文档',
description: 'IPv6 工具箱使用文档'
},
'/doc/user/enable_ipv6': {
title: '个人宽带如何开启IPv6网络访问',
description: '介绍个人宽带如何开启IPv6网络访问'
},
'/doc/user/cmd_bash_disable_ipv6': {
title: '命令行禁用/启用IPv6本地网络',
description: 'macOS和Window10命令行禁用/启用IPv6本地网络'
},
'/doc/user/cmd_getip': {
title: '命令行(curl)获取 IPv4 和 IPv6 地址',
description: '使用命令行curl获取IPv4和IPv6地址'
},
'/doc/user/view_ipv6_adress_url': {
title: '浏览器访问 IPv6 地址',
description: '如何在浏览器中访问IPv6地址'
},
'/doc/user/ipv4_ipv6_prefix_precedence': {
title: 'Windows 10/11 设置 IPv4/IPv6 访问优先级',
description: 'Windows 10/11 设置 IPv4/IPv6 访问优先级'
},
'/doc/user/ipv6_daohang': {
title: '国内 IPv6 资源导航',
description: '国内IPv6资源导航列表'
},
'/doc/user/pure_ipv6_website': {
title: '国内纯 IPv6 网站导航',
description: '国内纯IPv6网站导航列表'
},
'/doc/user/dns': {
title: '全国各省 DNS 服务器列表',
description: '全国各省DNS服务器列表'
},
'/doc/user/ipv6_dns': {
title: 'IPv6 DNS 地址列表',
description: 'IPv6 DNS地址列表'
},
'/doc/user/AliyunAuthorizeSecurityGroup': {
title: '阿里云自动化添加安全组',
description: '阿里云自动化添加安全组'
},
'/doc/user/TencentCloudAddSecurityGroup': {
title: '腾讯云自动化添加安全组',
description: '腾讯云自动化添加安全组'
},
'/doc/server/website_enable_ipv6': {
title: '网站开启 IPv6 的三种方式',
description: '从传统二进制部署的Nginx,到云原生部署的K8S、Istio,分别介绍网站开启IPv6的三种方式'
},
'/doc/server/tencent_cloud_cvm_ipv6': {
title: '腾讯云 cvm 开启 IPv6',
description: '一文看懂在腾讯云CVM上开启IPv6,只需7步'
},
'/doc/server/nginx_ipv6': {
title: 'Nginx 开启 IPv6',
description: '一文看懂Nginx中开启IPv6,包含设置IPv6 SSL证书'
},
'/doc/server/ipv6webcheck': {
title: '如何确认一个网站是否开启 IPv6',
description: '如何确认一个网站是否开启IPv6'
},
'/doc/server/ipv6_sign': {
title: '网站增加支持IPv6访问标识',
description: 'HTML+CSS代码,可以提醒访客本网站支持IPv6访问'
},
'/doc/server/ipv6_domain_record': {
title: '如何为域名添加 IPv6 解析记录',
description: '如何为域名添加IPv6解析记录'
},
'/doc/server/http2': {
title: '原创网站如何开启 HTTP/2',
description: '介绍网站如何开启HTTP/2协议'
},
'/doc/rfc/rfc8200': {
title: 'IPv6 RFC8200 解读',
description: 'RFC8200(Internet Protocol, Version 6 (IPv6) Specification) 是IPv6的权威定义'
},
'/doc/rfc/ipv6_address_format': {
title: 'IPv6 地址标识方法',
description: '首选格式、压缩格式、内嵌IPv4地址的IPv6地址格式'
},
'/doc/user/tcpdump_ipv6': {
title: 'tcpdump 分析IPv6包',
description: '使用tcpdump分析IPv6包'
},
'/doc/user/wireshark_ipv6': {
title: 'WireShark 分析IPv6包头',
description: '使用WireShark分析IPv6包头'
},
'/doc/user/ipv6_ping': {
title: 'IPv6 Ping 检测原理',
description: 'IPv6 Ping检测原理'
}
}
export function getDocMeta(path: string): DocMeta {
return docConfig[path] || { title: '', description: '' }
}
+135
View File
@@ -0,0 +1,135 @@
/*
前端的一系列配置
*/
const config = {
siteUrl: "https://nipw.cn/",
// 站点名称:用于页面标题 / 描述 / 页脚品牌展示
siteName: "柠檬味ipw.cn",
// 是否启用前端内置中间件(server/routes/middleware/[...slug].get.ts 本地转发)。
// true(默认):/middleware/* 候选为「外部节点… + 内置中间件(最后一位兜底)」;
// false:仅使用外部节点,候选列表不含内置中间件。
EnableInternalMiddleware: true,
// 内置中间件单 IP 限流次数(次/分钟);0 表示不限流。默认 120。
rateLimitPerMinute: 120,
// 外部独立中间件服务列表(base URL)。
// 与前端自带中间件(server/routes/middleware/[...slug].get.ts 本地转发)同级,
// 前端 /middleware/* 请求会依次尝试这些节点(出错重试下一个),
// 前端自带中间件始终放在候选列表最后一位兜底。
// 未配置或为空数组时,直接使用前端自带中间件。
Middleware: <string[]>[
"https://middleware-1.api-ipw.wsmdn.top/"
// "http://127.0.0.1:8092/",
// "https://middleware-2.wsmdn.top/",
],
// Umami 统计
umamiHost: "https://umami.wsmdn.top/",
umamiScriptUrl: "https://umami.wsmdn.top/zako.js",
umamiWebsiteId: "69a91329-b110-4cf7-a04a-be4360b1a8d3",
// 中华人民共和国备案系统
ICP: "",
GongAn: "",
// 全站是否禁止搜索引擎索引
noindex: false,
// Worker IP查询接口
v4OnlyAPI: "https://4.wsmdn.top",
v6OnlyAPI: "https://6.wsmdn.top",
DualStackAPI: "https://test.wsmdn.top",
// ---- 节点池结构 ----
// IPLocationAPI:IP 归属查询(location / asn)上游节点池(纯数组,无栈区分)
IPLocationAPI: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
{
label: "中国 湖北 武汉 电信",
id: "cn-wuhan-chinatelecom",
url: ""
},
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
}
],
// APIBaseURL:其余拨测(whois / ssl / detail / dns / dnssec / tcping / speed)上游节点池,含 { IPv6, IPv4, DualStack } 三栈
APIBaseURL: {
IPv6: [
{
label: "中国 四川 沙渠 电信[ZFC]",
id: "cn2-sichuan",
url: "https://cn2-sichuan.api-ipw.wsmdn.top/"
},
{
label: "中国 香港 九龙城区 旺角东 Cloudie[ZFC]",
id: "lntl-cn-hk-kowloon",
url: "https://lntl-cn-hk-kowloon.api-ipw.wsmdn.top/"
},
],
IPv4: [
{
label: "中国 广东 广州 腾讯云",
id: "cn-guangzhou",
url: "https://cn-guangzhou.api-ipw.wsmdn.top/"
},
{
label: "新加坡 腾讯云",
id: "sg-1",
url: "https://sg-1.api-ipw.wsmdn.top/"
},
{
label: "中国 香港 新界 西贡区 将军澳 MICC[ZFC]",
id: "hk-shatin",
url: "https://china-hk-kowloon-shatindistrict-newcloud.ipwapi.zfap.wsmdn.top/"
},
{
label: "中国 陕西 西安 北经济技术开发区 未央区凤城 中国电信[ZFC]",
id: "cn-xian",
url: "https://cn-xian-shaanxiprovince.api-ipw.wsmdn.top/"
},
{
label: "美国/加利福尼亚州/洛杉矶/蒙特利奇帕克/USCD[ZFC]",
id: "us-la",
url: "https://america-california-losangeles.ipwapi.zfap.wsmdn.top/"
},
// IP 直连新节点:URL 留空,仅经独立中间件转发
{
label: "山东 枣庄 双线",
id: "zaozhuang",
url: ""
},
{
label: "香港 Cogent",
id: "hongkong",
url: ""
},
// WS 通道节点:URL 留空,拨测经中间件 WS 转发
{
label: "呼和浩特移动",
id: "2faa15e6-18a7-4355-b422-ca53277d8d77",
url: ""
}
],
DualStack: [
{
label: "中国 江苏 移动",
id: "cn-jiangsu",
url: "https://cn-jiangsu.api-ipw.wsmdn.top/"
},
{
label: "中国 广东 深圳 龙岗 坪地街道 中国移动",
id: "cn-shenzhen",
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
},
{
label: "中国 湖北 武汉 电信",
id: "cn-wuhan-chinatelecom",
url: ""
},
]
}
}
export { config }
+131
View File
@@ -0,0 +1,131 @@
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:// 开头的域名部分
const urls = JSON.stringify(obj).match(/https?:\/\/[^"\/\\\s]+/g) || [];
// 提取域名 (Origin) 并去重
const domains = [...new Set(urls.map(url => new URL(url).origin))];
return domains;
};
const allowedDomains = extractDomains(config);
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: [
"nitro-cloudflare-dev",
'@element-plus/nuxt',
'@nuxtjs/sitemap',
'@nuxtjs/robots',
'@vueuse/nuxt',
"nuxt-security",
],
vite: {
optimizeDeps: {
include: [
'is-ip',
'shiki',
]
},
},
site: {
url: config.siteUrl,
name: 'Lemon IPW'
},
css: [
// 1. 引入 Element Plus 基础样式 (如果你还没有引入的话)
'element-plus/dist/index.css',
// 2. 🌟 关键:引入 Element Plus 官方的暗黑模式 CSS 变量文件
'element-plus/theme-chalk/dark/css-vars.css',
],
app:{
head: {
script: [
{
// 必须 innerHTML,不能 src(否则异步加载)
innerHTML: `
(function() {
var stored = localStorage.getItem('vueuse-color-scheme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var dark = stored === 'dark' || (!stored && prefersDark);
if (dark) document.documentElement.classList.add('dark');
})();
`,
// 关键:不加 async/defer,确保同步阻塞执行
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.svg' }
]
}
},
runtimeConfig: {
indexnowKey: '',
apiKeys: '',
public: {
siteUrl: config.siteUrl,
docConfig: docConfig,
},
},
routeRules: {
// /doc 文档站改为 SSG:构建时预渲染为静态 HTML,由边缘静态资源直接响应
'/doc/**': { prerender: true },
},
nitro: {
publicAssets: [
{
dir: 'public',
maxAge: 0
}
],
esbuild: {
options: {
target: 'es2022' // 明确告诉 Nitro 使用 es2022 进行打包
}
},
prerender: {
// 显式列出所有 doc 路由(来自 config/doc.ts 的 docConfig 键),
// 让动态 [...slug] 页面在构建时全部预渲染为静态 HTML
routes: ['/doc', ...Object.keys(docConfig).filter((p) => p !== '/doc')],
},
},
security: {
ssg: {
// /doc 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
// 触发 Cloudflare _headers 限制;关闭静态页安全头写入 _headers。
// SSR 页面仍由运行时中间件下发完整安全头,不受影响。
nitroHeaders: false,
},
headers: {
contentSecurityPolicy: {
'script-src': [
"'self'",
"'strict-dynamic'",
"'nonce-{{nonce}}'",
"'wasm-unsafe-eval'",
...allowedDomains // 允许 Umami 发送数据
],
'connect-src': [
"'self'",
...allowedDomains,// 允许 Umami 发送数据
],
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
'img-src': ["'self'", 'https://s0.wp.com', 'data:', 'https:'],
'font-src': ["'self'", 'https:', 'data:'],
}
}
},
sitemap: {
sources: [
'/api/__sitemap__/urls',
]
}
})
+49
View File
@@ -0,0 +1,49 @@
{
"name": "frontend-ssr",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "pnpm run build && wrangler dev",
"postinstall": "nuxt prepare",
"deploy": "pnpm run build && wrangler deploy",
"cf-typegen": "wrangler types"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@element-plus/nuxt": "^1.1.5",
"@nuxtjs/robots": "^6.1.3",
"@nuxtjs/sitemap": "^8.3.2",
"@nuxtjs/tailwindcss": "6.14.0",
"@shikijs/core": "^4.4.1",
"@shikijs/engine-javascript": "^4.4.1",
"@sindresorhus/slugify": "^3.0.0",
"@tailwindcss/vite": "^4.3.3",
"@vueuse/core": "^14.4.0",
"@vueuse/nuxt": "^14.4.0",
"better-sqlite3": "^12.11.1",
"element-plus": "^2.14.3",
"github-markdown-css": "^5.9.0",
"github-slugger": "^2.0.0",
"is-ip": "^5.0.1",
"markdown-it": "^14.3.0",
"markdown-it-anchor": "^9.2.1",
"nuxt": "^4.5.1",
"nuxt-security": "2.6.0",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@shikijs/langs": "^4.4.1",
"@shikijs/markdown-it": "^4.4.1",
"@shikijs/themes": "^4.4.1",
"@shikijs/types": "^4.4.1",
"@types/markdown-it": "^14.1.2",
"nitro-cloudflare-dev": "^0.2.2",
"sass-embedded": "^1.100.0",
"shiki": "^4.4.1",
"wrangler": "^4.118.0"
}
}
+10876
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
allowBuilds:
'@parcel/watcher': true
better-sqlite3: true
esbuild: true
sharp: true
vue-demi: true
workerd: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 941 B

After

Width:  |  Height:  |  Size: 941 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff. Show more