diff --git a/src/cdn/dns.ts b/src/cdn/dns.ts deleted file mode 100644 index 580f1d6..0000000 --- a/src/cdn/dns.ts +++ /dev/null @@ -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 { - 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; -} - -//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'); diff --git a/src/cdn/heartbeat.ts b/src/cdn/heartbeat.ts new file mode 100644 index 0000000..e7cfa43 --- /dev/null +++ b/src/cdn/heartbeat.ts @@ -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'); diff --git a/src/cdn/resolveDns.ts b/src/cdn/resolveDns.ts new file mode 100644 index 0000000..827ac61 --- /dev/null +++ b/src/cdn/resolveDns.ts @@ -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 { + 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; +} + +export default resolveDns; diff --git a/src/interfaces/IDnsResult.ts b/src/interfaces/IDnsResult.ts index 2c8e486..3d408a3 100644 --- a/src/interfaces/IDnsResult.ts +++ b/src/interfaces/IDnsResult.ts @@ -16,7 +16,7 @@ interface IServerEntry { ip: string; /** Which network / cloud provider this IP address belongs to. */ 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; /** A measure of how reliable this IP address is, based on how often and how recently it was resolved. */ weight: number;