Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8ec3757fd | ||
|
|
d398c4085c | ||
|
|
d04a90b20c | ||
|
|
92a8c1f308 |
No files matched your search
@@ -32,6 +32,11 @@ ipw-cn/
|
||||
├── 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分支
|
||||
|
||||
## 技术栈
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- `ipdb`:IP 数据库开关(首次启动自动下载约 200MB,之后每 24h 更新)
|
||||
- `cors`:允许的请求来源(逗号分隔)
|
||||
- `access-token`:API 访问令牌,留空则不启用鉴权
|
||||
- `ws-url`:WS 通道地址——接入独立中间件的 WebSocket 地址(如 `"wss://middleware-1.api-ipw.wsmdn.top/ws"`,须含 `wss://` 前缀与 `/ws` 路径);支持**逗号分隔多备**(第一个为主,主故障自动切换下一个);留空 = 不启用 WS 客户端,走原 HTTP 接口
|
||||
- `ws-url`:WS 通道地址——接入独立中间件的 WebSocket 地址(如 `"wss://middleware-1.api-ipw.wsmdn.top/ws"`,须含 `wss://` 前缀与 `/ws` 路径);支持**逗号分隔多个中间件,同时连接全部(多活)**,任一断开只重连自己,不影响其他连接;留空 = 不启用 WS 客户端,走原 HTTP 接口
|
||||
- `node-id`:WS 节点 id(与中间件 `apiKeys`/`wsKeys` 键、前端配置的节点 `id` 一致;建议用 UUID 唯一标识)
|
||||
- `node-key`:WS 注册 key(与中间件 `wsKeys[节点id]` 一致;**必填**——节点未配置该 key 时中间件拒绝注册并返回 401)
|
||||
|
||||
@@ -181,7 +181,7 @@ WS 通道涉及**中间件(服务端)**与**后端节点(客户端)**两
|
||||
|------|--------|----------|------|
|
||||
| 中间件(服务端) | `ws-port` | `WS_PORT` | WS 监听端口,默认 `8092`,`0`=关闭;节点连 `ws(s)://<中间件>:<ws-port>/ws` |
|
||||
| 中间件(服务端) | `wsKeys` | `WSKEYS` | WS 注册校验表:`{"<node-id>": "<key>"}`,节点 `register` 的 key 必须匹配 |
|
||||
| 节点(客户端) | `ws-url` | `WS_URL` | 中间件 WS 完整地址(含 `/ws` 路径),逗号分隔多备主备切换 |
|
||||
| 节点(客户端) | `ws-url` | `WS_URL` | 中间件 WS 完整地址(含 `/ws` 路径),逗号分隔多个则**同时连接全部(多活)** |
|
||||
| 节点(客户端) | `node-id` | `NODE_ID` | 节点 id,与中间件 `apiKeys`/`wsKeys` 键一致(建议 UUID) |
|
||||
| 节点(客户端) | `node-key` | `NODE_KEY` | 注册 key,与中间件 `wsKeys[节点id]` 一致,**必填** |
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ middleware-go 内置 WS 服务端,后端节点可作为 **WS 客户端**连入
|
||||
|
||||
### 节点侧接入(WS 客户端)
|
||||
|
||||
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选),配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册)
|
||||
1. 连接 `ws://<中间件IP>:8092/ws`(本仓库后端节点接入方式见 [后端节点部署 - WS 通道接入](/guide/deploy-node#ws-通道接入可选),配置 `WS_URL` / `NODE_ID` / `NODE_KEY` 即可自动连接注册;`WS_URL` 支持逗号分隔多个中间件地址,节点会**同时连接全部(多活)**,任一断开只重连自己)
|
||||
2. 首条消息发 `register { "nodeId": "<与中间件配置一致的节点 id>", "key": "<注册凭证>" }`(节点在 setting.json `wsKeys` 里配了 key 就必须传对;未配置 key 的开放节点可不传)
|
||||
3. 收到 `probe` → 执行拨测 → 回 `probe_result`(必须携带原 `requestId`,支持乱序返回,同一连接可并发多个拨测)
|
||||
4. 收到 `ping` → 回 `pong`;`status` 可忽略或记录
|
||||
|
||||
@@ -84,7 +84,7 @@ sudo bash install.sh
|
||||
|
||||
**WS 接入特殊规则**:
|
||||
|
||||
- `WS_URL` 留空时自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址时须为**完整路径**(含 `wss://` 前缀与 `/ws` 路径,如 `wss://host:8092/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(中间件 `wsKeys` 必须包含该节点,否则注册被拒 401);安装完成后脚本会提示把 `"<NODE_ID>": "<NODE_KEY>"` 加入中间件 setting.json 的 `wsKeys`
|
||||
|
||||
@@ -118,7 +118,7 @@ NODE_KEY=<注册key,与中间件 wsKeys[节点id] 一致;节点未配置 key
|
||||
./lemonipw
|
||||
```
|
||||
|
||||
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://主:8092/ws,ws://备:8092/ws`),第一个为主,主故障自动切换下一个
|
||||
- `WS_URL` 支持**逗号分隔多个中间件**(`ws://中间件1:8092/ws,ws://中间件2:8092/ws`),**同时连接全部(多活)**:每个地址独立注册并保持连接,任一断开只重连自己,不影响其他中间件;注意多个地址指向同一中间件实例时,同 `nodeId` 后注册的连接会顶掉先前的
|
||||
- **双向心跳**:节点每 10s 发 ping,中间件回 pong;心跳发送连续失败 3 次判定中间件不可用,断开后 3s 重试(注册被拒 30s 重试)
|
||||
- 收到 `probe` 后节点直调探针函数(带缓存)并回 `probe_result`,结果与 HTTP 通道一致
|
||||
- 节点注册带 `NODE_KEY`:中间件 `wsKeys` 里配了 key 就必须传对,否则返回 401 并断开;未配置 key 的节点开放注册
|
||||
|
||||
@@ -48,7 +48,7 @@ sudo bash install.sh
|
||||
|
||||
**WS 通道接入规则**(选 `y` 时):
|
||||
|
||||
- `WS_URL` 留空自动使用默认值 `wss://middleware-1.api-ipw.wsmdn.top/ws`;填写自己的中间件地址须为完整路径(`wss://host:8092/ws` 形式)
|
||||
- `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>"` 加入中间件 `wsKeys`
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { defineEventHandler, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
|
||||
import { config } from '../../../config/index'
|
||||
import { useMiddlewareFetch } from '../../../utils/middleware'
|
||||
|
||||
// GET /ip/<ip> → text/plain 输出指定 IP 的归属地(支持 IPv4 / IPv6,如 /ip/2001:db8::1)
|
||||
//
|
||||
// IP: 2001:db8::1
|
||||
// bilibili: 中国 江苏 南京 电信
|
||||
// geocn: 江苏省 南京市 江宁区 电信 宽带
|
||||
// ip2region: 中国 江苏 南京 电信
|
||||
// maxmind_city: 中国 江苏 南京
|
||||
// maxmind_asn: AS4134 中国电信
|
||||
// qqwry: 中国 江苏省 南京市 电信
|
||||
//
|
||||
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
|
||||
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
|
||||
|
||||
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
function checkRateLimit(ip: string, limit: number): boolean {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip)
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
|
||||
// 顺带清理过期条目,避免 Map 无限增长
|
||||
if (rateLimitMap.size > 1000) {
|
||||
for (const [k, v] of rateLimitMap) {
|
||||
if (v.resetAt <= now) rateLimitMap.delete(k)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= limit
|
||||
}
|
||||
|
||||
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
|
||||
function objectToText(obj: any): string {
|
||||
const parts: string[] = []
|
||||
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
|
||||
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
// ASN 编号(如 "AS4134")
|
||||
if (typeof obj.asn === 'string' && obj.asn) {
|
||||
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
|
||||
}
|
||||
// 运营商 / 组织 / 类型
|
||||
for (const k of ['isp', 'org', 'type']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
return [...new Set(parts)].join(' ')
|
||||
}
|
||||
|
||||
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
|
||||
function formatLocationText(data: any, ip: string): string {
|
||||
const lines: string[] = [`IP: ${ip}`]
|
||||
const sources: Array<[string, any]> = [
|
||||
['bilibili', data?.bilibili],
|
||||
['geocn', data?.geocn],
|
||||
['ip2region', data?.ip2region],
|
||||
['maxmind_city', data?.maxmind_city],
|
||||
['maxmind_asn', data?.maxmind_asn],
|
||||
['dbip_city', data?.dbip_city],
|
||||
['dbip_asn', data?.dbip_asn],
|
||||
['qqwry', data?.qqwry],
|
||||
]
|
||||
for (const [name, value] of sources) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (typeof value === 'string') {
|
||||
// ip2region 为 "国家|省份|城市|运营商" 管道分隔格式
|
||||
if (value.includes('|')) {
|
||||
const text = value.split('|').filter(Boolean).join(' ')
|
||||
if (text) lines.push(`${name}: ${text}`)
|
||||
} else if (value && value !== 'not loaded') {
|
||||
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
|
||||
lines.push(`${name}: ${value}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const text = objectToText(value)
|
||||
if (text) lines.push(`${name}: ${text}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
|
||||
async function locate(event: any, ip: string) {
|
||||
const backends = config.IPLocationAPI
|
||||
if (!backends || backends.length === 0) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
|
||||
}
|
||||
let lastError: any = null
|
||||
for (const backend of backends) {
|
||||
const path = `/middleware/${backend.id}/location/${ip}`
|
||||
const { data, error, execute } = useMiddlewareFetch(path)
|
||||
await execute()
|
||||
if (error.value) {
|
||||
lastError = error.value
|
||||
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
|
||||
continue
|
||||
}
|
||||
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
|
||||
const res = data.value
|
||||
if (res && typeof res === 'object' && (res as any).ip) {
|
||||
return formatLocationText(res, ip)
|
||||
}
|
||||
lastError = new Error('invalid location response')
|
||||
}
|
||||
|
||||
// 全部后端失败:透传最后一次错误(text/plain)
|
||||
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
|
||||
const status = typeof errStatus === 'number' ? errStatus : 502
|
||||
setResponseStatus(event, status)
|
||||
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 0) 统一输出 text/plain(不是 JSON)
|
||||
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
||||
|
||||
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
|
||||
const siteOrigin = new URL(config.siteUrl).origin
|
||||
const origin = getRequestHeader(event, 'origin')
|
||||
const referer = getRequestHeader(event, 'referer')
|
||||
if (origin && origin !== siteOrigin) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
|
||||
}
|
||||
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
|
||||
}
|
||||
|
||||
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
|
||||
const rateLimit = config.rateLimitPerMinute || 0
|
||||
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
|
||||
}
|
||||
|
||||
// 3) 取路径参数 IP
|
||||
const ipParam: string = event.context.params?.ip as any
|
||||
let ip = ipParam ? ipParam.trim() : ''
|
||||
if (!ip) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing IP parameter' })
|
||||
}
|
||||
|
||||
return locate(event, ip)
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { defineEventHandler, getQuery, createError, setResponseStatus, setResponseHeader, getRequestHeader, getRequestIP } from 'h3'
|
||||
import { config } from '../../../config/index'
|
||||
import { useMiddlewareFetch } from '../../../utils/middleware'
|
||||
|
||||
// GET /ip → text/plain 输出 IP 归属地(?ip=<ip> 优先,否则查请求方 IP)
|
||||
//
|
||||
// IP: 1.2.3.4
|
||||
// bilibili: 中国 江苏 南京 电信
|
||||
// geocn: 江苏省 南京市 江宁区 电信 宽带
|
||||
// ip2region: 中国 江苏 南京 电信
|
||||
// maxmind_city: 中国 江苏 南京
|
||||
// maxmind_asn: AS4134 中国电信
|
||||
// qqwry: 中国 江苏省 南京市 电信
|
||||
//
|
||||
// 复用 utils/middleware.ts 的 useMiddlewareFetch():候选依次为 config.Middleware 外部中间层 + 前端自带中间件兜底,
|
||||
// 仅当节点无法连接或上游 401/403/418/502 时切换;IPLocationAPI 配置了多个后端时逐个重试。
|
||||
|
||||
// ---- 简单限流(每 IP 每分钟次数上限,进程内内存计数;limit 0 表示不限流) ----
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
function checkRateLimit(ip: string, limit: number): boolean {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip)
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 })
|
||||
// 顺带清理过期条目,避免 Map 无限增长
|
||||
if (rateLimitMap.size > 1000) {
|
||||
for (const [k, v] of rateLimitMap) {
|
||||
if (v.resetAt <= now) rateLimitMap.delete(k)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= limit
|
||||
}
|
||||
|
||||
// ---- 文本美化:把单个数据源的归属地对象拼成可读字符串 ----
|
||||
function objectToText(obj: any): string {
|
||||
const parts: string[] = []
|
||||
// 位置字段(按国家 → 省/区域 → 城市 → 区县)
|
||||
for (const k of ['country', 'administrative_area', 'region', 'city', 'district', 'province']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
// ASN 编号(如 "AS4134")
|
||||
if (typeof obj.asn === 'string' && obj.asn) {
|
||||
parts.push(obj.asn.startsWith('AS') ? obj.asn : `AS${obj.asn}`)
|
||||
}
|
||||
// 运营商 / 组织 / 类型
|
||||
for (const k of ['isp', 'org', 'type']) {
|
||||
const v = obj[k]
|
||||
if (typeof v === 'string' && v) parts.push(v)
|
||||
}
|
||||
return [...new Set(parts)].join(' ')
|
||||
}
|
||||
|
||||
// ---- 文本美化:多数据源逐行输出(跳过后端未加载的库) ----
|
||||
function formatLocationText(data: any, ip: string): string {
|
||||
const lines: string[] = [`IP: ${ip}`]
|
||||
const sources: Array<[string, any]> = [
|
||||
['bilibili', data?.bilibili],
|
||||
['geocn', data?.geocn],
|
||||
['ip2region', data?.ip2region],
|
||||
['maxmind_city', data?.maxmind_city],
|
||||
['maxmind_asn', data?.maxmind_asn],
|
||||
['dbip_city', data?.dbip_city],
|
||||
['dbip_asn', data?.dbip_asn],
|
||||
['qqwry', data?.qqwry],
|
||||
]
|
||||
for (const [name, value] of sources) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (typeof value === 'string') {
|
||||
// ip2region 为 "国家|省份|城市|运营商" 管道分隔格式
|
||||
if (value.includes('|')) {
|
||||
const text = value.split('|').filter(Boolean).join(' ')
|
||||
if (text) lines.push(`${name}: ${text}`)
|
||||
} else if (value && value !== 'not loaded') {
|
||||
// 纯字符串结果(error 等)原样输出,"not loaded" 跳过
|
||||
lines.push(`${name}: ${value}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const text = objectToText(value)
|
||||
if (text) lines.push(`${name}: ${text}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ---- 核心:查询指定 IP 的归属地(IPLocationAPI 多后端逐个重试) ----
|
||||
async function locate(event: any, ip: string) {
|
||||
const backends = config.IPLocationAPI
|
||||
if (!backends || backends.length === 0) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'No location backend configured' })
|
||||
}
|
||||
let lastError: any = null
|
||||
for (const backend of backends) {
|
||||
const path = `/middleware/${backend.id}/location/${ip}`
|
||||
const { data, error, execute } = useMiddlewareFetch(path)
|
||||
await execute()
|
||||
if (error.value) {
|
||||
lastError = error.value
|
||||
console.error(`[ip] backend "${backend.label}" failed for ${ip}:`, error.value?.message || error.value)
|
||||
continue
|
||||
}
|
||||
// 成功:location 响应必含 ip 字段;缺失视为失败(如误返回错误体),换下一个后端
|
||||
const res = data.value
|
||||
if (res && typeof res === 'object' && (res as any).ip) {
|
||||
return formatLocationText(res, ip)
|
||||
}
|
||||
lastError = new Error('invalid location response')
|
||||
}
|
||||
|
||||
// 全部后端失败:透传最后一次错误(text/plain)
|
||||
const errStatus = lastError?.status ?? lastError?.statusCode ?? 502
|
||||
const status = typeof errStatus === 'number' ? errStatus : 502
|
||||
setResponseStatus(event, status)
|
||||
return `Error ${status}: ${lastError?.message || 'Backend unreachable'}`
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 0) 统一输出 text/plain(不是 JSON)
|
||||
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
||||
|
||||
// 1) Origin / Referer 校验:跨域浏览器调用一律拒绝
|
||||
const siteOrigin = new URL(config.siteUrl).origin
|
||||
const origin = getRequestHeader(event, 'origin')
|
||||
const referer = getRequestHeader(event, 'referer')
|
||||
if (origin && origin !== siteOrigin) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: cross-origin request' })
|
||||
}
|
||||
if (referer && referer !== siteOrigin && !referer.startsWith(siteOrigin + '/')) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden: invalid referer' })
|
||||
}
|
||||
|
||||
// 2) 每 IP 每分钟限流(次数取 config.rateLimitPerMinute,0 表示不限流)
|
||||
const rateLimit = config.rateLimitPerMinute || 0
|
||||
if (rateLimit > 0 && !checkRateLimit(getRequestIP(event) || 'unknown', rateLimit)) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
|
||||
}
|
||||
|
||||
// 3) 确定要查询的 IP:?ip=<ip> 优先,否则查请求方 IP
|
||||
const query = getQuery(event)
|
||||
let ip = typeof query.ip === 'string' ? query.ip.trim() : ''
|
||||
if (!ip) {
|
||||
ip = getRequestIP(event, { xForwardedFor: true }) || ''
|
||||
}
|
||||
ip = ip.trim()
|
||||
if (!ip) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Unable to determine IP address' })
|
||||
}
|
||||
|
||||
return locate(event, ip)
|
||||
})
|
||||
@@ -64,9 +64,12 @@ export function useMiddlewareFetch<T = any>(url: MaybeRefOrGetter<string>, optio
|
||||
try {
|
||||
for (let i = 0; i < candidates.value.length; i++) {
|
||||
if (id !== requestId) return undefined // 已被更新的请求取代
|
||||
const candidate = candidates.value[i]
|
||||
// noUncheckedIndexedAccess 下索引访问为 string | undefined,防御性跳过
|
||||
if (candidate === undefined) continue
|
||||
try {
|
||||
// $fetch 为 Nuxt 全局注入;候选失败会抛 FetchError(含 status)
|
||||
const res = await $fetch<T>(candidates.value[i], fetchOptions)
|
||||
const res = await $fetch<T>(candidate, fetchOptions)
|
||||
if (id !== requestId) return undefined
|
||||
data.value = res
|
||||
return res
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ NODE_KEY=""
|
||||
WS_USED_DEFAULT=""
|
||||
case "${WS_CHOICE,,}" in
|
||||
y|yes)
|
||||
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=默认 wss://middleware-1.api-ipw.wsmdn.top/ws;逗号分隔可多备): " WS_URL
|
||||
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=贡献节点给柠檬 wss://middleware-1.api-ipw.wsmdn.top/ws;逗号分隔多个将同时连接全部): " WS_URL
|
||||
if [ -z "$WS_URL" ]; then
|
||||
WS_URL="wss://middleware-1.api-ipw.wsmdn.top/ws"
|
||||
WS_USED_DEFAULT="true"
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
//
|
||||
// 配置(readConfig 解析,环境变量优先,其次 setting.json,远端配置可覆盖):
|
||||
//
|
||||
// WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,主失败自动切换下一个;不配置 = 不启用)
|
||||
// WS_URL ws://<中间件>:8092/ws,ws://<备中间件>:8092/ws (逗号分隔多个中间件,同时连接全部,多活;不配置 = 不启用)
|
||||
// NODE_ID 节点 id(与中间件 wsKeys 键 / 前端配置 id 一致)
|
||||
// NODE_KEY 注册 key(与中间件 wsKeys[节点id] 一致;中间件未配置该节点 key 时留空)
|
||||
//
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
// 返回处理在本文件内完成,结果经 probe_result 上报。节点 HTTP 接口完全不变。
|
||||
//
|
||||
// 心跳:NODE 与 MIDDLEWARE 双向心跳。本端每 10s 发一次 ping,发送失败重试,连续失败 3 次
|
||||
// 判定当前中间件不可用,断开并切换下一个 WS_URL;收到中间件 ping 回 pong。
|
||||
// 判定当前中间件不可用,断开该连接并按同一 URL 独立重连(不影响其他中间件连接)。
|
||||
|
||||
// wsProbe 学 HTTP handler:从函数拿数据 + 缓存,返回 (status, body)。
|
||||
// raw 与 query 来自中间件 probe 消息(等价于路由参数)。
|
||||
@@ -360,8 +360,8 @@ const (
|
||||
wsHeartbeatTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// wsClientOnce 连接并服务单个中间件 URL,返回下次重试前的等待时长:
|
||||
// - 连接/读循环断开 → 3s(切换下一个中间件)
|
||||
// wsClientOnce 连接并服务单个中间件 URL,返回下次重试前的等待时长(由 per-URL 连接循环调用):
|
||||
// - 连接/读循环断开 → 3s(重试同一 URL)
|
||||
// - 注册被拒(register_error)→ 30s(多为 key 配置错误,加大间隔避免空转刷日志)
|
||||
// - 心跳连续失败 wsHeartbeatRetry 次 → 主动断开,3s
|
||||
// - 返回 0 = 配置缺失(不再重试)
|
||||
@@ -399,7 +399,7 @@ func wsClientOnce(url string) time.Duration {
|
||||
}
|
||||
slog.Info("ws client registered", "nodeId", WS_NODE_ID, "url", url)
|
||||
|
||||
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(上层切换下一个中间件)
|
||||
// 心跳 goroutine:每 10s 发 ping,失败重试,连续 3 次失败主动断开(per-URL 循环重连同一中间件)
|
||||
stopHeartbeat := make(chan struct{})
|
||||
go func() {
|
||||
fail := 0
|
||||
@@ -413,7 +413,7 @@ func wsClientOnce(url string) time.Duration {
|
||||
fail++
|
||||
slog.Warn("ws heartbeat send failed", "url", url, "fail", fail, "error", err)
|
||||
if fail >= wsHeartbeatRetry {
|
||||
slog.Warn("ws heartbeat failed too many times, switching middleware", "url", url)
|
||||
slog.Warn("ws heartbeat failed too many times, closing connection (will retry same url)", "url", url)
|
||||
c.Close(websocket.StatusPolicyViolation, "heartbeat timeout")
|
||||
return
|
||||
}
|
||||
@@ -445,23 +445,35 @@ func wsClientOnce(url string) time.Duration {
|
||||
}
|
||||
}
|
||||
|
||||
// wsClientLoop 主循环:依次尝试所有配置的中间件 URL(逗号分隔,第一个为主),
|
||||
// 当前中间件连接失败或心跳超时 → 3s 后切换下一个;注册被拒 → 30s;全部失败循环重试。
|
||||
// wsClientLoop 同时连接所有配置的中间件 URL(逗号分隔,多活):
|
||||
// 每个 URL 由独立 goroutine 负责,各自注册并保持连接,任一断开只重连自己,不影响其他中间件。
|
||||
// 注意:多个 URL 指向同一中间件实例时,中间件按 nodeId 单连接,后注册的连接会顶掉先前的。
|
||||
func wsClientLoop() {
|
||||
urls := splitWSURLs(WS_URL)
|
||||
if len(urls) == 0 {
|
||||
slog.Info("ws client disabled (WS_URL not set)")
|
||||
return
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for _, url := range urls {
|
||||
wg.Add(1)
|
||||
go func(u string) {
|
||||
defer wg.Done()
|
||||
wsClientOnceLoop(u)
|
||||
}(url)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// wsClientOnceLoop 单个中间件 URL 的持续连接循环:连接 → 注册 → 心跳/读循环 → 断开重试
|
||||
func wsClientOnceLoop(url string) {
|
||||
for {
|
||||
for _, url := range urls {
|
||||
retryDelay := wsClientOnce(url)
|
||||
if retryDelay == 0 {
|
||||
slog.Info("ws client stopped")
|
||||
return
|
||||
}
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay := wsClientOnce(url)
|
||||
if retryDelay == 0 {
|
||||
slog.Info("ws client stopped", "url", url)
|
||||
return
|
||||
}
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user