Files
BYW-VoiceHub/server/utils/ip-utils.ts
T

150 lines
3.8 KiB
TypeScript

import type { H3Event } from 'h3'
import { getHeaders } from 'h3'
import { isIP } from 'node:net'
const SUPPORTED_CLIENT_IP_HEADERS = new Set([
'eo-connecting-ip',
'eo-client-ip',
'x-edgeone-client-ip',
'edgeone-client-ip',
'cf-connecting-ip',
'true-client-ip',
'fastly-client-ip',
'fly-client-ip',
'x-nf-client-connection-ip',
'x-vercel-forwarded-for',
'x-azure-clientip',
'x-appengine-user-ip',
'x-cluster-client-ip',
'x-real-ip',
'x-client-ip',
'x-real-client-ip',
'x-forwarded-client-ip',
'x-original-forwarded-for',
'x-forwarded-for',
'x-forwarded',
'forwarded-for',
'forwarded'
])
// 未配置 TRUSTED_CLIENT_IP_HEADERS 时的回退头部列表,覆盖主流平台
const FALLBACK_CLIENT_IP_HEADERS = Object.freeze([
'x-forwarded-for',
'x-real-ip',
'x-vercel-forwarded-for',
'cf-connecting-ip',
'true-client-ip'
])
function getTrustedClientIPHeaders() {
const configured = String(process.env.TRUSTED_CLIENT_IP_HEADERS || '')
.split(',')
.map((header) => header.trim().toLowerCase())
.filter((header) => SUPPORTED_CLIENT_IP_HEADERS.has(header))
return configured.length > 0
? [...new Set(configured)]
: FALLBACK_CLIENT_IP_HEADERS
}
/**
* 获取客户端IP地址
* @param event H3Event对象
* @returns 客户端IP地址
*/
export function getClientIP(event: H3Event): string {
const headers = getHeaders(event)
// 只有部署者明确声明受信头部时才读取转发头,避免客户端直连源站时伪造 IP。
const ipHeaders = getTrustedClientIPHeaders()
for (const header of ipHeaders) {
const value = headers[header]
if (value) {
const values = Array.isArray(value) ? value : [value]
for (const item of values) {
for (const candidate of String(item).split(',')) {
const ip = normalizeIPCandidate(candidate)
if (ip) return ip
}
}
}
}
// 如果没有找到,直接从连接信息获取
// 从连接信息获取IP(备用方法)
const remoteAddress = event.node.req.socket?.remoteAddress
if (remoteAddress) {
// 移除IPv6映射的IPv4前缀
const cleanIP = normalizeIPCandidate(remoteAddress)
if (cleanIP) {
return cleanIP
}
}
return 'unknown'
}
function normalizeIPCandidate(value: string): string | null {
let candidate = value.trim().replace(/^"|"$/g, '')
const forwardedMatch = candidate.match(/^for\s*=\s*(.+)$/i)
if (forwardedMatch) {
const forwardedValue = forwardedMatch[1] || ''
candidate = (forwardedValue.split(';').shift() || '').trim().replace(/^"|"$/g, '')
}
if (candidate.startsWith('[')) {
const closingBracket = candidate.indexOf(']')
if (closingBracket > 0) candidate = candidate.slice(1, closingBracket)
} else if (isIP(candidate) === 0 && /^\d{1,3}(?:\.\d{1,3}){3}:\d+$/.test(candidate)) {
candidate = candidate.slice(0, candidate.lastIndexOf(':'))
}
const cleanIP = candidate.replace(/^::ffff:/i, '')
return isValidIP(cleanIP) ? cleanIP : null
}
/**
* 验证IP地址格式是否有效
* @param ip IP地址字符串
* @returns 是否为有效IP
*/
function isValidIP(ip: string): boolean {
// Node 原生解析器支持压缩 IPv6,避免大量合法地址被归入 unknown 共享限流桶。
if (isIP(ip) === 0) {
return false
}
// 排除明显无效的IP
if (ip === '0.0.0.0' || ip === '::') {
return false
}
return true
}
/**
* 格式化IP地址用于邮件显示
* @param ip IP地址
* @returns 格式化后的IP地址字符串
*/
export function formatIPForEmail(ip: string): string {
if (!ip || ip === 'unknown') {
return '未知'
}
// 如果是本地IP,显示为本地访问
if (
ip === '127.0.0.1' ||
ip === '::1' ||
ip.startsWith('192.168.') ||
ip.startsWith('10.') ||
ip.startsWith('172.')
) {
return `${ip} (本地网络)`
}
return ip
}