Counter Strike : Global Offensive Source Code
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

518 lines
18 KiB

  1. // To deploy: C:\bin\putty\pscp.exe D:\csgo\trunk\src\engine\hltvbroadcastrelay.js [email protected]:/home/sergiy/csgo_relay/hltvbroadcastrelay.js
  2. var http = require('http');
  3. var accounts = {};
  4. var url = require('url');
  5. var fs = require("fs");
  6. var os = require("os");
  7. var adminCookie = Math.random();
  8. var anonymousGetAllowance = 10; // this is how many GET requests are currently allowed (goes up with every POST up to a point) without the Akamai x-origin-auth header
  9. function zeroArray(len) {
  10. var arr = new Array(len);
  11. for (var i = 0; i < len; ++i)
  12. arr[i] = 0;
  13. return arr;
  14. }
  15. var stats = { post_field: 0, get_field: 0, get_start: 0, get_frag_meta: 0, sync: 0, not_found: 0, new_acct: 0, err: zeroArray(8), requests: 0, started: Date.now(), version: 5 };
  16. "use strict";
  17. var port = 8080;
  18. function datasize( obj, field ) {
  19. var f = obj[field];
  20. if (f == null)
  21. return '';
  22. if (typeof f === 'string' || Buffer.isBuffer( f ) )
  23. return f.length;
  24. else if (f == null)
  25. return '';
  26. else return typeof f;
  27. }
  28. function listAllAccounts() {
  29. var uptime = ( Date.now() - stats.started ) / 1000;
  30. var html = '<p>Uptime ' + Math.floor((uptime / 60 / 60) % 60) + ':' + Math.floor((uptime / 60) % 60) + ':' + Math.floor(uptime % 60) + '</p>\n<!-- Listing accounts -->\n<p>Stats: ' + JSON.stringify(stats) + '</p>\n';
  31. html += '<form action="/save" method="post"><input type="submit" value="Save State To Disk"/></form>';
  32. for (var acct in accounts) {
  33. var acc = accounts[acct];
  34. html += '<p><a href="/' + acct + '">' + acct + '</a> -> ' + acc.length + ' frames, map ' + ( acc[0] == null ? "unknown" : acc[0].map ) + ' </p>\n';
  35. }
  36. return html;
  37. }
  38. function listSingleAccount( accId, acc )
  39. {
  40. var title = '';
  41. if (acc[0]){
  42. title += '<p>map <b>' + acc[0].map;
  43. title += '</b> started ' + new Date(acc[0].timestamp).toUTCString();
  44. if (acc[0].signup_fragment)
  45. title += ', signup fragment ' + acc[0].signup_fragment;
  46. title += '</p><code>playcast "http://csgo-broadcast.akamai.steamstatic.com/' + accId + '"</code>';
  47. title += '<form action="/' + accId + '/delete" method="post"><input type="submit" value="Delete Broadcast"/></form>';
  48. }
  49. var html = '<p><i>' + title + '</i></p>';
  50. html += "<table><tr> <td>fragment</td> <td>ticks</td> <td>start</td> <td>full</td> <td>delta</td> <td>timestamp</td></tr>\n";
  51. var last_frag = 0;
  52. for (var fragment in acc) {
  53. if (last_frag + 1 < fragment)
  54. html += '<tr><td colspan="6"><i>' + (last_frag + 1) + ' ... ' + (fragment - 1) + 'missing</i></td></tr>\n';
  55. last_frag = parseInt(fragment);
  56. html += '<tr><td>' + fragment + '</td>';
  57. var f = acc[fragment];
  58. if (f) {
  59. html += '<td>' + (f.tick ? f.tick : "") + ' - ' + (f.endtick ? f.endtick : "") + '</td>';
  60. html += '<td>' + datasize(f, 'start');
  61. if (acc[0] != null && acc[0].signup_fragment == fragment)
  62. html += '<b>signup</b>';
  63. html += '</td><td>' + datasize(f, 'full') + '</td>';
  64. html += '<td>' + datasize(f, 'delta') + '</td>';
  65. html += '<td>' + (f.timestamp ? new Date(f.timestamp).toUTCString() : '') + '</td>';
  66. html += '<td><form action="/' + accId + '/' + fragment + '?delete=fragment" method="post"><input type="submit" value="Delete fragment"/></form></td></tr>\n'
  67. }
  68. else
  69. html += '<td colspan="5">null</td>';
  70. }
  71. html += "</table>";
  72. return html;
  73. }
  74. function respondSimpleHtml( response, htmlBody ) {
  75. response.writeHead(200, {'Content-Type': 'text/html'});
  76. response.end('<html><body>' + htmlBody + '</body></html>');
  77. }
  78. function respondSimpleError(uri, response, code, explanation) {
  79. // if( uri ) console.log( uri + " => " + code + " " + explanation );
  80. response.writeHead(code, explanation);
  81. response.end();
  82. }
  83. function getAccountBufferSize( acc ){
  84. var totalSize = 0;
  85. for (var fragment in acc )
  86. {
  87. for (var field in acc[fragment]) {
  88. var length = acc[fragment][field].length;
  89. if( length )
  90. totalSize += length;
  91. }
  92. }
  93. return totalSize;
  94. }
  95. function isSyncReady( f ) {
  96. return f != null && typeof( f ) == "object" && f.full != null && f.delta != null && f.tick != null && f.endtick != null;
  97. }
  98. function respondAccSync( param, uri, response, acc )
  99. {
  100. var nowMs = Date.now();
  101. response.setHeader( 'Cache-Control', 'public, max-age=3');
  102. response.setHeader( 'Expires', new Date( nowMs + 3000 ).toUTCString() ); // whatever we find out, this information is going to be stale 3-5 seconds from now
  103. var acc0 = acc[0];
  104. if (acc0 != null && acc0.start != null)
  105. {
  106. var fragment = param.query.fragment, frag = null;
  107. if( fragment == null )
  108. {
  109. // skip the last 3-4 fragments, to let the front-running clients get 404, and akamai wait for 3+ seconds, and re-try that fragment again
  110. // then go back another 3 fragments that are the buffer size for the client - we want to have the full 3 fragments ahead of whatever the user is streaming for the smooth experience
  111. // if we don't, then legit in-sync clients will often hit akamai-cached 404 on buffered fragments
  112. fragment = acc.length - 8;
  113. if (fragment >= 0 && fragment >= acc0.signup_fragment) {
  114. // can't serve anything before the start fragment
  115. var f = acc[fragment];
  116. if ( isSyncReady(f) )
  117. frag = f;
  118. }
  119. } else {
  120. if (fragment < acc0.signup_fragment)
  121. fragment = acc0.signup_fragment;
  122. for (; fragment < acc.length; fragment++) {
  123. var f = acc[fragment];
  124. if (isSyncReady(f)) {
  125. frag = f;
  126. break;
  127. }
  128. }
  129. }
  130. if( frag )
  131. {
  132. console.log("Sync fragment " + fragment);
  133. // found the fragment that we want to send out
  134. response.writeHead(200, { "Content-Type": "application/json" });
  135. if (acc0.protocol == null)
  136. acc0.protocol = 4;
  137. response.end(JSON.stringify({
  138. tick:frag.tick,
  139. endtick: frag.endtick,
  140. rtdelay: ( nowMs - frag.timestamp ) / 1000, // delay of this fragment from real-time, in seconds
  141. rcvage: ( nowMs - acc[acc.length - 1].timestamp ) / 1000, // Receive age: how many seconds since relay last received data from game server
  142. fragment: fragment,
  143. signup_fragment: acc0.signup_fragment,
  144. tps: acc0.tps,
  145. keyframe_interval: acc0.keyframe_interval,
  146. map: acc0.map,
  147. protocol: acc0.protocol
  148. }));
  149. return; // success!
  150. }
  151. // not found
  152. response.writeHead(405, "Fragment not found, please check back soon");
  153. }
  154. else
  155. response.writeHead(404, "Broadcast has not started yet");
  156. response.end();
  157. }
  158. function postField( request, param, response, acc, fragment, field )
  159. {
  160. // decide on what exactly the response code is - we have enough info now
  161. if (field == "start") {
  162. console.log("Start tick " + param.query.tick + " in fragment " + fragment);
  163. response.writeHead(200);
  164. if (acc[0] == null)
  165. acc[0] = {};
  166. if (acc[0].signup_fragment > fragment)
  167. console.log("UNEXPECTED new start fragment " + fragment + " after " + acc[0].signup_fragment);
  168. acc[0].signup_fragment = fragment;
  169. fragment = 0; // keep the start in the fragment 0
  170. } else {
  171. if (acc[0] == null) {
  172. console.log("205 - need start fragment");
  173. response.writeHead(205);
  174. } else {
  175. if (acc[0].start == null) {
  176. console.log("205 - need start data");
  177. response.writeHead(205);
  178. } else {
  179. response.writeHead(200);
  180. }
  181. }
  182. if (acc[fragment] == null) {
  183. //console.log("Creating fragment " + fragment + " in account " + path[1]);
  184. acc[fragment] = {};
  185. }
  186. }
  187. for (q in param.query) {
  188. var v = param.query[q], n = parseInt( v );
  189. acc[fragment][q] = ( v == n ? n : v );
  190. }
  191. var body = [];
  192. request.on('data', function (data) { body.push( data ); });
  193. request.on('end', function () {
  194. var totalBufer = Buffer.concat(body)
  195. acc[fragment][field] = totalBufer;
  196. acc[fragment].timestamp = Date.now();
  197. if( field == "start")
  198. console.log("Received [" + fragment + "]." + field + ", " + totalBufer.length + " bytes in " + body.length + " pieces");
  199. response.end();
  200. });
  201. }
  202. function serveBlob( request, response, blob ) {
  203. if (blob == null) {
  204. response.writeHead(404, "Field not found");
  205. response.end();
  206. } else {// we have data to serve
  207. if (Buffer.isBuffer(blob)) {
  208. response.writeHead(200, { 'Content-Type': 'application/octet-stream' });
  209. //console.log("Serving " + blob.length + " bytes: " + request.url);
  210. response.end(blob);
  211. } else {
  212. response.writeHead(404, "Unexpected field type " + typeof (blob)); // we only serve strings
  213. console.log("Unexpected Field type " + typeof (blob)); // we only serve strings
  214. response.end();
  215. }
  216. }
  217. }
  218. function getStart(request, response, acc, fragment, field) {
  219. if (acc[0] == null || acc[0].signup_fragment != fragment) {
  220. respondSimpleError(request.url, response, 404, "Invalid or expired start fragment, please re-sync");
  221. } else{
  222. // always take start data from the 0th fragment
  223. serveBlob(request, response, acc[0][field]);
  224. }
  225. }
  226. function getField(request, response, acc, fragment, field) {
  227. serveBlob( request, response, acc[fragment][field] );
  228. }
  229. function getFragmentMetadata(response, acc, fragment)
  230. {
  231. var res = {};
  232. for( var field in acc[ fragment ] )
  233. {
  234. var f = acc[fragment][field];
  235. if( typeof( f ) == 'number' ) res[ field] = f;
  236. else if( Buffer.isBuffer( f ) ) res[ field ] = f.length;
  237. }
  238. response.writeHead( 200, {"Content-Type": "application/json"});
  239. response.end(JSON.stringify(res));
  240. }
  241. function getSaveStateFileName() {
  242. if( os.type() == 'Linux' )
  243. return '/var/log/csgo-relay-save/' + Date.now() + '.json';
  244. else
  245. return 'hltvbroadcastrelay_server_state.json';
  246. }
  247. function getLoadStateFileName() {
  248. if (os.type() == 'Linux')
  249. return '/var/log/csgo-relay-save/autoload.json';
  250. else
  251. return 'hltvbroadcastrelay_server_state.json';
  252. }
  253. fs.readFile(getLoadStateFileName(), "utf8", function (err, data) {
  254. if (err == null) {
  255. if (accounts.length > 0)
  256. console.log("Cannot replace accounts array because accounts has already been modified");
  257. else {
  258. accounts = JSON.parse(data);
  259. var accDigest = [];
  260. for (var a in accounts) {
  261. var acc = accounts[a];
  262. for (var f in acc) {
  263. var fragment = acc[f];
  264. for (var ff in fragment) {
  265. var field = fragment[ff];
  266. if (typeof (field) == 'object') {
  267. if (Array.isArray(field)) {
  268. fragment[ff] = new Buffer(field);
  269. } else if (field.type == 'Buffer' && typeof (field.data) == 'object') {
  270. fragment[ff] = new Buffer(field.data);
  271. } else console.log("Cannot recover fragment field " + f + "/" + ff + ": it's an object, and I can't guess how to restore it to Buffer object");
  272. }
  273. }
  274. }
  275. accDigest.push(a + '[' + acc.length + ']');
  276. }
  277. console.log("Restored accounts: " + accDigest.join(','));
  278. }
  279. }
  280. });
  281. function checkOriginAuth( originAuth, expected )
  282. {
  283. return typeof (originAuth) == 'string' && (originAuth == expected || originAuth.indexOf(expected) >= 0);
  284. }
  285. function processRequestUnprotected(request, response) {
  286. // https://nodejs.org/api/http.html#http_class_http_incomingmessage
  287. var uri, isAdmin = false, isAkamai = false;
  288. if (request.url == '/idebug?admin') {
  289. adminCookie = Math.floor( Math.random() * 1e9 );
  290. console.log("Authenticating new admin: " + adminCookie );
  291. isAdmin = true;
  292. uri = '/';
  293. }
  294. else {
  295. uri = decodeURI(request.url);
  296. var cookies = request.headers.cookie;
  297. if (cookies)
  298. cookies.split(';').forEach(function (cookie) {
  299. var parts = cookie.split('=');
  300. if (parts[0].trim() == 'admin') {
  301. if (parts[1].trim() == adminCookie)
  302. isAdmin = true;
  303. else
  304. console.log('Admin cookie out of date: ' + adminCookie);
  305. }
  306. });
  307. }
  308. if (isAdmin) {
  309. console.log('Admin ' + request.method + uri);
  310. response.setHeader('Set-Cookie', 'admin=' + adminCookie);
  311. }
  312. var param = url.parse(uri, true);
  313. var path = param.pathname.split("/");
  314. path.shift(); // the first element is always empty, because the path starts with /
  315. response.httpVersion = '1.0';
  316. var prime = path.shift();
  317. if( prime == null || prime == '' || prime == 'index.html') {
  318. if( isAdmin )
  319. respondSimpleHtml( response, listAllAccounts());
  320. else
  321. respondSimpleError(uri, response, 401, 'Unauthorized');
  322. return;
  323. }
  324. var isPost, originAuth = request.headers['x-origin-auth'];
  325. if (request.method == 'POST') {
  326. isPost = true;
  327. if (isAdmin) {
  328. if (prime == 'save' ) {
  329. var str = JSON.stringify(accounts);
  330. var fileName = getSaveStateFileName();
  331. var printout = 'Saving server state into ' + fileName + ', ' + str.length + ' bytes';
  332. console.log(printout);
  333. fs.writeFile( fileName, str, "utf8", function (err) {
  334. if (err) console.log(err); else console.log("Current server state saved");
  335. });
  336. respondSimpleHtml(response, printout);
  337. return;
  338. }
  339. } else {
  340. if (checkOriginAuth(originAuth, "hMugYm7Lv4o5")) {
  341. if (anonymousGetAllowance < 20)
  342. anonymousGetAllowance += 2;
  343. } else {
  344. console.log("Unauthorized POST to " + request.url + ", origin auth " + originAuth);
  345. respondSimpleError(uri, response, 403, "Not Authorized");
  346. return;
  347. }
  348. }
  349. }
  350. else if (request.method == 'GET') {
  351. if ( checkOriginAuth( originAuth, 'c93a737c6aeab43b7f4ce18394f9374332c8f935' ) )
  352. isAkamai = true;
  353. else if (!isAdmin) {
  354. if (anonymousGetAllowance <= 0) {
  355. respondSimpleError(uri, response, 403, "Not Authorized");
  356. return;
  357. } else {
  358. anonymousGetAllowance--;
  359. console.log('Non-Akamai GET (' + originAuth + ') allowed (' + anonymousGetAllowance + ') ' + uri);
  360. }
  361. }
  362. isPost = false;
  363. } else {
  364. respondSimpleError(uri, response, 404, "Only POST or GET in this API");
  365. return;
  366. }
  367. var acc = accounts[ prime ];
  368. if( acc == null )
  369. {
  370. // the account does not exist
  371. if( isPost )
  372. {
  373. // it's ok, we'll create a new account
  374. console.log("Creating account '" + prime + "'");
  375. accounts[prime] = acc = [];
  376. stats.new_acct++;
  377. }else{
  378. // GET requests don't create new accounts; but we can add more routing here later
  379. respondSimpleError(uri, response, 404, "Account " + prime + " not found"); // invalid account
  380. stats.err[0]++;
  381. return;
  382. }
  383. }
  384. var subAcc = path.shift();
  385. if( subAcc == null || subAcc == '' )
  386. {
  387. if (isPost) {
  388. respondSimpleError(uri, response, 405, "Invalid POST: no fragment or field");
  389. stats.err[1]++;
  390. }
  391. else if (isAdmin)
  392. respondSimpleHtml(response, listSingleAccount(prime, acc));
  393. else
  394. respondSimpleError(uri, response, 401, "Unauthorized");
  395. return;
  396. }
  397. stats.requests++;
  398. var fragment = parseInt(subAcc);
  399. if (fragment != subAcc ) {
  400. if( subAcc == "sync" ) {
  401. respondAccSync(param, uri, response, acc);
  402. stats.sync++;
  403. }
  404. else if( subAcc == "size" )
  405. respondSimpleHtml(response, "Account " + prime + " all buffers use: <b>" + getAccountBufferSize(acc) + "</b> bytes");
  406. else if (subAcc == "delete") {
  407. if (isPost) {
  408. delete accounts[prime];
  409. respondSimpleHtml(response, "<p><emp>Account " + prime + " and all its buffers are deleted</emp><p>" + listAllAccounts());
  410. } else respondSimpleError(uri, response, 405, "Fragment delete must be a POST request");
  411. }
  412. else {
  413. respondSimpleError(uri, response, 405, "Fragment is not an int or pseudo-fragment name (sync, size)");
  414. stats.err[2]++;
  415. }
  416. return;
  417. }
  418. var field = path.shift();
  419. if( isPost )
  420. {
  421. stats.post_field++;
  422. if (field != null) {
  423. postField(request, param, response, acc, fragment, field);
  424. var fragRemove = fragment - 1200;
  425. //if (fragRemove > 100 && acc[fragRemove] != null) { // keep the first 100 fragments for reference/debugging; keep the last 1200 (1 hour worth of data, ~200Mb at most) because we have enough memory for that. In actuality we only need ~15-20 last fragments and fragment [0]
  426. // delete acc[fragRemove]; // free the memory
  427. //}
  428. }
  429. else if (param.query.delete == "fragment" && isAdmin) {
  430. acc[fragment] = null
  431. respondSimpleHtml(response, "<p>Fragment " + fragment + " is deleted</p>" + listSingleAccount(prime, acc));
  432. }
  433. else {
  434. respondSimpleError(uri, response, 405, "Cannot post fragment without field name");
  435. stats.err[3]++;
  436. }
  437. } else {
  438. if (field == 'start') {
  439. getStart(request, response, acc, fragment, field);
  440. stats.get_start++;
  441. } else if( acc[ fragment ] == null ) {
  442. stats.err[4]++;
  443. response.writeHead(404, "Fragment " + fragment + " not found");
  444. response.end();
  445. } else if (field == null || field == '') {
  446. getFragmentMetadata(response, acc, fragment);
  447. stats.get_frag_meta++;
  448. } else {
  449. getField(request, response, acc, fragment, field);
  450. stats.get_field++;
  451. }
  452. }
  453. }
  454. function processRequest(request, response)
  455. {
  456. try{
  457. processRequestUnprotected(request, response);
  458. } catch (err) {
  459. console.log(( new Date ).toUTCString() + " Exception when processing request " + request.url);
  460. console.log(err);
  461. console.log(err.stack);
  462. }
  463. }
  464. var newServer = http.createServer(processRequest).listen(port);
  465. if( newServer)
  466. console.log(( new Date() ).toUTCString() + " Started in " + os.type() + " at " + __dirname + " on port " + port);
  467. else
  468. console.log(( new Date() ).toUTCString() + " Failed to start on port " + port);