♻️ Split dns resolver into two files
This commit is contained in:
parent
fab92b078b
commit
908c4871ed
4 changed files with 197 additions and 193 deletions
192
src/cdn/dns.ts
192
src/cdn/dns.ts
|
@ -1,192 +0,0 @@
|
||||||
/* --- DNS resolver for the CDN storing SWTOR's patch files ---
|
|
||||||
|
|
|
||||||
| The SWTOR launcher first connects to cdn-patch.swtor.com,
|
|
||||||
| there the traffic is split onto Akamai and Level3, and
|
|
||||||
| distributed to local points of presence.
|
|
||||||
| The master files appear to be stored on 159.153.92.51, and
|
|
||||||
| all CDNs are synchronized with that source.
|
|
||||||
|
|
|
||||||
| > cdn-patch.swtor.com
|
|
||||||
| -> gslb-patch.swtor.biowareonline.net
|
|
||||||
| -> cdn-patch.swtor.com.edgesuite.net (Akamai)
|
|
||||||
| | -> a56.d.akamai.net
|
|
||||||
| | -> 2.21.74.90
|
|
||||||
| | -> 2.21.74.98
|
|
||||||
| -> cdn-patch.swtor.com.c.footprint.net (Level3)
|
|
||||||
| -> eu.lvlt.cdn.ea.com.c.footprint.net (Europe)
|
|
||||||
| | -> 8.12.207.125
|
|
||||||
| | -> 8.253.37.126
|
|
||||||
| -> na.lvlt.cdn.ea.com.c.footprint.net (US)
|
|
||||||
| -> 205.128.74.252
|
|
||||||
| -> 4.23.36.253
|
|
||||||
| -> 192.221.105.254
|
|
||||||
|
|
|
||||||
| Examples:
|
|
||||||
| - http://cdn-patch.swtor.com/patch/assets_swtor_main/assets_swtor_main_-1to0.solidpkg
|
|
||||||
| - http://159.153.92.51/patch/assets_swtor_main/assets_swtor_main_-1to0.solidpkg
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { exec} from 'child_process';
|
|
||||||
import * as dns from 'dns';
|
|
||||||
import { IDnsResult, IServerEntry } from '../interfaces/IDnsResult';
|
|
||||||
|
|
||||||
//Time when this script started, for delta time calculations
|
|
||||||
const startTime = Date.now();
|
|
||||||
let lastUpdate = 0;
|
|
||||||
|
|
||||||
//List of servers, sorted by reliability
|
|
||||||
let servers: IServerEntry[] = [];
|
|
||||||
//the master source is included by default
|
|
||||||
servers.push({ ip: '159.153.92.51', type: 'master', lastSeen: Infinity, weight: Infinity });
|
|
||||||
|
|
||||||
//TODO: send e-mail with the error
|
|
||||||
const assert = (cond: boolean) => { if (!cond) { console.warn('Assert failed'); } };
|
|
||||||
|
|
||||||
//Looks up the given domain and returns a list of IP addresses, along with their time-to-live
|
|
||||||
async function resolveDns(domain: string): Promise<IDnsResult[]> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
//check given string for correctness to prevent injection attacks
|
|
||||||
if (!domain.match(/^[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,3}$/)) { return resolve([]); }
|
|
||||||
|
|
||||||
//Check Level3/North_America separetely
|
|
||||||
if (domain !== 'cdn-patch.swtor.com') {
|
|
||||||
dns.resolve4(domain, { ttl: true }, (err, result) => {
|
|
||||||
return resolve(result.map(({ address, ttl }) => ({ address, ttl, type: 'level3-us' as IDnsResult['type'] })));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
//Use bash so we get more information.
|
|
||||||
//Also do plenty of asserts to ensure that overall CDN structure has stayed unchanged, and TODO send e-mail if it's different (max. once per hour)
|
|
||||||
exec('dig +noall +answer "cdn-patch.swtor.com"', { timeout: 10000 }, (error, stdout) => {
|
|
||||||
//check for error
|
|
||||||
assert(!error);
|
|
||||||
if (error) {
|
|
||||||
return resolve([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = stdout.trim().split('\n').map((line) => line.split(/\t| /));
|
|
||||||
|
|
||||||
//Verify output
|
|
||||||
assert(data.length > 3);
|
|
||||||
data.forEach((dataLine) => assert(dataLine.length === 5));
|
|
||||||
assert(data[0][0] === 'cdn-patch.swtor.com.');
|
|
||||||
assert(data[0][1].match(/^[0-9]{1,3}$/) !== null); //at least up to 598
|
|
||||||
assert(data[0][2] === 'IN');
|
|
||||||
assert(data[0][3] === 'CNAME');
|
|
||||||
assert(data[0][4] === 'gslb-patch.swtor.biowareonline.net.');
|
|
||||||
|
|
||||||
assert(data[1][0] === 'gslb-patch.swtor.biowareonline.net.');
|
|
||||||
assert(data[1][1].match(/^[0-9]{1,2}$/) !== null); //up to 60 seconds
|
|
||||||
assert(data[1][2] === 'IN');
|
|
||||||
assert(data[1][3] === 'CNAME');
|
|
||||||
assert(data[1][4] === 'cdn-patch.swtor.com.edgesuite.net.' || data[1][4] === 'cdn-patch.swtor.com.c.footprint.net.');
|
|
||||||
|
|
||||||
assert(data[2][0] === data[1][4]);
|
|
||||||
assert(data[2][1].match(/^[0-9]{1,5}$/) !== null); //at least up to 15092 if Akamai, at least up to 627 if Level3
|
|
||||||
assert(data[2][2] === 'IN');
|
|
||||||
assert(data[2][3] === 'CNAME');
|
|
||||||
assert(
|
|
||||||
(data[2][4] === 'a56.d.akamai.net.' && data[1][4] === 'cdn-patch.swtor.com.edgesuite.net.') ||
|
|
||||||
(data[2][4] === 'eu.lvlt.cdn.ea.com.c.footprint.net.' && data[1][4] === 'cdn-patch.swtor.com.c.footprint.net.'),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 3, il = data.length; i < il; i++) {
|
|
||||||
assert(data[i][0] === data[2][4]);
|
|
||||||
assert(data[i][1].match(/^[0-9]{1,3}$/) !== null); //up to 60 seconds if Akamai, at least up to 218 if Level3
|
|
||||||
assert(data[i][2] === 'IN');
|
|
||||||
assert(data[i][3] === 'A');
|
|
||||||
assert(data[i][4].match(/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/) !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
//Prepare return values
|
|
||||||
let type: IDnsResult['type'];
|
|
||||||
switch (data[1][4]) {
|
|
||||||
case 'cdn-patch.swtor.com.edgesuite.net.': type = 'akamai'; break;
|
|
||||||
case 'cdn-patch.swtor.com.c.footprint.net.': type = 'level3-eu'; break;
|
|
||||||
default: type = 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(data.filter((e, index) => index >= 3).map(([, ttl, , , address]) => ({ ttl: Math.min(Number(ttl), Number(data[0][1]), Number(data[1][1]), Number(data[2][1])), address, type })));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}) as Promise<IDnsResult[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Updates the list of servers based on current DNS data
|
|
||||||
async function heartbeatDns(domain: string) {
|
|
||||||
//Get list of current patch servers
|
|
||||||
const dnsResults = await resolveDns(domain);
|
|
||||||
|
|
||||||
//Remeber time when response came in
|
|
||||||
const now = Date.now() - startTime;
|
|
||||||
|
|
||||||
//Schedule next check based on time-to-live, but never longer than 1 minute
|
|
||||||
const ttl = Math.min(60, ...(dnsResults.map((obj) => obj.ttl))) + 1;
|
|
||||||
setTimeout(heartbeatDns.bind(null, domain), ttl * 1000);
|
|
||||||
|
|
||||||
//Update array with new information
|
|
||||||
dnsResults.forEach(
|
|
||||||
({ address, type }, index) => {
|
|
||||||
//Calculate weight:
|
|
||||||
//on cdn-patch.swtor.com: 3 if first, 2 if second, otherwise 1
|
|
||||||
let weight = (index < 2) ? (3 - index) : 1;
|
|
||||||
//on Level3 US: 1.2 is first, 1 if second
|
|
||||||
if (domain !== 'cdn-patch.swtor.com') {
|
|
||||||
weight = (index === 0) ? 1.2 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
//if ip is already contained
|
|
||||||
for (let i = 0, il = servers.length; i < il; i++) {
|
|
||||||
const server = servers[i];
|
|
||||||
if (server.ip === address) {
|
|
||||||
server.lastSeen = now;
|
|
||||||
server.weight += weight;
|
|
||||||
if (server.type !== type) { server.type = type; }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//if not yet contained, add to array
|
|
||||||
servers.push({
|
|
||||||
ip: address,
|
|
||||||
lastSeen: now,
|
|
||||||
type,
|
|
||||||
weight: weight + 1, //give a boost to new values compared to existing values
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
//Remove old entries - old = not seen for one hour
|
|
||||||
servers = servers.filter((server) => (now - server.lastSeen) < 3600000);
|
|
||||||
|
|
||||||
//Decay weights - reduce them based on update frequency (-50% if full minute, but less if TTL was shorter than a minute)
|
|
||||||
const decayFactor = 0.5 ** ((now - lastUpdate) / 60000);
|
|
||||||
lastUpdate = now;
|
|
||||||
servers.forEach((server) => { server.weight *= decayFactor; });
|
|
||||||
|
|
||||||
//Sort the array by weight
|
|
||||||
servers.sort((a, b) => b.weight - a.weight);
|
|
||||||
|
|
||||||
//Output current list
|
|
||||||
let output = '';
|
|
||||||
servers.forEach((server) => {
|
|
||||||
//set colors based on server type, see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
|
|
||||||
//bright color if seen within last 5 minutes
|
|
||||||
if (now - server.lastSeen < 300000) { output += '\x1b[1m'; } else { output += '\x1b[0m'; }
|
|
||||||
switch (server.type) {
|
|
||||||
case 'master': output += '\x1b[37m'; break; //white
|
|
||||||
case 'akamai': output += '\x1b[35m'; break; //magenta
|
|
||||||
case 'level3-us': output += '\x1b[32m'; break; //green
|
|
||||||
case 'level3-eu': output += '\x1b[36m'; break; //cyan
|
|
||||||
case 'unknown': default: output += '\x1b[31m'; //red
|
|
||||||
}
|
|
||||||
output += server.ip;
|
|
||||||
output += '\t';
|
|
||||||
});
|
|
||||||
//Reset color to default
|
|
||||||
output += '\x1b[0m';
|
|
||||||
console.log(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
//start loading additional addresses, both from CDN, and specifically from Level3/North_America so we have more than just European servers
|
|
||||||
heartbeatDns('cdn-patch.swtor.com');
|
|
||||||
heartbeatDns('na.lvlt.cdn.ea.com.c.footprint.net');
|
|
91
src/cdn/heartbeat.ts
Normal file
91
src/cdn/heartbeat.ts
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
import { IServerEntry } from '../interfaces/IDnsResult';
|
||||||
|
import resolveDns from './resolveDns';
|
||||||
|
|
||||||
|
/** Time when this script started, for delta time calculations */
|
||||||
|
const startTime = Date.now();
|
||||||
|
let lastUpdate = 0;
|
||||||
|
|
||||||
|
/** List of servers, sorted by reliability */
|
||||||
|
let servers: IServerEntry[] = [];
|
||||||
|
//the master source is included by default
|
||||||
|
servers.push({ ip: '159.153.92.51', type: 'master', lastSeen: Infinity, weight: Infinity });
|
||||||
|
|
||||||
|
/** Updates the list of servers based on current DNS data */
|
||||||
|
async function heartbeatDns(domain: string) {
|
||||||
|
//Get list of current patch servers
|
||||||
|
const dnsResults = await resolveDns(domain);
|
||||||
|
|
||||||
|
//Remeber time when response came in
|
||||||
|
const now = Date.now() - startTime;
|
||||||
|
|
||||||
|
//Schedule next check based on time-to-live, but never longer than 1 minute
|
||||||
|
const ttl = Math.min(60, ...(dnsResults.map((obj) => obj.ttl))) + 1;
|
||||||
|
setTimeout(heartbeatDns.bind(null, domain), ttl * 1000);
|
||||||
|
|
||||||
|
//Update array with new information
|
||||||
|
dnsResults.forEach(
|
||||||
|
({ address, type }, index) => {
|
||||||
|
//Calculate weight:
|
||||||
|
//on cdn-patch.swtor.com: 3 if first, 2 if second, otherwise 1
|
||||||
|
let weight = (index < 2) ? (3 - index) : 1;
|
||||||
|
//on Level3 US: 1.2 is first, 1 if second
|
||||||
|
if (domain !== 'cdn-patch.swtor.com') {
|
||||||
|
weight = (index === 0) ? 1.2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
//if ip is already contained
|
||||||
|
for (let i = 0, il = servers.length; i < il; i += 1) {
|
||||||
|
const server = servers[i];
|
||||||
|
if (server.ip === address) {
|
||||||
|
server.lastSeen = now;
|
||||||
|
server.weight += weight;
|
||||||
|
if (server.type !== type) { server.type = type; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//if not yet contained, add to array
|
||||||
|
servers.push({
|
||||||
|
ip: address,
|
||||||
|
lastSeen: now,
|
||||||
|
type,
|
||||||
|
weight: weight + 1, //give a boost to new values compared to existing values
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
//Remove old entries - old = not seen for one hour
|
||||||
|
servers = servers.filter((server) => (now - server.lastSeen) < 3600000);
|
||||||
|
|
||||||
|
//Decay weights - reduce them based on update frequency (-50% if full minute, but less if TTL was shorter than a minute)
|
||||||
|
const decayFactor = 0.5 ** ((now - lastUpdate) / 60000);
|
||||||
|
lastUpdate = now;
|
||||||
|
servers.forEach((server) => { server.weight *= decayFactor; });
|
||||||
|
|
||||||
|
//Sort the array by weight
|
||||||
|
servers.sort((a, b) => b.weight - a.weight);
|
||||||
|
|
||||||
|
//Output current list
|
||||||
|
let output = '';
|
||||||
|
servers.forEach((server) => {
|
||||||
|
//set colors based on server type, see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
|
||||||
|
//bright color if seen within last 5 minutes
|
||||||
|
if (now - server.lastSeen < 300000) { output += '\x1b[1m'; } else { output += '\x1b[0m'; }
|
||||||
|
switch (server.type) {
|
||||||
|
case 'master': output += '\x1b[37m'; break; //white
|
||||||
|
case 'akamai': output += '\x1b[35m'; break; //magenta
|
||||||
|
case 'level3-us': output += '\x1b[32m'; break; //green
|
||||||
|
case 'level3-eu': output += '\x1b[36m'; break; //cyan
|
||||||
|
case 'unknown': default: output += '\x1b[31m'; //red
|
||||||
|
}
|
||||||
|
output += server.ip;
|
||||||
|
output += '\t';
|
||||||
|
});
|
||||||
|
//Reset color to default
|
||||||
|
output += '\x1b[0m';
|
||||||
|
console.log(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
//start loading additional addresses, both from CDN, and specifically from Level3/North_America so we have more than just European servers
|
||||||
|
heartbeatDns('cdn-patch.swtor.com');
|
||||||
|
heartbeatDns('na.lvlt.cdn.ea.com.c.footprint.net');
|
105
src/cdn/resolveDns.ts
Normal file
105
src/cdn/resolveDns.ts
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
/* --- DNS resolver for the CDN storing SWTOR's patch files ---
|
||||||
|
|
|
||||||
|
| The SWTOR launcher first connects to cdn-patch.swtor.com,
|
||||||
|
| there the traffic is split onto Akamai and Level3, and
|
||||||
|
| distributed to local points of presence.
|
||||||
|
| The master files appear to be stored on 159.153.92.51, and
|
||||||
|
| all CDNs are synchronized with that source.
|
||||||
|
|
|
||||||
|
| > cdn-patch.swtor.com
|
||||||
|
| -> gslb-patch.swtor.biowareonline.net
|
||||||
|
| -> cdn-patch.swtor.com.edgesuite.net (Akamai)
|
||||||
|
| | -> a56.d.akamai.net
|
||||||
|
| | -> 2.21.74.90
|
||||||
|
| | -> 2.21.74.98
|
||||||
|
| -> cdn-patch.swtor.com.c.footprint.net (Level3)
|
||||||
|
| -> eu.lvlt.cdn.ea.com.c.footprint.net (Europe)
|
||||||
|
| | -> 8.12.207.125
|
||||||
|
| | -> 8.253.37.126
|
||||||
|
| -> na.lvlt.cdn.ea.com.c.footprint.net (US)
|
||||||
|
| -> 205.128.74.252
|
||||||
|
| -> 4.23.36.253
|
||||||
|
| -> 192.221.105.254
|
||||||
|
|
|
||||||
|
| Examples:
|
||||||
|
| - http://cdn-patch.swtor.com/patch/assets_swtor_main/assets_swtor_main_-1to0.solidpkg
|
||||||
|
| - http://159.153.92.51/patch/assets_swtor_main/assets_swtor_main_-1to0.solidpkg
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { exec} from 'child_process';
|
||||||
|
import * as dns from 'dns';
|
||||||
|
import { IDnsResult } from '../interfaces/IDnsResult';
|
||||||
|
|
||||||
|
//TODO: send e-mail with the error
|
||||||
|
const assert = (cond: boolean) => { if (!cond) { console.warn('Assert failed'); } };
|
||||||
|
|
||||||
|
/** Looks up the given domain and returns a list of IP addresses, along with their time-to-live */
|
||||||
|
async function resolveDns(domain: string): Promise<IDnsResult[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
//check given string for correctness to prevent injection attacks
|
||||||
|
if (!domain.match(/^[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,3}$/)) { return resolve([]); }
|
||||||
|
|
||||||
|
//Check Level3/North_America separetely
|
||||||
|
if (domain !== 'cdn-patch.swtor.com') {
|
||||||
|
dns.resolve4(domain, { ttl: true }, (err, result) => {
|
||||||
|
return resolve(result.map(({ address, ttl }) => ({ address, ttl, type: 'level3-us' as IDnsResult['type'] })));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
//Use bash so we get more information.
|
||||||
|
//Also do plenty of asserts to ensure that overall CDN structure has stayed unchanged, and TODO send e-mail if it's different (max. once per hour)
|
||||||
|
exec('dig +noall +answer "cdn-patch.swtor.com"', { timeout: 10000 }, (error, stdout) => {
|
||||||
|
//check for error
|
||||||
|
assert(!error);
|
||||||
|
if (error) {
|
||||||
|
return resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = stdout.trim().split('\n').map((line) => line.split(/\t| /));
|
||||||
|
|
||||||
|
//Verify output
|
||||||
|
assert(data.length > 3);
|
||||||
|
data.forEach((dataLine) => assert(dataLine.length === 5));
|
||||||
|
assert(data[0][0] === 'cdn-patch.swtor.com.');
|
||||||
|
assert(data[0][1].match(/^[0-9]{1,3}$/) !== null); //at least up to 598
|
||||||
|
assert(data[0][2] === 'IN');
|
||||||
|
assert(data[0][3] === 'CNAME');
|
||||||
|
assert(data[0][4] === 'gslb-patch.swtor.biowareonline.net.');
|
||||||
|
|
||||||
|
assert(data[1][0] === 'gslb-patch.swtor.biowareonline.net.');
|
||||||
|
assert(data[1][1].match(/^[0-9]{1,2}$/) !== null); //up to 60 seconds
|
||||||
|
assert(data[1][2] === 'IN');
|
||||||
|
assert(data[1][3] === 'CNAME');
|
||||||
|
assert(data[1][4] === 'cdn-patch.swtor.com.edgesuite.net.' || data[1][4] === 'cdn-patch.swtor.com.c.footprint.net.');
|
||||||
|
|
||||||
|
assert(data[2][0] === data[1][4]);
|
||||||
|
assert(data[2][1].match(/^[0-9]{1,5}$/) !== null); //at least up to 15092 if Akamai, at least up to 627 if Level3
|
||||||
|
assert(data[2][2] === 'IN');
|
||||||
|
assert(data[2][3] === 'CNAME');
|
||||||
|
assert(
|
||||||
|
(data[2][4] === 'a56.d.akamai.net.' && data[1][4] === 'cdn-patch.swtor.com.edgesuite.net.') ||
|
||||||
|
(data[2][4] === 'eu.lvlt.cdn.ea.com.c.footprint.net.' && data[1][4] === 'cdn-patch.swtor.com.c.footprint.net.'),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 3, il = data.length; i < il; i += 1) {
|
||||||
|
assert(data[i][0] === data[2][4]);
|
||||||
|
assert(data[i][1].match(/^[0-9]{1,3}$/) !== null); //up to 60 seconds if Akamai, at least up to 218 if Level3
|
||||||
|
assert(data[i][2] === 'IN');
|
||||||
|
assert(data[i][3] === 'A');
|
||||||
|
assert(data[i][4].match(/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/) !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
//Prepare return values
|
||||||
|
let type: IDnsResult['type'];
|
||||||
|
switch (data[1][4]) {
|
||||||
|
case 'cdn-patch.swtor.com.edgesuite.net.': type = 'akamai'; break;
|
||||||
|
case 'cdn-patch.swtor.com.c.footprint.net.': type = 'level3-eu'; break;
|
||||||
|
default: type = 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(data.filter((e, index) => index >= 3).map(([, ttl, , , address]) => ({ ttl: Math.min(Number(ttl), Number(data[0][1]), Number(data[1][1]), Number(data[2][1])), address, type })));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}) as Promise<IDnsResult[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default resolveDns;
|
|
@ -16,7 +16,7 @@ interface IServerEntry {
|
||||||
ip: string;
|
ip: string;
|
||||||
/** Which network / cloud provider this IP address belongs to. */
|
/** Which network / cloud provider this IP address belongs to. */
|
||||||
type: NetworkType;
|
type: NetworkType;
|
||||||
/** When we were last able to resolve to this IP address. */
|
/** When we were last able to resolve to this IP address, time given in milliseconds. */
|
||||||
lastSeen: number;
|
lastSeen: number;
|
||||||
/** A measure of how reliable this IP address is, based on how often and how recently it was resolved. */
|
/** A measure of how reliable this IP address is, based on how often and how recently it was resolved. */
|
||||||
weight: number;
|
weight: number;
|
||||||
|
|
Loading…
Reference in a new issue