mirror of https://github.com/calzoneman/sync.git
Refactor socket.io controller
This commit is contained in:
parent
107155a661
commit
0118a6fb15
|
@ -370,7 +370,7 @@ Channel.prototype.acceptUser = function (user) {
|
||||||
user.socket.on("readChanLog", this.handleReadLog.bind(this, user));
|
user.socket.on("readChanLog", this.handleReadLog.bind(this, user));
|
||||||
|
|
||||||
LOGGER.info(user.realip + " joined " + this.name);
|
LOGGER.info(user.realip + " joined " + this.name);
|
||||||
if (user.socket._isUsingTor) {
|
if (user.socket.context.torConnection) {
|
||||||
if (this.modules.options && this.modules.options.get("torbanned")) {
|
if (this.modules.options && this.modules.options.get("torbanned")) {
|
||||||
user.kick("This channel has banned connections from Tor.");
|
user.kick("This channel has banned connections from Tor.");
|
||||||
this.logger.log("[login] Blocked connection from Tor exit at " +
|
this.logger.log("[login] Blocked connection from Tor exit at " +
|
||||||
|
|
|
@ -1,17 +1,12 @@
|
||||||
var sio = require("socket.io");
|
import sio from 'socket.io';
|
||||||
var db = require("../database");
|
import db from '../database';
|
||||||
var User = require("../user");
|
import User from '../user';
|
||||||
var Server = require("../server");
|
import Server from '../server';
|
||||||
var Config = require("../config");
|
import Config from '../config';
|
||||||
var cookieParser = require("cookie-parser")(Config.get("http.cookie-secret"));
|
const cookieParser = require("cookie-parser")(Config.get("http.cookie-secret"));
|
||||||
var $util = require("../utilities");
|
import typecheck from 'json-typecheck';
|
||||||
var Flags = require("../flags");
|
import { isTorExit } from '../tor';
|
||||||
var typecheck = require("json-typecheck");
|
import session from '../session';
|
||||||
var net = require("net");
|
|
||||||
var util = require("../utilities");
|
|
||||||
var crypto = require("crypto");
|
|
||||||
var isTorExit = require("../tor").isTorExit;
|
|
||||||
var session = require("../session");
|
|
||||||
import counters from '../counters';
|
import counters from '../counters';
|
||||||
import { verifyIPSessionCookie } from '../web/middleware/ipsessioncookie';
|
import { verifyIPSessionCookie } from '../web/middleware/ipsessioncookie';
|
||||||
import Promise from 'bluebird';
|
import Promise from 'bluebird';
|
||||||
|
@ -21,118 +16,181 @@ import { CachingGlobalBanlist } from './globalban';
|
||||||
import proxyaddr from 'proxy-addr';
|
import proxyaddr from 'proxy-addr';
|
||||||
import { Counter, Gauge } from 'prom-client';
|
import { Counter, Gauge } from 'prom-client';
|
||||||
import Socket from 'socket.io/lib/socket';
|
import Socket from 'socket.io/lib/socket';
|
||||||
|
import { TokenBucket } from '../util/token-bucket';
|
||||||
|
import http from 'http';
|
||||||
|
|
||||||
const LOGGER = require('@calzoneman/jsli')('ioserver');
|
const LOGGER = require('@calzoneman/jsli')('ioserver');
|
||||||
|
|
||||||
var CONNECT_RATE = {
|
// WIP, not in use yet
|
||||||
burst: 5,
|
class IOServer {
|
||||||
sustained: 0.1
|
constructor(options = {
|
||||||
};
|
proxyTrustFn: proxyaddr.compile('127.0.0.1')
|
||||||
|
}) {
|
||||||
|
({
|
||||||
|
proxyTrustFn: this.proxyTrustFn
|
||||||
|
} = options);
|
||||||
|
|
||||||
var ipThrottle = {};
|
this.ipThrottle = new Map();
|
||||||
// Keep track of number of connections per IP
|
this.ipCount = new Map();
|
||||||
var ipCount = {};
|
|
||||||
|
|
||||||
function parseCookies(socket, accept) {
|
|
||||||
var req = socket.request;
|
|
||||||
if (req.headers.cookie) {
|
|
||||||
cookieParser(req, null, () => {
|
|
||||||
accept(null, true);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
req.cookies = {};
|
|
||||||
req.signedCookies = {};
|
|
||||||
accept(null, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called before an incoming socket.io connection is accepted.
|
|
||||||
*/
|
|
||||||
function handleAuth(socket, accept) {
|
|
||||||
socket.user = null;
|
|
||||||
socket.aliases = [];
|
|
||||||
|
|
||||||
const promises = [];
|
|
||||||
const auth = socket.request.signedCookies.auth;
|
|
||||||
if (auth) {
|
|
||||||
promises.push(verifySession(auth).then(user => {
|
|
||||||
socket.user = Object.assign({}, user);
|
|
||||||
}).catch(error => {
|
|
||||||
// Do nothing
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
promises.push(getAliases(socket._realip).then(aliases => {
|
// Map proxied sockets to the real IP address via X-Forwarded-For
|
||||||
socket.aliases = aliases;
|
// If the resulting address is a known Tor exit, flag it as such
|
||||||
}).catch(error => {
|
ipProxyMiddleware(socket, next) {
|
||||||
// Do nothing
|
if (!socket.context) socket.context = {};
|
||||||
}));
|
socket.context.ipAddress = proxyaddr(socket.client.request, this.proxyTrustFn);
|
||||||
|
if (isTorExit(socket.context.ipAddress)) {
|
||||||
Promise.all(promises).then(() => {
|
socket.context.torConnection = true;
|
||||||
accept(null, true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleIPSessionCookie(socket, accept) {
|
|
||||||
var cookie = socket.request.signedCookies['ip-session'];
|
|
||||||
if (!cookie) {
|
|
||||||
socket.ipSessionFirstSeen = new Date();
|
|
||||||
return accept(null, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessionMatch = verifyIPSessionCookie(socket._realip, cookie);
|
|
||||||
if (sessionMatch) {
|
|
||||||
socket.ipSessionFirstSeen = sessionMatch.date;
|
|
||||||
} else {
|
|
||||||
socket.ipSessionFirstSeen = new Date();
|
|
||||||
}
|
|
||||||
accept(null, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function throttleIP(sock) {
|
|
||||||
var ip = sock._realip;
|
|
||||||
|
|
||||||
if (!(ip in ipThrottle)) {
|
|
||||||
ipThrottle[ip] = $util.newRateLimiter();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ipThrottle[ip].throttle(CONNECT_RATE)) {
|
|
||||||
LOGGER.warn("IP throttled: " + ip);
|
|
||||||
sock.emit("kick", {
|
|
||||||
reason: "Your IP address is connecting too quickly. Please "+
|
|
||||||
"wait 10 seconds before joining again."
|
|
||||||
});
|
|
||||||
sock.disconnect();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ipLimitReached(sock) {
|
|
||||||
var ip = sock._realip;
|
|
||||||
|
|
||||||
sock.on("disconnect", function () {
|
|
||||||
counters.add("socket.io:disconnect", 1);
|
|
||||||
ipCount[ip]--;
|
|
||||||
if (ipCount[ip] === 0) {
|
|
||||||
/* Clear out unnecessary counters to save memory */
|
|
||||||
delete ipCount[ip];
|
|
||||||
}
|
}
|
||||||
});
|
next();
|
||||||
|
|
||||||
if (!(ip in ipCount)) {
|
|
||||||
ipCount[ip] = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ipCount[ip]++;
|
// Reject global banned IP addresses
|
||||||
if (ipCount[ip] > Config.get("io.ip-connection-limit")) {
|
ipBanMiddleware(socket, next) {
|
||||||
sock.emit("kick", {
|
if (isIPGlobalBanned(socket.context.ipAddress)) {
|
||||||
reason: "Too many connections from your IP address"
|
LOGGER.info('Rejecting %s - banned',
|
||||||
|
socket.context.ipAddress);
|
||||||
|
next(new Error('You are banned from the server'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit connection attempts by IP address
|
||||||
|
ipThrottleMiddleware(socket, next) {
|
||||||
|
if (!this.ipThrottle.has(socket.context.ipAddress)) {
|
||||||
|
this.ipThrottle.set(socket.context.ipAddress, new TokenBucket(5, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = this.ipThrottle.get(socket.context.ipAddress);
|
||||||
|
if (bucket.throttle()) {
|
||||||
|
LOGGER.info('Rejecting %s - exceeded connection rate limit',
|
||||||
|
socket.context.ipAddress);
|
||||||
|
next(new Error('Rate limit exceeded'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
ipConnectionLimitMiddleware(socket, next) {
|
||||||
|
const ip = socket.context.ipAddress;
|
||||||
|
const count = this.ipCount.get(ip) || 0;
|
||||||
|
if (count >= Config.get('io.ip-connection-limit')) {
|
||||||
|
// TODO: better error message would be nice
|
||||||
|
next(new Error('Too many connections from your IP address'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ipCount.set(ip, count + 1);
|
||||||
|
socket.once('disconnect', () => {
|
||||||
|
this.ipCount.set(ip, this.ipCount.get(ip) - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse cookies
|
||||||
|
cookieParsingMiddleware(socket, next) {
|
||||||
|
const req = socket.request;
|
||||||
|
if (req.headers.cookie) {
|
||||||
|
cookieParser(req, null, () => next());
|
||||||
|
} else {
|
||||||
|
req.cookies = {};
|
||||||
|
req.signedCookies = {};
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine session age from ip-session cookie
|
||||||
|
// (Used for restricting chat)
|
||||||
|
ipSessionCookieMiddleware(socket, next) {
|
||||||
|
const cookie = socket.request.signedCookies['ip-session'];
|
||||||
|
if (!cookie) {
|
||||||
|
socket.context.ipSessionFirstSeen = new Date();
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionMatch = verifyIPSessionCookie(socket.context.ipAddress, cookie);
|
||||||
|
if (sessionMatch) {
|
||||||
|
socket.context.ipSessionFirstSeen = sessionMatch.date;
|
||||||
|
} else {
|
||||||
|
socket.context.ipSessionFirstSeen = new Date();
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match login cookie against the DB, look up aliases
|
||||||
|
authUserMiddleware(socket, next) {
|
||||||
|
socket.context.aliases = [];
|
||||||
|
|
||||||
|
const promises = [];
|
||||||
|
const auth = socket.request.signedCookies.auth;
|
||||||
|
if (auth) {
|
||||||
|
promises.push(verifySession(auth).then(user => {
|
||||||
|
socket.context.user = Object.assign({}, user);
|
||||||
|
}).catch(error => {
|
||||||
|
LOGGER.warn('Unable to verify session for %s - ignoring auth',
|
||||||
|
socket.context.ipAddress);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
promises.push(getAliases(socket.context.ipAddress).then(aliases => {
|
||||||
|
socket.context.aliases = aliases;
|
||||||
|
}).catch(error => {
|
||||||
|
LOGGER.warn('Unable to load aliases for %s',
|
||||||
|
socket.context.ipAddress);
|
||||||
|
}));
|
||||||
|
|
||||||
|
Promise.all(promises).then(() => next());
|
||||||
|
}
|
||||||
|
|
||||||
|
metricsEmittingMiddleware(socket, next) {
|
||||||
|
emitMetrics(socket);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleConnection(socket) {
|
||||||
|
LOGGER.info('Accepted socket from %s', socket.context.ipAddress);
|
||||||
|
counters.add('socket.io:accept', 1);
|
||||||
|
socket.once('disconnect', () => counters.add('socket.io:disconnect', 1));
|
||||||
|
|
||||||
|
const user = new User(socket, socket.context.ipAddress, socket.context.user);
|
||||||
|
if (socket.context.user) {
|
||||||
|
db.recordVisit(socket.context.ipAddress, user.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
const announcement = Server.getServer().announcement;
|
||||||
|
if (announcement !== null) {
|
||||||
|
socket.emit('announcement', announcement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initSocketIO() {
|
||||||
|
patchTypecheckedFunctions();
|
||||||
|
|
||||||
|
const io = this.io = sio.instance = sio();
|
||||||
|
io.use(this.ipProxyMiddleware.bind(this));
|
||||||
|
io.use(this.ipBanMiddleware.bind(this));
|
||||||
|
io.use(this.ipThrottleMiddleware.bind(this));
|
||||||
|
io.use(this.ipConnectionLimitMiddleware.bind(this));
|
||||||
|
io.use(this.cookieParsingMiddleware.bind(this));
|
||||||
|
io.use(this.ipSessionCookieMiddleware.bind(this));
|
||||||
|
io.use(this.authUserMiddleware.bind(this));
|
||||||
|
io.use(this.metricsEmittingMiddleware.bind(this));
|
||||||
|
io.on('connection', this.handleConnection.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
bindTo(servers) {
|
||||||
|
if (!this.io) {
|
||||||
|
throw new Error('Cannot bind: socket.io has not been initialized yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
servers.forEach(server => {
|
||||||
|
this.io.attach(server);
|
||||||
});
|
});
|
||||||
sock.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -167,18 +225,6 @@ function patchTypecheckedFunctions() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ipForwardingMiddleware(webConfig) {
|
|
||||||
const trustFn = proxyaddr.compile(webConfig.getTrustedProxies());
|
|
||||||
|
|
||||||
return function (socket, accept) {
|
|
||||||
LOGGER.debug('ip = %s', socket.client.request.connection.remoteAddress);
|
|
||||||
//socket.client.request.ip = socket.client.conn.remoteAddress;
|
|
||||||
socket._realip = proxyaddr(socket.client.request, trustFn);
|
|
||||||
LOGGER.debug('socket._realip: %s', socket._realip);
|
|
||||||
accept(null, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let globalIPBanlist = null;
|
let globalIPBanlist = null;
|
||||||
function isIPGlobalBanned(ip) {
|
function isIPGlobalBanned(ip) {
|
||||||
if (globalIPBanlist === null) {
|
if (globalIPBanlist === null) {
|
||||||
|
@ -219,7 +265,7 @@ function emitMetrics(sock) {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
LOGGER.error('Error emitting transport upgrade metrics for socket (ip=%s): %s',
|
LOGGER.error('Error emitting transport upgrade metrics for socket (ip=%s): %s',
|
||||||
sock._realip, error.stack);
|
sock.context.ipAddress, error.stack);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -229,130 +275,74 @@ function emitMetrics(sock) {
|
||||||
promSocketDisconnect.inc(1, new Date());
|
promSocketDisconnect.inc(1, new Date());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
LOGGER.error('Error emitting disconnect metrics for socket (ip=%s): %s',
|
LOGGER.error('Error emitting disconnect metrics for socket (ip=%s): %s',
|
||||||
sock._realip, error.stack);
|
sock.context.ipAddress, error.stack);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
LOGGER.error('Error emitting metrics for socket (ip=%s): %s',
|
LOGGER.error('Error emitting metrics for socket (ip=%s): %s',
|
||||||
sock._realip, error.stack);
|
sock.context.ipAddress, error.stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
let instance = null;
|
||||||
* Called after a connection is accepted
|
|
||||||
*/
|
|
||||||
function handleConnection(sock) {
|
|
||||||
var ip = sock._realip;
|
|
||||||
if (!ip) {
|
|
||||||
sock.emit("kick", {
|
|
||||||
reason: "Your IP address could not be determined from the socket connection. See https://github.com/Automattic/socket.io/issues/1737 for details"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (net.isIPv6(ip)) {
|
|
||||||
ip = util.expandIPv6(ip);
|
|
||||||
sock._realip = ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTorExit(ip)) {
|
|
||||||
sock._isUsingTor = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var srv = Server.getServer();
|
|
||||||
|
|
||||||
if (throttleIP(sock)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for global ban on the IP
|
|
||||||
if (isIPGlobalBanned(ip)) {
|
|
||||||
LOGGER.info("Rejecting " + ip + " - global banned");
|
|
||||||
sock.emit("kick", { reason: "Your IP is globally banned." });
|
|
||||||
sock.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ipLimitReached(sock)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emitMetrics(sock);
|
|
||||||
|
|
||||||
LOGGER.info("Accepted socket from " + ip);
|
|
||||||
counters.add("socket.io:accept", 1);
|
|
||||||
|
|
||||||
const user = new User(sock, ip, sock.user);
|
|
||||||
if (sock.user) {
|
|
||||||
db.recordVisit(ip, user.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
const announcement = srv.announcement;
|
|
||||||
if (announcement != null) {
|
|
||||||
sock.emit("announcement", announcement);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init: function (srv, webConfig) {
|
init: function (srv, webConfig) {
|
||||||
patchTypecheckedFunctions();
|
if (instance !== null) {
|
||||||
var bound = {};
|
throw new Error('ioserver.init: already initialized');
|
||||||
const ioOptions = {
|
}
|
||||||
perMessageDeflate: Config.get("io.per-message-deflate")
|
|
||||||
};
|
|
||||||
var io = sio.instance = sio();
|
|
||||||
|
|
||||||
io.use(ipForwardingMiddleware(webConfig));
|
const ioServer = instance = new IOServer({
|
||||||
io.use(parseCookies);
|
proxyTrustFn: proxyaddr.compile(webConfig.getTrustedProxies())
|
||||||
io.use(handleIPSessionCookie);
|
});
|
||||||
io.use(handleAuth);
|
|
||||||
io.on("connection", handleConnection);
|
ioServer.initSocketIO();
|
||||||
|
|
||||||
|
const uniqueListenAddresses = new Set();
|
||||||
|
const servers = [];
|
||||||
|
|
||||||
Config.get("listen").forEach(function (bind) {
|
Config.get("listen").forEach(function (bind) {
|
||||||
if (!bind.io) {
|
if (!bind.io) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var id = bind.ip + ":" + bind.port;
|
|
||||||
if (id in bound) {
|
const id = bind.ip + ":" + bind.port;
|
||||||
LOGGER.warn("Ignoring duplicate listen address " + id);
|
if (uniqueListenAddresses.has(id)) {
|
||||||
|
LOGGER.warn("Ignoring duplicate listen address %s", id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id in srv.servers) {
|
if (srv.servers.hasOwnProperty(id)) {
|
||||||
io.attach(srv.servers[id], ioOptions);
|
servers.push(srv.servers[id]);
|
||||||
} else {
|
} else {
|
||||||
var server = require("http").createServer().listen(bind.port, bind.ip);
|
const server = http.createServer().listen(bind.port, bind.ip);
|
||||||
server.on("clientError", function (err, socket) {
|
servers.push(server);
|
||||||
try {
|
|
||||||
socket.destroy();
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
io.attach(server, ioOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bound[id] = null;
|
uniqueListenAddresses.add(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ioServer.bindTo(servers);
|
||||||
},
|
},
|
||||||
|
|
||||||
handleConnection: handleConnection
|
IOServer: IOServer
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Clean out old rate limiters */
|
/* Clean out old rate limiters */
|
||||||
setInterval(function () {
|
setInterval(function () {
|
||||||
for (var ip in ipThrottle) {
|
if (instance == null) return;
|
||||||
if (ipThrottle[ip].lastTime < Date.now() - 60 * 1000) {
|
|
||||||
var obj = ipThrottle[ip];
|
let cleaned = 0;
|
||||||
/* Not strictly necessary, but seems to help the GC out a bit */
|
const keys = instance.ipThrottle.keys();
|
||||||
for (var key in obj) {
|
for (const key of keys) {
|
||||||
delete obj[key];
|
if (instance.ipThrottle.get(key).lastRefill < Date.now() - 60000) {
|
||||||
}
|
const bucket = instance.ipThrottle.delete(key);
|
||||||
delete ipThrottle[ip];
|
for (const k in bucket) delete bucket[k];
|
||||||
|
cleaned++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Config.get("aggressive-gc") && global && global.gc) {
|
if (cleaned > 0) {
|
||||||
global.gc();
|
LOGGER.info('Cleaned up %d stale IP throttle token buckets', cleaned);
|
||||||
}
|
}
|
||||||
}, 5 * 60 * 1000);
|
}, 5 * 60 * 1000);
|
||||||
|
|
|
@ -104,6 +104,10 @@ export function addReportHook(hook) {
|
||||||
reportHooks.push(hook);
|
reportHooks.push(hook);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearReportHooks() {
|
||||||
|
reportHooks = [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force metrics to be reported right now.
|
* Force metrics to be reported right now.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -196,28 +196,6 @@ Server.prototype.reloadCertificateData = function reloadCertificateData() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
Server.prototype.getHTTPIP = function (req) {
|
|
||||||
var ip = req.ip;
|
|
||||||
if (ip === "127.0.0.1" || ip === "::1") {
|
|
||||||
var fwd = req.header("x-forwarded-for");
|
|
||||||
if (fwd && typeof fwd === "string") {
|
|
||||||
return fwd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ip;
|
|
||||||
};
|
|
||||||
|
|
||||||
Server.prototype.getSocketIP = function (socket) {
|
|
||||||
var raw = socket.handshake.address.address;
|
|
||||||
if (raw === "127.0.0.1" || raw === "::1") {
|
|
||||||
var fwd = socket.handshake.headers["x-forwarded-for"];
|
|
||||||
if (fwd && typeof fwd === "string") {
|
|
||||||
return fwd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return raw;
|
|
||||||
};
|
|
||||||
|
|
||||||
Server.prototype.isChannelLoaded = function (name) {
|
Server.prototype.isChannelLoaded = function (name) {
|
||||||
name = name.toLowerCase();
|
name = name.toLowerCase();
|
||||||
for (var i = 0; i < this.channels.length; i++) {
|
for (var i = 0; i < this.channels.length; i++) {
|
||||||
|
|
22
src/user.js
22
src/user.js
|
@ -7,14 +7,17 @@ var Account = require("./account");
|
||||||
var Flags = require("./flags");
|
var Flags = require("./flags");
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import Logger from './logger';
|
import Logger from './logger';
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
const LOGGER = require('@calzoneman/jsli')('user');
|
const LOGGER = require('@calzoneman/jsli')('user');
|
||||||
|
|
||||||
function User(socket, ip, loginInfo) {
|
function User(socket, ip, loginInfo) {
|
||||||
this.flags = 0;
|
this.flags = 0;
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
this.realip = ip;
|
// Expanding IPv6 addresses shouldn't really be necessary
|
||||||
this.displayip = util.cloakIP(ip);
|
// At some point, the IPv6 related stuff should be revisited
|
||||||
|
this.realip = net.isIPv6(ip) ? util.expandIPv6(ip) : ip;
|
||||||
|
this.displayip = util.cloakIP(this.realip);
|
||||||
this.channel = null;
|
this.channel = null;
|
||||||
this.queueLimiter = util.newRateLimiter();
|
this.queueLimiter = util.newRateLimiter();
|
||||||
this.chatLimiter = util.newRateLimiter();
|
this.chatLimiter = util.newRateLimiter();
|
||||||
|
@ -22,7 +25,7 @@ function User(socket, ip, loginInfo) {
|
||||||
this.awaytimer = false;
|
this.awaytimer = false;
|
||||||
|
|
||||||
if (loginInfo) {
|
if (loginInfo) {
|
||||||
this.account = new Account.Account(this.realip, loginInfo, socket.aliases);
|
this.account = new Account.Account(this.realip, loginInfo, socket.context.aliases);
|
||||||
this.registrationTime = new Date(this.account.user.time);
|
this.registrationTime = new Date(this.account.user.time);
|
||||||
this.setFlag(Flags.U_REGISTERED | Flags.U_LOGGED_IN | Flags.U_READY);
|
this.setFlag(Flags.U_REGISTERED | Flags.U_LOGGED_IN | Flags.U_READY);
|
||||||
socket.emit("login", {
|
socket.emit("login", {
|
||||||
|
@ -33,7 +36,7 @@ function User(socket, ip, loginInfo) {
|
||||||
socket.emit("rank", this.account.effectiveRank);
|
socket.emit("rank", this.account.effectiveRank);
|
||||||
LOGGER.info(ip + " logged in as " + this.getName());
|
LOGGER.info(ip + " logged in as " + this.getName());
|
||||||
} else {
|
} else {
|
||||||
this.account = new Account.Account(this.realip, null, socket.aliases);
|
this.account = new Account.Account(this.realip, null, socket.context.aliases);
|
||||||
socket.emit("rank", -1);
|
socket.emit("rank", -1);
|
||||||
this.setFlag(Flags.U_READY);
|
this.setFlag(Flags.U_READY);
|
||||||
}
|
}
|
||||||
|
@ -429,12 +432,15 @@ setInterval(function () {
|
||||||
}, 5 * 60 * 1000);
|
}, 5 * 60 * 1000);
|
||||||
|
|
||||||
User.prototype.getFirstSeenTime = function getFirstSeenTime() {
|
User.prototype.getFirstSeenTime = function getFirstSeenTime() {
|
||||||
if (this.registrationTime && this.socket.ipSessionFirstSeen) {
|
if (this.registrationTime && this.socket.context.ipSessionFirstSeen) {
|
||||||
return Math.min(this.registrationTime.getTime(), this.socket.ipSessionFirstSeen.getTime());
|
return Math.min(
|
||||||
|
this.registrationTime.getTime(),
|
||||||
|
this.socket.context.ipSessionFirstSeen.getTime()
|
||||||
|
);
|
||||||
} else if (this.registrationTime) {
|
} else if (this.registrationTime) {
|
||||||
return this.registrationTime.getTime();
|
return this.registrationTime.getTime();
|
||||||
} else if (this.socket.ipSessionFirstSeen) {
|
} else if (this.socket.context.ipSessionFirstSeen) {
|
||||||
return this.socket.ipSessionFirstSeen.getTime();
|
return this.socket.context.ipSessionFirstSeen.getTime();
|
||||||
} else {
|
} else {
|
||||||
LOGGER.error(`User "${this.getName()}" (IP: ${this.realip}) has neither ` +
|
LOGGER.error(`User "${this.getName()}" (IP: ${this.realip}) has neither ` +
|
||||||
"an IP session first seen time nor a registered account.");
|
"an IP session first seen time nor a registered account.");
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
class TokenBucket {
|
||||||
|
constructor(capacity, refillRate) {
|
||||||
|
this.capacity = capacity;
|
||||||
|
this.refillRate = refillRate;
|
||||||
|
this.count = capacity;
|
||||||
|
this.lastRefill = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
throttle() {
|
||||||
|
const now = Date.now();
|
||||||
|
const delta = Math.floor((now - this.lastRefill) / 1000 * this.refillRate);
|
||||||
|
if (delta > 0) {
|
||||||
|
this.count = Math.min(this.capacity, this.count + delta);
|
||||||
|
this.lastRefill = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.count === 0) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
this.count--;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TokenBucket };
|
|
@ -0,0 +1,153 @@
|
||||||
|
const assert = require('assert');
|
||||||
|
const IOServer = require('../../lib/io/ioserver').IOServer;
|
||||||
|
|
||||||
|
describe('IOServer', () => {
|
||||||
|
let server;
|
||||||
|
let socket;
|
||||||
|
beforeEach(() => {
|
||||||
|
server = new IOServer();
|
||||||
|
socket = {
|
||||||
|
context: {
|
||||||
|
ipAddress: '9.9.9.9'
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
request: {
|
||||||
|
connection: {
|
||||||
|
remoteAddress: '127.0.0.1'
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': '1.2.3.4'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.request = socket.client.request;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#ipProxyMiddleware', () => {
|
||||||
|
it('proxies from a trusted address', done => {
|
||||||
|
server.ipProxyMiddleware(socket, error => {
|
||||||
|
assert(!error);
|
||||||
|
assert.strictEqual(socket.context.ipAddress, '1.2.3.4');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not proxy from a non-trusted address', done => {
|
||||||
|
socket.client.request.connection.remoteAddress = '5.6.7.8';
|
||||||
|
server.ipProxyMiddleware(socket, error => {
|
||||||
|
assert(!error);
|
||||||
|
assert.strictEqual(socket.context.ipAddress, '5.6.7.8');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets context.torConnection = true for Tor exits', () => {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#ipBanMiddleware', () => {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#ipThrottleMiddleware', () => {
|
||||||
|
it('throttles connections', done => {
|
||||||
|
let i = 0;
|
||||||
|
function callback(error) {
|
||||||
|
if (i < 5) {
|
||||||
|
assert(!error);
|
||||||
|
} else {
|
||||||
|
assert.strictEqual(error.message, 'Rate limit exceeded');
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
server.ipThrottleMiddleware(socket, error => {
|
||||||
|
callback(error);
|
||||||
|
if (++i < 6) next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#ipConnectionLimitMiddleware', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
socket.once = (event, callback) => {
|
||||||
|
socket[`on_${event}`] = callback;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows IPs before the limit', done => {
|
||||||
|
server.ipConnectionLimitMiddleware(socket, error => {
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects IPs at the limit', done => {
|
||||||
|
server.ipCount.set(socket.context.ipAddress,
|
||||||
|
require('../../lib/config').get('io.ip-connection-limit'));
|
||||||
|
server.ipConnectionLimitMiddleware(socket, error => {
|
||||||
|
assert(error, 'Expected an error to be returned');
|
||||||
|
assert.strictEqual(error.message,
|
||||||
|
'Too many connections from your IP address');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manages the ipCount map correctly', done => {
|
||||||
|
const ip = socket.context.ipAddress;
|
||||||
|
|
||||||
|
assert(!server.ipCount.has(ip), 'Test precondition failed: ipCount.has(ip)');
|
||||||
|
|
||||||
|
server.ipConnectionLimitMiddleware(socket, error => {
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.strictEqual(server.ipCount.get(ip), 1);
|
||||||
|
|
||||||
|
socket.on_disconnect();
|
||||||
|
|
||||||
|
assert.strictEqual(server.ipCount.get(ip), 0);
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#cookieParsingMiddleware', () => {
|
||||||
|
it('parses cookies', done => {
|
||||||
|
socket.request.headers.cookie = 'flavor=chocolate%20chip';
|
||||||
|
|
||||||
|
server.cookieParsingMiddleware(socket, () => {
|
||||||
|
assert.strictEqual(socket.request.cookies.flavor, 'chocolate chip');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to empty objects if no cookies', done => {
|
||||||
|
server.cookieParsingMiddleware(socket, () => {
|
||||||
|
assert.deepStrictEqual(socket.request.cookies, {});
|
||||||
|
assert.deepStrictEqual(socket.request.signedCookies, {});
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#ipSessionCookieMiddleware', () => {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#authUserMiddleware', () => {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
});
|
|
@ -6,6 +6,10 @@ var fs = require('fs');
|
||||||
var path = require('path');
|
var path = require('path');
|
||||||
|
|
||||||
describe('JSONFileMetricsReporter', function () {
|
describe('JSONFileMetricsReporter', function () {
|
||||||
|
before(() => {
|
||||||
|
Metrics.clearReportHooks();
|
||||||
|
});
|
||||||
|
|
||||||
describe('#report', function () {
|
describe('#report', function () {
|
||||||
it('reports metrics to file', function (done) {
|
it('reports metrics to file', function (done) {
|
||||||
const outfile = path.resolve(os.tmpdir(),
|
const outfile = path.resolve(os.tmpdir(),
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
const assert = require('assert');
|
||||||
|
const TokenBucket = require('../../lib/util/token-bucket').TokenBucket;
|
||||||
|
|
||||||
|
describe('TokenBucket', () => {
|
||||||
|
describe('#throttle', () => {
|
||||||
|
let bucket;
|
||||||
|
beforeEach(() => {
|
||||||
|
bucket = new TokenBucket(5, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumes capacity and then throttles', () => {
|
||||||
|
assert(!bucket.throttle(), 'should not be empty yet');
|
||||||
|
assert(!bucket.throttle(), 'should not be empty yet');
|
||||||
|
assert(!bucket.throttle(), 'should not be empty yet');
|
||||||
|
assert(!bucket.throttle(), 'should not be empty yet');
|
||||||
|
assert(!bucket.throttle(), 'should not be empty yet');
|
||||||
|
assert(bucket.throttle(), 'should be empty now');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refills tokens', () => {
|
||||||
|
bucket.count = 0;
|
||||||
|
const oldRefill = bucket.lastRefill = Date.now() - 1000;
|
||||||
|
assert(!bucket.throttle(), 'should have refilled');
|
||||||
|
assert(bucket.lastRefill >= oldRefill + 1000, 'should have updated lastRefill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refills at most {capacity} tokens', () => {
|
||||||
|
bucket.count = 0;
|
||||||
|
bucket.lastRefill = Date.now() - 10000;
|
||||||
|
bucket.throttle();
|
||||||
|
assert.strictEqual(bucket.count, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does a partial refill', () => {
|
||||||
|
bucket.count = 0;
|
||||||
|
bucket.lastRefill = Date.now() - 400;
|
||||||
|
bucket.throttle();
|
||||||
|
assert.strictEqual(bucket.count, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips refilling if delta = 0', () => {
|
||||||
|
bucket.count = 0;
|
||||||
|
const oldRefill = bucket.lastRefill;
|
||||||
|
bucket.throttle();
|
||||||
|
assert.strictEqual(bucket.count, 0);
|
||||||
|
assert.strictEqual(bucket.lastRefill, oldRefill);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles fractional refill rates', () => {
|
||||||
|
bucket = new TokenBucket(5, 0.1);
|
||||||
|
bucket.count = 0;
|
||||||
|
assert(bucket.throttle());
|
||||||
|
bucket.lastRefill = Date.now() - 10000;
|
||||||
|
assert(!bucket.throttle());
|
||||||
|
assert.strictEqual(bucket.count, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -40,6 +40,14 @@ Callbacks = {
|
||||||
scrollChat();
|
scrollChat();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Socket.IO error callback
|
||||||
|
error: function (msg) {
|
||||||
|
$("<div/>")
|
||||||
|
.addClass("server-msg-disconnect")
|
||||||
|
.text("Unable to connect: " + msg)
|
||||||
|
.appendTo($("#messagebuffer"));
|
||||||
|
},
|
||||||
|
|
||||||
errorMsg: function(data) {
|
errorMsg: function(data) {
|
||||||
if (data.alert) {
|
if (data.alert) {
|
||||||
alert(data.msg);
|
alert(data.msg);
|
||||||
|
|
Loading…
Reference in New Issue