import axios from 'axios'
import https from 'https'
import http from 'http'
import net from 'net'
import get from 'lodash-es/get.js'
import size from 'lodash-es/size.js'
import each from 'lodash-es/each.js'
import map from 'lodash-es/map.js'
import trim from 'lodash-es/trim.js'
import values from 'lodash-es/values.js'
import evem from 'wsemi/src/evem.mjs'
import sep from 'wsemi/src/sep.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import iseobj from 'wsemi/src/iseobj.mjs'
import cint from 'wsemi/src/cint.mjs'
import cstr from 'wsemi/src/cstr.mjs'
import waitFun from 'wsemi/src/waitFun.mjs'
import provideServer from './provideServer.mjs'
//_reqTimeout: axios 1.20+ 對經proxy之請求其內建timeout不生效(實測連不上之代理會卡到系統TCP connect timeout約21s而非設定值, 直連則正常), 大量無效代理逐一卡死會使整體檢測逾時; 故經proxy之請求一律附加AbortSignal.timeout強制中斷, 另保留timeout供直連情形與未來相容
function _reqTimeout(ms) {
return {
timeout: ms,
signal: AbortSignal.timeout(ms),
}
}
/**
* 抓取代理伺服器
*
* @class
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {String} [opt.src='https://api.proxyscrape.com/v2/?request=displayproxies&protocol=http&timeout=5000&anonymity=all'] 輸入取得代理伺服器API字串,預設'https://api.proxyscrape.com/v2/?request=displayproxies&protocol=http&timeout=5000&anonymity=all'
* @param {String} [opt.tar='https://httpbin.org/ip'] 輸入檢測目標網址字串,預設'https://httpbin.org/ip'
* @param {Integer} [opt.timeGetProxies=30*1000] 輸入輪循抓取代理伺服器時間間隔整數,單位ms,預設30*1000
* @param {Integer} [opt.timeTestProxies=60*1000] 輸入輪循測試代理伺服器時間間隔整數,單位ms,預設60*1000
* @param {Boolean} [opt.anonymityCheck=true] 輸入是否對代理做匿名性檢測布林值,開啟時會先查詢本機真實公網IP(依序嘗試httpbin.org、ipify、checkip.amazonaws.com、icanhazip.com),再透過代理打https://httpbin.org/get驗證目標端所見之origin與X-Forwarded-For、X-Real-IP、Forwarded等標頭皆不含本機真實IP,且不帶Via、Proxy-Connection等暴露代理存在之標頭;若本機真實IP查詢全部失敗則視為無法驗證,該回合所有代理皆不放行(fail-closed),預設true
* @param {Integer} [opt.anonymityCheckRepeats=5] 輸入匿名性檢測重複次數整數,各次以獨立連線送出並於時間上分散(間隔anonymityCheckInterval),用以偵測負載平衡型代理呈時間窗之機率性洩漏,任一次洩漏即判定失敗,預設5
* @param {Integer} [opt.anonymityCheckInterval=2000] 輸入匿名性檢測各次之間隔時間整數,單位ms,預設2000
* @param {Boolean} [opt.withServer=false] 輸入是否創建供數據供給伺服器布林值,預設false
* @param {Integer} [opt.serverPort=8080] 輸入創建伺服器所用port整數,預設8080
* @param {String} [opt.serverApiName='getProxies'] 輸入供給數據API名稱字串,預設'getProxies'
* @param {Array} [opt.serverCorsOrigins=['*']] 輸入允許跨域網域陣列,若給予['*']代表允許全部,預設['*']
* @returns {Object} 回傳事件物件,提供函數getProxies(等待至有有效代理)與getProxiesOnce(抓取並檢測一回合後回傳當前有效代理,可能為空陣列),可監聽事件getRawProxies、add、delete、change
* @example
*
* import _ from 'lodash-es'
* import wip from './src/WIpProxy.mjs'
*
* let wo = wip({
* // tar: `https://www.google.com`,
* withServer: true,
* serverPort: 9000,
* serverCorsOrigins: ['*'],
* })
* wo.on('getRawProxies', (prxsRaw) => {
* console.log(`已抓取公開代理共 ${_.size(prxsRaw)} 個...`)
* })
* wo.on('add', (p, proxies) => {
* // console.log('新加入代理', { host: p.host, port: p.port }, _.map(proxies, 'proxy'))
* })
* wo.on('delete', (p, proxies) => {
* // console.log('刪除代理', { host: p.host, port: p.port }, _.map(proxies, 'proxy'))
* })
* wo.on('change', (proxies) => {
* // console.log(`有效代理`, _.map(proxies, 'proxy'))
* console.log(`已檢測有效代理共 ${_.size(proxies)} 個...`)
* })
*
* //browser view: http://localhost:9000/getProxies
*
*/
function WIpProxy(opt = {}) {
//src
let src = get(opt, 'src', '')
if (!isestr(src)) {
src = 'https://api.proxyscrape.com/v2/?request=displayproxies' +
`&protocol=http` +
// `&protocol=https` +
`&timeout=5000` +
// `&country=${join(['tw', 'cn', 'hk', 'jp', 'kr', 'sg', 'my', 'in', 'id', 'th', 'vn'])}` +
// `&ssl=yes` +
`&anonymity=elite` //elite匿名代理, all會使用X-Forwarded-For儲存原本ip導致被識別
}
//tar
let tar = get(opt, 'tar', '')
if (!isestr(tar)) {
tar = 'https://httpbin.org/ip' //'https://www.google.com'
}
//timeGetProxies
let timeGetProxies = get(opt, 'timeGetProxies')
if (!ispint(timeGetProxies)) {
timeGetProxies = 30 * 1000 //30s
}
timeGetProxies = cint(timeGetProxies)
//timeTestProxies
let timeTestProxies = get(opt, 'timeTestProxies')
if (!ispint(timeTestProxies)) {
timeTestProxies = 60 * 1000 //1min
}
timeTestProxies = cint(timeTestProxies)
//anonymityCheck
let anonymityCheck = get(opt, 'anonymityCheck')
if (!isbol(anonymityCheck)) {
anonymityCheck = true
}
//anonymityCheckRepeats
let anonymityCheckRepeats = get(opt, 'anonymityCheckRepeats')
if (!ispint(anonymityCheckRepeats)) {
anonymityCheckRepeats = 5
}
anonymityCheckRepeats = cint(anonymityCheckRepeats)
//anonymityCheckInterval
let anonymityCheckInterval = get(opt, 'anonymityCheckInterval')
if (!ispint(anonymityCheckInterval)) {
anonymityCheckInterval = 2000 //2s
}
anonymityCheckInterval = cint(anonymityCheckInterval)
//withServer
let withServer = get(opt, 'withServer')
if (!isbol(withServer)) {
withServer = false
}
//serverPort
let serverPort = get(opt, 'serverPort')
if (!ispint(serverPort)) {
serverPort = 8080
}
serverPort = cint(serverPort)
//serverApiName
let serverApiName = get(opt, 'serverApiName')
if (!isestr(serverApiName)) {
serverApiName = 'getProxies'
}
//serverCorsOrigins
let serverCorsOrigins = get(opt, 'serverCorsOrigins', [])
if (!isearr(serverCorsOrigins)) {
serverCorsOrigins = ['*']
}
//prxsRaw, kpPrx
let prxsRaw = []
let kpPrx = {}
//_normalizeIp: 正規化單一IP字串, 去除[]包裹, ipv4之port, 及ipv4-mapped-ipv6之'::ffff:'前綴
function _normalizeIp(v) {
let s = trim(v)
if (s === '') {
return ''
}
if (s[0] === '[') {
//形如'[::1]:8080'或'[2001:db8::1]'
let k = s.indexOf(']')
if (k > 0) {
s = s.slice(1, k)
}
}
else if (s.split(':').length === 2) {
//僅單一冒號才視為'1.2.3.4:port', 多冒號為ipv6不可截斷
s = s.split(':')[0]
}
//ipv4-mapped-ipv6, 如'::ffff:1.2.3.4'或無括號帶port之'::ffff:1.2.3.4:5678', 統一還原為ipv4
let m = s.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})(?::\d+)?$/i)
if (m) {
s = m[1]
}
return s
}
//_originHasIp: 檢查origin或標頭值(如'ip1, ip2', 'for=1.2.3.4;proto=https', '"[::1]:443"')內是否含有指定IP, 以IP可用字元之連續段切token後逐一正規化精確比對
function _originHasIp(origin, ip) {
if (!isestr(ip) || !isestr(origin)) {
return false
}
let vs = map(origin.split(/[^0-9a-fA-F:.[\]]+/), (v) => _normalizeIp(v))
return vs.indexOf(ip) >= 0
}
//myRealIpSrcs: 查詢本機真實公網IP之來源, 依序嘗試, 任一成功即止, 避免單一服務(httpbin.org)逾時或限流時匿名性檢測失去比對基準
let myRealIpSrcs = [
{ url: 'https://httpbin.org/ip', parse: (data) => get(data, 'origin', '') },
{ url: 'https://api.ipify.org?format=json', parse: (data) => get(data, 'ip', '') },
{ url: 'https://checkip.amazonaws.com', parse: (data) => data },
{ url: 'https://icanhazip.com', parse: (data) => data },
]
//myRealIp: 本機真實公網IP, 用於匿名性檢測時比對代理egress是否洩漏
let myRealIp = ''
let myRealIpReady = false
let myRealIpPm = null
async function _fetchMyRealIp() {
if (myRealIpReady) {
return
}
if (!myRealIpPm) {
myRealIpPm = (async () => {
//最多兩輪, 每輪依序嘗試各來源
for (let round = 0; round < 2 && !isestr(myRealIp); round++) {
for (let k = 0; k < myRealIpSrcs.length; k++) {
let { url, parse } = myRealIpSrcs[k]
try {
let res = await axios.get(url, { timeout: 10000, responseType: 'text', transformResponse: [(v) => v] })
let raw = trim(get(res, 'data', ''))
let data = raw
if (raw[0] === '{') {
data = JSON.parse(raw)
}
let origin = parse(data)
if (isestr(origin)) {
//origin可能為"1.2.3.4", "1.2.3.4:port", 或"::ffff:1.2.3.4"(部分環境如雲端runner為ipv4-mapped-ipv6), 取首個逗號前再正規化
let ip = _normalizeIp(sep(origin, ',')[0])
//須為合法IP才採用: 若來源回200但內容非IP(如被劫持或回HTML), 誤採為真實IP會使比對永遠不中而無聲放行全部代理
if (net.isIP(ip) !== 0) {
myRealIp = ip
}
}
}
catch (err) {
//換下一個來源
}
if (isestr(myRealIp)) {
break
}
}
}
if (anonymityCheck && !isestr(myRealIp)) {
console.log('_fetchMyRealIp: unable to determine real ip, all proxies are rejected until it is available (anonymityCheck=true)')
}
myRealIpReady = true
})()
}
await myRealIpPm
}
//LEAK_IP_HEADERS: 目標端若見到這些標頭且值含本機真實IP即為洩漏
let LEAK_IP_HEADERS = [
'x-forwarded-for', 'x-real-ip', 'x-client-ip', 'client-ip',
'true-client-ip', 'cf-connecting-ip', 'forwarded',
'x-originating-ip', 'x-cluster-client-ip',
]
//LEAK_PROXY_HEADERS: 目標端若見到這些標頭即代表代理暴露自身存在, 非elite
let LEAK_PROXY_HEADERS = [
'via', 'proxy-connection', 'x-proxy-id',
]
async function _anonymityCheckCore(host, port) {
if (!anonymityCheck) {
return { ok: true }
}
if (!isestr(myRealIp)) {
//沒抓到自己IP就無從驗證匿名性, 不放行未經驗證之代理(fail-closed)
return { ok: false, reason: 'anonymity unverifiable: real ip unknown' }
}
//單次檢測: 使用/get一次取得origin與目標端所見標頭, 同時檢查IP洩漏與代理暴露
let checkOnce = async () => {
try {
let res = await axios.get('https://httpbin.org/get', {
proxy: { host, port, protocol: 'http' },
httpAgent: new http.Agent({ keepAlive: false }), //每次獨立連線, 避免keep-alive重用同一條連線而黏在負載平衡代理之同一後端, 使重複檢測失去獨立性
httpsAgent: new https.Agent({ rejectUnauthorized: false, keepAlive: false }),
..._reqTimeout(5000),
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36',
'Accept': 'application/json',
},
validateStatus: () => true,
})
if (res.status !== 200) {
return { ok: false, reason: `anonymity status[${res.status}]` }
}
let origin = get(res, 'data.origin', '')
let headers = get(res, 'data.headers', null)
if (!isestr(origin) || !iseobj(headers)) {
return { ok: false, reason: 'anonymity non-json' }
}
if (_originHasIp(origin, myRealIp)) {
return { ok: false, reason: `leaks real IP` }
}
let hs = {}
each(headers, (v, k) => {
hs[cstr(k).toLowerCase()] = cstr(v)
})
for (let k of LEAK_IP_HEADERS) {
if (k in hs && _originHasIp(hs[k], myRealIp)) {
return { ok: false, reason: `leaks real IP via ${k}` }
}
}
for (let k of LEAK_PROXY_HEADERS) {
if (k in hs) {
return { ok: false, reason: `exposes proxy via ${k}` }
}
}
return { ok: true }
}
catch (err) {
return { ok: false, reason: trim(err.message || '') }
}
}
//重複檢測於時間上分散: 負載平衡型代理之洩漏常呈數秒級時間窗(實測67.203.23.79:8081約9成時間洩漏, 僅1~2s短暫正常窗), 同一瞬間之多次取樣會一起落入正常窗而整組放行, 故各次相隔anonymityCheckInterval送出且各用獨立連線
//採順序執行而非並發: _testProxiesCore對全部代理同時檢測, 若每個代理再並發多次取樣, 總連線數暴增(實測百餘代理×4次會使本機/CI大量請求逾時, 有效代理歸零), 順序執行使單一代理同時僅一個連線, 總並發維持在代理數量級
//任一次失敗(洩漏或連線失敗)即判定失敗並提前結束
for (let i = 0; i < anonymityCheckRepeats; i++) {
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, anonymityCheckInterval))
}
let r = await checkOnce()
if (!r.ok) {
return r
}
}
return { ok: true }
}
async function _getProxiesCore(src) {
let ps = []
try {
//res, 直連proxyscrape, timeout生效; 設上限避免上游無回應時卡住整個getProxiesOnce
let res = await axios.get(src, { timeout: 15000 })
//c
let c = res.data
// console.log('c', c)
//sep
let ss = sep(c, '\n')
// console.log('ss', ss)
//ps
ps = map(ss, (v) => {
let [host, port] = v.split(':')
port = cint(port)
return {
proxy: v,
host,
port,
}
})
// console.log('ps', ps)
}
catch (err) {
console.log('_getProxiesCore catch', err.message)
}
// console.log('ps', ps)
return ps
}
let pm_getProxies = null
async function _getProxies() {
// console.log('call getProxies...')
let core = async() => {
//get
prxsRaw = await _getProxiesCore(src)
//emit
ev.emit('getRawProxies', prxsRaw)
}
//core, 若已有進行中之回合則共用同一promise, 讓呼叫端可等待其完成
if (pm_getProxies) {
return pm_getProxies
}
pm_getProxies = core()
.catch(() => {}) //吃掉錯誤
.finally(() => {
pm_getProxies = null
})
return pm_getProxies
}
async function _testProxyCore(host, port, url) {
//ret
let ret = (state, msg) => {
return {
state,
msg,
p: {
proxy: `${host}:${port}`,
host,
port,
},
}
}
//匿名性預檢: 先確認代理不會透過X-Forwarded-For等洩漏本機真實IP
let ac = await _anonymityCheckCore(host, port)
if (!ac.ok) {
return ret('error', ac.reason)
}
//r
let r = {}
try {
//agent
let agent = new https.Agent({ rejectUnauthorized: false }) //避免證書錯誤中止
//get
let res = await axios.get(url, {
proxy: {
host,
port,
protocol: 'http',
},
httpsAgent: agent,
..._reqTimeout(5000),
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36',
'Accept': 'application/json',
},
})
if (res.status === 200) {
r = ret('success', res.data)
}
else {
r = ret('error', `status[${res.status}] error`)
}
}
catch (err) {
r = ret('error', trim(err.message))
// if (r.msg.indexOf(`ssl3_get_record`) >= 0) {
// r.msg = `ssl3_get_record error`
// }
}
// console.log(r)
return r
}
async function _testProxiesCore(prxs) {
//pms
let pms = map(prxs, (p) => {
let pm = _testProxyCore(p.host, p.port, tar)
return pm
})
//rrs, allSettled
let rrs = await Promise.allSettled(pms)
// console.log('rrs', rrs)
//psValid, psInvalid
let psValid = []
let psInvalid = []
each(rrs, (v) => {
//be
let p = get(v, 'value.p', null)
let be = iseobj(p) && isestr(get(p, 'proxy'))
//check
if (!be) {
return true //跳出換下一個
}
//b
let status = get(v, 'status', '')
let b1 = status === 'fulfilled'
let state = get(v, 'value.state', '')
let b2 = state === 'success'
let b = b1 && b2
//push
if (b) {
psValid.push(p)
}
else {
psInvalid.push(p)
}
})
return {
psValid,
psInvalid,
}
}
let pm_testProxies = null
async function _testProxies() {
// console.log('call testProxies...')
let core = async() => {
//確保本機真實IP已取得, 供匿名性檢查比對
await _fetchMyRealIp()
//get
let r = await _testProxiesCore(prxsRaw)
//emit add
each(r.psValid, (p) => {
kpPrx[p.proxy] = p
ev.emit('add', p, values(kpPrx))
})
//emit delete
each(r.psInvalid, (p) => {
delete kpPrx[p.proxy]
ev.emit('delete', p, values(kpPrx))
})
//emit change
ev.emit('change', values(kpPrx))
}
//core, 若已有進行中之回合則共用同一promise, 讓呼叫端可等待其完成
if (pm_testProxies) {
return pm_testProxies
}
pm_testProxies = core()
.catch(() => {}) //吃掉錯誤
.finally(() => {
pm_testProxies = null
})
return pm_testProxies
}
function init() {
//取得代理清單
if (true) {
_getProxies()
setInterval(() => {
_getProxies()
}, timeGetProxies)
}
//過濾出有效代理清單, 延遲3s觸發, 給時間抓取
if (true) {
setTimeout(() => {
_testProxies()
setInterval(() => {
_testProxies()
}, timeTestProxies)
}, 3000)
}
}
async function getProxies() {
let vs
await waitFun(() => {
vs = values(kpPrx)
return size(vs) > 0
})
return vs
}
//ev
let ev = evem()
//withServer
if (withServer) {
provideServer(getProxies, {
port: serverPort,
apiName: serverApiName,
corsOrigins: serverCorsOrigins,
})
}
async function getProxiesOnce() {
await _getProxies()
await _testProxies()
//本回合已完整結束, 直接回傳當前結果(可能為空陣列), 不可再用getProxies等待, 否則無有效代理時會等到waitFun逾時(約200s)才reject
let ps = values(kpPrx)
return ps
}
//save
ev.init = init
ev.getProxies = getProxies
ev.getProxiesOnce = getProxiesOnce
return ev
}
export default WIpProxy