diff --git a/src/channel/channel.js b/src/channel/channel.js index 1d5eb35e..3b44da0b 100644 --- a/src/channel/channel.js +++ b/src/channel/channel.js @@ -370,7 +370,7 @@ Channel.prototype.acceptUser = function (user) { user.socket.on("readChanLog", this.handleReadLog.bind(this, user)); 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")) { user.kick("This channel has banned connections from Tor."); this.logger.log("[login] Blocked connection from Tor exit at " + diff --git a/src/io/ioserver.js b/src/io/ioserver.js index 567e1fba..f1cb2a95 100644 --- a/src/io/ioserver.js +++ b/src/io/ioserver.js @@ -1,17 +1,12 @@ -var sio = require("socket.io"); -var db = require("../database"); -var User = require("../user"); -var Server = require("../server"); -var Config = require("../config"); -var cookieParser = require("cookie-parser")(Config.get("http.cookie-secret")); -var $util = require("../utilities"); -var Flags = require("../flags"); -var typecheck = require("json-typecheck"); -var net = require("net"); -var util = require("../utilities"); -var crypto = require("crypto"); -var isTorExit = require("../tor").isTorExit; -var session = require("../session"); +import sio from 'socket.io'; +import db from '../database'; +import User from '../user'; +import Server from '../server'; +import Config from '../config'; +const cookieParser = require("cookie-parser")(Config.get("http.cookie-secret")); +import typecheck from 'json-typecheck'; +import { isTorExit } from '../tor'; +import session from '../session'; import counters from '../counters'; import { verifyIPSessionCookie } from '../web/middleware/ipsessioncookie'; import Promise from 'bluebird'; @@ -21,118 +16,181 @@ import { CachingGlobalBanlist } from './globalban'; import proxyaddr from 'proxy-addr'; import { Counter, Gauge } from 'prom-client'; import Socket from 'socket.io/lib/socket'; +import { TokenBucket } from '../util/token-bucket'; +import http from 'http'; const LOGGER = require('@calzoneman/jsli')('ioserver'); -var CONNECT_RATE = { - burst: 5, - sustained: 0.1 -}; +// WIP, not in use yet +class IOServer { + constructor(options = { + proxyTrustFn: proxyaddr.compile('127.0.0.1') + }) { + ({ + proxyTrustFn: this.proxyTrustFn + } = options); -var ipThrottle = {}; -// Keep track of number of connections per IP -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 - })); + this.ipThrottle = new Map(); + this.ipCount = new Map(); } - promises.push(getAliases(socket._realip).then(aliases => { - socket.aliases = aliases; - }).catch(error => { - // Do nothing - })); - - Promise.all(promises).then(() => { - 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]; + // Map proxied sockets to the real IP address via X-Forwarded-For + // If the resulting address is a known Tor exit, flag it as such + ipProxyMiddleware(socket, next) { + if (!socket.context) socket.context = {}; + socket.context.ipAddress = proxyaddr(socket.client.request, this.proxyTrustFn); + if (isTorExit(socket.context.ipAddress)) { + socket.context.torConnection = true; } - }); - - if (!(ip in ipCount)) { - ipCount[ip] = 0; + next(); } - ipCount[ip]++; - if (ipCount[ip] > Config.get("io.ip-connection-limit")) { - sock.emit("kick", { - reason: "Too many connections from your IP address" + // Reject global banned IP addresses + ipBanMiddleware(socket, next) { + if (isIPGlobalBanned(socket.context.ipAddress)) { + 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; function isIPGlobalBanned(ip) { if (globalIPBanlist === null) { @@ -219,7 +265,7 @@ function emitMetrics(sock) { } } catch (error) { 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()); } catch (error) { LOGGER.error('Error emitting disconnect metrics for socket (ip=%s): %s', - sock._realip, error.stack); + sock.context.ipAddress, error.stack); } }); } catch (error) { LOGGER.error('Error emitting metrics for socket (ip=%s): %s', - sock._realip, error.stack); + sock.context.ipAddress, error.stack); } } -/** - * 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); - } - -} +let instance = null; module.exports = { init: function (srv, webConfig) { - patchTypecheckedFunctions(); - var bound = {}; - const ioOptions = { - perMessageDeflate: Config.get("io.per-message-deflate") - }; - var io = sio.instance = sio(); + if (instance !== null) { + throw new Error('ioserver.init: already initialized'); + } - io.use(ipForwardingMiddleware(webConfig)); - io.use(parseCookies); - io.use(handleIPSessionCookie); - io.use(handleAuth); - io.on("connection", handleConnection); + const ioServer = instance = new IOServer({ + proxyTrustFn: proxyaddr.compile(webConfig.getTrustedProxies()) + }); + + ioServer.initSocketIO(); + + const uniqueListenAddresses = new Set(); + const servers = []; Config.get("listen").forEach(function (bind) { if (!bind.io) { return; } - var id = bind.ip + ":" + bind.port; - if (id in bound) { - LOGGER.warn("Ignoring duplicate listen address " + id); + + const id = bind.ip + ":" + bind.port; + if (uniqueListenAddresses.has(id)) { + LOGGER.warn("Ignoring duplicate listen address %s", id); return; } - if (id in srv.servers) { - io.attach(srv.servers[id], ioOptions); + if (srv.servers.hasOwnProperty(id)) { + servers.push(srv.servers[id]); } else { - var server = require("http").createServer().listen(bind.port, bind.ip); - server.on("clientError", function (err, socket) { - try { - socket.destroy(); - } catch (e) { - } - }); - io.attach(server, ioOptions); + const server = http.createServer().listen(bind.port, bind.ip); + servers.push(server); } - bound[id] = null; + uniqueListenAddresses.add(id); }); + + ioServer.bindTo(servers); }, - handleConnection: handleConnection + IOServer: IOServer }; /* Clean out old rate limiters */ setInterval(function () { - for (var ip in ipThrottle) { - if (ipThrottle[ip].lastTime < Date.now() - 60 * 1000) { - var obj = ipThrottle[ip]; - /* Not strictly necessary, but seems to help the GC out a bit */ - for (var key in obj) { - delete obj[key]; - } - delete ipThrottle[ip]; + if (instance == null) return; + + let cleaned = 0; + const keys = instance.ipThrottle.keys(); + for (const key of keys) { + if (instance.ipThrottle.get(key).lastRefill < Date.now() - 60000) { + const bucket = instance.ipThrottle.delete(key); + for (const k in bucket) delete bucket[k]; + cleaned++; } } - if (Config.get("aggressive-gc") && global && global.gc) { - global.gc(); + if (cleaned > 0) { + LOGGER.info('Cleaned up %d stale IP throttle token buckets', cleaned); } }, 5 * 60 * 1000); diff --git a/src/metrics/metrics.js b/src/metrics/metrics.js index 0088b5b8..6e1badb3 100644 --- a/src/metrics/metrics.js +++ b/src/metrics/metrics.js @@ -104,6 +104,10 @@ export function addReportHook(hook) { reportHooks.push(hook); } +export function clearReportHooks() { + reportHooks = []; +} + /** * Force metrics to be reported right now. */ diff --git a/src/server.js b/src/server.js index 9c8f7fcc..76b596be 100644 --- a/src/server.js +++ b/src/server.js @@ -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) { name = name.toLowerCase(); for (var i = 0; i < this.channels.length; i++) { diff --git a/src/user.js b/src/user.js index b9a00df2..0dc7cdba 100644 --- a/src/user.js +++ b/src/user.js @@ -7,14 +7,17 @@ var Account = require("./account"); var Flags = require("./flags"); import { EventEmitter } from 'events'; import Logger from './logger'; +import net from 'net'; const LOGGER = require('@calzoneman/jsli')('user'); function User(socket, ip, loginInfo) { this.flags = 0; this.socket = socket; - this.realip = ip; - this.displayip = util.cloakIP(ip); + // Expanding IPv6 addresses shouldn't really be necessary + // 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.queueLimiter = util.newRateLimiter(); this.chatLimiter = util.newRateLimiter(); @@ -22,7 +25,7 @@ function User(socket, ip, loginInfo) { this.awaytimer = false; 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.setFlag(Flags.U_REGISTERED | Flags.U_LOGGED_IN | Flags.U_READY); socket.emit("login", { @@ -33,7 +36,7 @@ function User(socket, ip, loginInfo) { socket.emit("rank", this.account.effectiveRank); LOGGER.info(ip + " logged in as " + this.getName()); } 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); this.setFlag(Flags.U_READY); } @@ -429,12 +432,15 @@ setInterval(function () { }, 5 * 60 * 1000); User.prototype.getFirstSeenTime = function getFirstSeenTime() { - if (this.registrationTime && this.socket.ipSessionFirstSeen) { - return Math.min(this.registrationTime.getTime(), this.socket.ipSessionFirstSeen.getTime()); + if (this.registrationTime && this.socket.context.ipSessionFirstSeen) { + return Math.min( + this.registrationTime.getTime(), + this.socket.context.ipSessionFirstSeen.getTime() + ); } else if (this.registrationTime) { return this.registrationTime.getTime(); - } else if (this.socket.ipSessionFirstSeen) { - return this.socket.ipSessionFirstSeen.getTime(); + } else if (this.socket.context.ipSessionFirstSeen) { + return this.socket.context.ipSessionFirstSeen.getTime(); } else { LOGGER.error(`User "${this.getName()}" (IP: ${this.realip}) has neither ` + "an IP session first seen time nor a registered account."); diff --git a/src/util/token-bucket.js b/src/util/token-bucket.js new file mode 100644 index 00000000..c71e97a5 --- /dev/null +++ b/src/util/token-bucket.js @@ -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 }; diff --git a/test/io/ioserver.js b/test/io/ioserver.js new file mode 100644 index 00000000..02776c4d --- /dev/null +++ b/test/io/ioserver.js @@ -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 + }); +}); diff --git a/test/metrics/metricstest.js b/test/metrics/metricstest.js index 717370cb..85191e57 100644 --- a/test/metrics/metricstest.js +++ b/test/metrics/metricstest.js @@ -6,6 +6,10 @@ var fs = require('fs'); var path = require('path'); describe('JSONFileMetricsReporter', function () { + before(() => { + Metrics.clearReportHooks(); + }); + describe('#report', function () { it('reports metrics to file', function (done) { const outfile = path.resolve(os.tmpdir(), diff --git a/test/util/token-bucket.js b/test/util/token-bucket.js new file mode 100644 index 00000000..f170f08a --- /dev/null +++ b/test/util/token-bucket.js @@ -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); + }); + }); +}); diff --git a/www/js/callbacks.js b/www/js/callbacks.js index 15966819..a0cc85af 100644 --- a/www/js/callbacks.js +++ b/www/js/callbacks.js @@ -40,6 +40,14 @@ Callbacks = { scrollChat(); }, + // Socket.IO error callback + error: function (msg) { + $("
") + .addClass("server-msg-disconnect") + .text("Unable to connect: " + msg) + .appendTo($("#messagebuffer")); + }, + errorMsg: function(data) { if (data.alert) { alert(data.msg);