150 lines
3.8 KiB
TypeScript
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
|
|
}
|