From 808bd6b453758f33633eef2d2f9885e918c836a8 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 18 Mar 2020 15:08:56 +0100 Subject: [PATCH 1/7] Validate messages sent Validate messages sent over links with the json schema provided from them. This aids debugging as an exception will be thrown from the code sending the bad content. --- lib/link/messages.js | 14 ++++++++++++++ test/lib/link/messages.js | 10 ++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/link/messages.js b/lib/link/messages.js index e651e5cb..7cd22e8d 100644 --- a/lib/link/messages.js +++ b/lib/link/messages.js @@ -140,6 +140,11 @@ class Request { response = {}; } + if (!this._responseValidator({ seq: 0, type: this.responseType, response})) { + console.error(this._responseValidator.errors); + throw new Error(`Validation failed responding to ${this.requestType}`); + } + link.connector.send(this.responseType, { ...response, seq: message.seq }); }).catch(err => { @@ -162,6 +167,10 @@ class Request { */ async send(link, data = {}) { // XXX validate link target/source? + if (!this._requestValidator({ seq: 0, type: this.requestType, data})) { + console.error(this._requestValidator.errors); + throw new Error(`Validation failed sending ${this.requestType}`); + } let seq = link.connector.send(this.requestType, data); let responseMessage = await link.waitFor(this.responseType, { seq }); if (responseMessage.data.error) { @@ -454,6 +463,11 @@ class Event { * Sends the given event data over the link. */ send(link, data = {}) { + if (!this._eventValidator({ seq: 0, type: this.eventType, data})) { + console.error(this._eventValidator.errors); + throw new Error(`Validation failed sending ${this.eventType}`); + } + link.connector.send(this.eventType, data); } } diff --git a/test/lib/link/messages.js b/test/lib/link/messages.js index 21d03629..33997cef 100644 --- a/test/lib/link/messages.js +++ b/test/lib/link/messages.js @@ -16,6 +16,9 @@ describe("lib/link/messages", function() { let testRequest = new link.Request({ type: 'test', links: ['source-target'], + requestProperties: { + "test": { type: "string" }, + }, }); describe("constructor", function() { @@ -93,8 +96,8 @@ describe("lib/link/messages", function() { testSourceLink.waitFor = (type, condition) => { return { data: { error: "test error" }}; }; - assert.rejects( - testRequest.send(testSourceLink), + await assert.rejects( + testRequest.send(testSourceLink, { test: "blah" }), new errors.RequestError("test error") ); delete testSourceLink.waitFor; @@ -106,6 +109,9 @@ describe("lib/link/messages", function() { let testEvent = new link.Event({ type: 'test', links: ['source-target'], + eventProperties: { + "test": { type: "string" }, + }, }); describe("constructor", function() { From 677512512ece623d522e6b3fbdb91df237940b4e Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sat, 21 Mar 2020 20:18:43 +0100 Subject: [PATCH 2/7] Replace socket.io with plain WebSocket Replace socket.io with plain WebSocket using the ws library as it doesn't really provide any useful tools over plain WebSocket when it comes to implementing reliable communication that works across reconnects. The widespread browser support for WebSocket also means it has little value to add for the web interface over WebSocket. --- CHANGELOG.md | 1 + client.js | 9 +-- clusterctl.js | 12 ++-- docs/socket.md | 20 ++----- lib/link/connectors.js | 79 +++++++++++--------------- lib/npmPostinstall.js | 4 -- lib/schema.js | 6 +- master.js | 110 +++++++++++++++++++++---------------- package.json | 3 +- test/lib/link/connector.js | 31 ++++++----- test/master.js | 8 +-- test/mock.js | 5 +- 12 files changed, 139 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 669f9877..f2835276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,6 +143,7 @@ Version 2.0.0 - Removed `--databaseDirectory`, `--masterPort`, and `--sslPort` command line arguments from the master server. - Implemented a new config system that replaces the old. Breaks all plugins. +- Removed usage of socket.io entirely in favor of a plain WebSocket connection. Version 1.2.4 diff --git a/client.js b/client.js index 725d185f..3d21c2dd 100644 --- a/client.js +++ b/client.js @@ -390,17 +390,19 @@ class InstanceConnection extends link.Link { } } -class SlaveConnector extends link.SocketIOClientConnector { +class SlaveConnector extends link.WebSocketClientConnector { constructor(slaveConfig) { - super(slaveConfig.get("slave.master_url"), slaveConfig.get("slave.master_token")); + super(slaveConfig.get("slave.master_url")); this.id = slaveConfig.get("slave.id"); this.name = slaveConfig.get("slave.name"); + this._token = slaveConfig.get("slave.master_token"); } register() { console.log("SOCKET | registering slave"); this.send('register_slave', { + token: this._token, agent: 'Clusterio Slave', version, id: this.id, @@ -412,8 +414,7 @@ class SlaveConnector extends link.SocketIOClientConnector { /** * Handles running the slave * - * Connects to the master server over the socket.io connection and manages - * intsances. + * Connects to the master server over the WebSocket and manages intsances. */ class Slave extends link.Link { // I don't like God classes, but the alternative of putting all this state diff --git a/clusterctl.js b/clusterctl.js index 873e6d49..edf8baef 100644 --- a/clusterctl.js +++ b/clusterctl.js @@ -1,4 +1,3 @@ -const io = require("socket.io-client"); const jwt = require("jsonwebtoken"); const fs = require("fs-extra"); const yargs = require("yargs"); @@ -315,10 +314,16 @@ commands = new Map([...commands.map(command => [command.name, command])]); /** * Connector for control connection to master server */ -class ControlConnector extends link.SocketIOClientConnector { +class ControlConnector extends link.WebSocketClientConnector { + constructor(url, token) { + super(url); + this._token = token; + } + register() { console.log("SOCKET | registering control"); this.send('register_control', { + token: this._token, agent: 'clusterctl', version: version, }); @@ -328,8 +333,7 @@ class ControlConnector extends link.SocketIOClientConnector { /** * Handles running the control * - * Connects to the master server over the socket.io connection and sends - * commands to it. + * Connects to the master server over WebSocket and sends commands to it. */ class Control extends link.Link { diff --git a/docs/socket.md b/docs/socket.md index 523cf684..790e7b0d 100644 --- a/docs/socket.md +++ b/docs/socket.md @@ -5,21 +5,9 @@ Socket.io protocol slaves, and unless you're developing Clusterio it's probably not going to be very useful for you. -The protocol is built on the [socket.io](https://socket.io/) library -which lets mulitple streams be multiplexed in many different ways over -the same connection and abstracts away reconnection logic. -Unfortunately I've been unable to figure what the message delivery -guarantees of the library is, so whether or not messages can be lost is -still an unknown, I assume that messages are delivered as long as a -connection is maintained or manages to reconnect. - -While there are many mechanisms to multiplex streams, the core -communication between the master server and slaves is done entirely by -sending single POJO payloads over the `message` event. There's a -shortcut for sending these via the `.send` method of the socket.io -sockets. - -These messages contain the following three properties: +The protocol is built on WebSocket and uses JSON encoded text messages +to communicate to and from the master server. These messages contain +the following three properties: - seq - integer - The sequence number of the message - type - string - The type of message - data - object - Message data @@ -28,7 +16,7 @@ These messages contain the following three properties: Handshake --------- -Upon connecting to the socket.io endpoint the server will respond with a +Upon connecting to the WebSocket endpoint the server will respond with a `hello` message containing the master server version as the data. The client is then expected to send a `register_slave` or `register_control` message back depending on what kind of client it is. If the register diff --git a/lib/link/connectors.js b/lib/link/connectors.js index 93458546..6311ce2e 100644 --- a/lib/link/connectors.js +++ b/lib/link/connectors.js @@ -1,6 +1,6 @@ "use strict"; const events = require("events"); -const io = require("socket.io-client"); +const WebSocket = require("ws"); const schema = require("lib/schema"); @@ -8,12 +8,11 @@ const schema = require("lib/schema"); /** * Connector for master server clients */ -class SocketIOClientConnector extends events.EventEmitter { - constructor(url, token) { +class WebSocketClientConnector extends events.EventEmitter { + constructor(url) { super(); this._url = url; - this._token = token; this._seq = 1; this._state = "new"; @@ -33,7 +32,7 @@ class SocketIOClientConnector extends events.EventEmitter { * @returns the sequence number of the message sent */ send(type, data) { - this._socket.send({ seq: this._seq, type, data }); + this._socket.send(JSON.stringify({ seq: this._seq, type, data })); return this._seq++; } @@ -44,7 +43,7 @@ class SocketIOClientConnector extends events.EventEmitter { */ close(reason) { this.send('close', { reason }); - this.disconnect(); + this._socket.close(); } /** @@ -54,19 +53,13 @@ class SocketIOClientConnector extends events.EventEmitter { this._check(["new"]); this._state = "handshake"; - // Open socket.io connection to master - console.log(`SOCKET | connecting to ${this._url}`) let url = new URL(this._url); - url.searchParams.append('token', this._token); + url.pathname += "api/socket"; - // I really have to question the choice of having the path connected - // to be passed as separate parameter instead of the namespace. - let urlPath = url.pathname + "socket.io"; - url.pathname = ""; - this._socket = io(url.href, { - path: urlPath, - reconnectionAttempts: 2, + // Open WebSocket to master + console.log(`SOCKET | connecting to ${url}`) + this._socket = new WebSocket(url, { // For now we do not verify TLS certificates since the default setup is // to create a self-signed certificate. rejectUnauthorized: false, @@ -79,45 +72,35 @@ class SocketIOClientConnector extends events.EventEmitter { } _attachSocketHandlers() { + this._socket.on("close", (code, reason) => { + console.log(`SOCKET | Close (code: ${code}, reason: ${reason})`); + }); this._socket.on("error", err => { console.error("SOCKET | Error:", err); this._socket.close(); }); + this._socket.on("unexpected-response", (req, res) => { + let data = ""; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + console.error(`SOCKET | Unexpeced Response ${res.statusCode}`); + console.error(data); + }); + }); - /*this._socket.on("connect_error", err => { - console.log("SOCKET | Connect error:", err); - });*/ - this._socket.on("connect_timeout", () => { - console.log("SOCKET | Connect timeout"); + this._socket.on("open", () => { + console.log("SOCKET | Open"); }); - this._socket.on("reconnect", attempt => { - console.log(`SOCKET | Reconnect no. ${attempt} succeeded`); + this._socket.on("ping", data => { + console.log(`SOCKET | Ping (data: ${data}`); }); - this._socket.on("reconnect_attempt", () => { - console.log("SOCKET | Attempting reconnect"); - }); - this._socket.on("reconnecting", attempt => { - console.log(`SOCKET | Attempting reconnect no. ${attempt}`); - }); - /*this._socket.on("reconnect_error", err => { - console.log("SOCKET | Reconnect error:", err); - });*/ - this._socket.on("reconnect_failed", () => { - console.log("SOCKET | Reconnecting failed"); - }); - /*this._socket.on("ping", () => { - console.log("SOCKET | Ping"); - }); - this._socket.on("pong", (ms) => { - // XXX Latency might be useful - console.log(`SOCKET | Pong latency ${ms}ms`); - });*/ - this._socket.on("connect", () => { - console.log("SOCKET | connected"); + this._socket.on("pong", data => { + console.log(`SOCKET | Pong (data: ${data}`); }); // Handle messages - this._socket.on("message", message => { + this._socket.on("message", data => { + let message = JSON.parse(data); if (this._state === "handshake") { this._processHandshake(message); } else if (this._state === "ready") { @@ -132,7 +115,7 @@ class SocketIOClientConnector extends events.EventEmitter { * Immediatly close the connection */ disconnect() { - this._socket.close(); + this._socket.terminate(); } _processHandshake(message) { @@ -155,7 +138,7 @@ class SocketIOClientConnector extends events.EventEmitter { } else if (type === 'close') { console.log(`SOCKET | received close from server: ${data.reason}`); this.emit('error', new Error(`server closed during handshake: ${data.reason}`)); - this._socket.close(); + this._socket.terminate(); } } @@ -224,6 +207,6 @@ class VirtualConnector extends events.EventEmitter { } module.exports = { - SocketIOClientConnector, + WebSocketClientConnector, VirtualConnector, }; diff --git a/lib/npmPostinstall.js b/lib/npmPostinstall.js index 73095cac..a83a3e45 100644 --- a/lib/npmPostinstall.js +++ b/lib/npmPostinstall.js @@ -87,10 +87,6 @@ let downloadList = [ url: "https://fonts.googleapis.com/css?family=Roboto", file: "static/external/css/Roboto.css", }, - { - url: "https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.4/socket.io.js", - file: "static/external/socket.io.js", - }, ] // initiate folder creations and downloads diff --git a/lib/schema.js b/lib/schema.js index 8b49c9cb..788dd339 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -105,8 +105,9 @@ const clientHandshake = ajv.compile({ "type": { const: "register_slave" }, "data": { additionalProperties: false, - required: ["agent", "version", "name", "id"], + required: ["token", "agent", "version", "name", "id"], properties: { + "token": { type: "string" }, "agent": { type: "string" }, "version": { type: "string" }, "name": { type: "string" }, @@ -120,8 +121,9 @@ const clientHandshake = ajv.compile({ "type": { const: "register_control" }, "data": { additionalProperties: false, - required: ["agent", "version"], + required: ["token", "agent", "version"], properties: { + "token": { type: "string" }, "agent": { type: "string" }, "version": { type: "string" }, } diff --git a/master.js b/master.js index 853e5050..29e8b8cc 100644 --- a/master.js +++ b/master.js @@ -23,6 +23,7 @@ const request = require("request"); const setBlocking = require("set-blocking"); const events = require("events"); const yargs = require("yargs"); +const WebSocket = require("ws"); const version = require("./package").version; // ugly globals @@ -292,9 +293,6 @@ app.get("/api/getFactorioLocale", function(req,res){ } }); -// socket.io connection for slaves -var io = require("socket.io")({}); - class BaseConnection extends link.Link { constructor(target, connector) { super('master', target, connector); @@ -541,17 +539,9 @@ class SlaveConnection extends BaseConnection { } } -// Authentication middleware for socket.io connections -io.use((socket, next) => { - let token = socket.handshake.query.token; - authenticate.check(token).then((result) => { - if (result.ok) { - next(); - } else { - console.error(`SOCKET | authentication failed for ${socket.handshake.address}`); - next(new Error(result.msg)); - } - }); +const wss = new WebSocket.Server({ + noServer: true, + path: "/api/socket", }); /** @@ -569,7 +559,7 @@ function isInteger(value) { /** * Connector for master server connections */ -class SocketIOServerConnector extends events.EventEmitter { +class WebSocketServerConnector extends events.EventEmitter { constructor(socket) { super(); @@ -577,9 +567,9 @@ class SocketIOServerConnector extends events.EventEmitter { this._socket = socket; this._socket.on('message', message => { - this.emit('message', message); + this.emit('message', JSON.parse(message)); }); - this._socket.on('disconnect', () => { + this._socket.on('close', (code, reason) => { this.emit('disconnect'); }); } @@ -590,7 +580,7 @@ class SocketIOServerConnector extends events.EventEmitter { * @returns the sequence number of the message sent */ send(type, data = {}) { - this._socket.send({ seq: this._seq, type, data }); + this._socket.send(JSON.stringify({ seq: this._seq, type, data })); return this._seq++; } @@ -601,53 +591,73 @@ class SocketIOServerConnector extends events.EventEmitter { */ close(reason) { this.send('close', { reason }); - this.disconnect(); + this._socket.close(); } /** * Immediatly close the connection */ disconnect() { - this._socket.disconnect(true); + this._socket.terminate(); } } -io.on('connection', function (socket) { - console.log(`SOCKET | new connection from ${socket.handshake.address}`); +wss.on('connection', function (socket, req) { + console.log(`SOCKET | new connection from ${socket.remoteAddress}`); // Start connection handshake - let connector = new SocketIOServerConnector(socket); + let connector = new WebSocketServerConnector(socket); connector.send('hello', { version }); // Handle socket handshake socket.once('message', (payload) => { + payload = JSON.parse(payload); + // XXX prometheusWsUsageCounter.labels('message', "other").inc(); if (!schema.clientHandshake(payload)) { - console.log(`SOCKET | closing ${socket.handshake.address} after receiving invalid handshake`, payload); - connector.send('close', { reason: "Invalid handshake" }); - connector.disconnect(true); + console.log(`SOCKET | closing ${req.socket.remoteAddress} after receiving invalid handshake`, payload); + connector.close("Invalid handshake"); return; } let { seq, type, data } = payload; - if (type === "register_slave") { - let connection = slaveConnections.get(data.id); - if (connection) { - console.log(`SOCKET | disconecting existing connection for slave ${data.id}`); - connection.connector.close("Registered from another connection"); - } + if (["register_slave", "register_control"].includes(type)) { + authenticate.check(data.token).then((result) => { + if (result.ok) { + if (type === "register_slave") { + let connection = slaveConnections.get(data.id); + if (connection) { + console.log(`SOCKET | disconecting existing connection for slave ${data.id}`); + connection.connector.close("Registered from another connection"); + } - console.log(`SOCKET | registered slave ${data.id} version ${data.version}`); - slaveConnections.set(data.id, new SlaveConnection(data, connector)); - connector.send('ready'); + console.log(`SOCKET | registered slave ${data.id} version ${data.version}`); + slaveConnections.set(data.id, new SlaveConnection(data, connector)); + connector.send('ready'); - } else if (type === "register_control") { - console.log(`SOCKET | registered control from ${socket.handshake.address}`); - controlConnections.push(new ControlConnection(data, connector)); - connector.send('ready'); + } else { // type === "register_control" + console.log(`SOCKET | registered control from ${req.socket.remoteAddress}`); + controlConnections.push(new ControlConnection(data, connector)); + connector.send('ready'); + } + + } else { + console.error(`SOCKET | authentication failed for ${req.socket.remoteAddress}`); + connector.close("Authentication failed"); + } + }).catch((err) => { + console.error(` ++---------------------------------------------------------------------+ +| Unexpected error occured while authenticating client, please report | +| it to https://github.com/clusterio/factorioClusterio/issues | ++---------------------------------------------------------------------+` + ); + console.error(err); + connection.close("Unexpected error"); + }); } else if (type === "close") { - console.log(`SOCKET | received close from ${socket.handshake.address}: ${data.reason}`); + console.log(`SOCKET | received close from ${req.socket.remoteAddress}: ${data.reason}`); connector.disconnect(); return; } @@ -693,10 +703,21 @@ async function loadPlugins(pluginInfos) { /** * Calls listen on server capturing any errors that occurs - * binding to the port. + * binding to the port. Also adds handler for WebSocket + * upgrade event. */ function listen(server, ...args) { return new Promise((resolve, reject) => { + server.on("upgrade", (req, socket, head) => { + console.log("handling upgrade"); + + // For reasons that defy common sense, the connection event has + // to be emitted explictly when using noServer. + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit('connection', ws, req); + }); + }); + function wrapError(err) { reject(new errors.StartupError( `Server listening failed: ${err.message}` @@ -826,7 +847,6 @@ async function startServer() { if (httpPort) { httpServer = require("http").Server(app); await listen(httpServer, httpPort); - io.attach(httpServer); console.log("Listening for HTTP on port %s...", httpServer.address().port); } @@ -847,10 +867,6 @@ async function startServer() { cert: certificate }, app) await listen(httpsServer, httpsPort); - - // XXX I'm uncertain whether or not socket.io actually supports - // attaching to multiple servers at the same time. - io.attach(httpsServer); console.log("Listening for HTTPS on port %s...", httpsServer.address().port); } } @@ -861,7 +877,7 @@ module.exports = { // For testing only _db: db, _setConfig, - _SocketIOServerConnector: SocketIOServerConnector, + _WebSocketServerConnector: WebSocketServerConnector, _controlConnections: controlConnections, _ControlConnection: ControlConnection, _slaveConnections: slaveConnections, diff --git a/package.json b/package.json index 2b258eb0..82dde234 100644 --- a/package.json +++ b/package.json @@ -61,10 +61,9 @@ "set-blocking": "^2.0.0", "simple-git": "^1.96.0", "single-line-log": "^1.1.2", - "socket.io": "^2.2.0", - "socket.io-client": "^2.2.0", "sync-request": "^4.0.1", "typescript-collections": "^1.3.2", + "ws": "^7.2.3", "yargs": "^14.2.0" }, "devDependencies": { diff --git a/test/lib/link/connector.js b/test/lib/link/connector.js index 014baa8c..ea84094a 100644 --- a/test/lib/link/connector.js +++ b/test/lib/link/connector.js @@ -6,14 +6,14 @@ const link = require('lib/link'); describe("lib/link/connectors", function() { - describe("class SocketIOClientConnector", function() { - let testConnector = new link.SocketIOClientConnector('source', 'url', 'token'); + describe("class WebSocketClientConnector", function() { + let testConnector = new link.WebSocketClientConnector('source', 'url'); testConnector._socket = new mock.MockSocket(); describe(".disconnect()", function() { - it("should call close on the socket", function() { - testConnector._socket.closeCalled = false; + it("should call terminate on the socket", function() { + testConnector._socket.terminateCalled = false; testConnector.disconnect(); - assert(testConnector._socket.closeCalled, "Close was not called"); + assert(testConnector._socket.terminateCalled, "Close was not called"); }); }); @@ -30,21 +30,22 @@ describe("lib/link/connectors", function() { it("calls send on the socket", function() { testConnector._socket.sentMessages = []; let seq = testConnector.send('test', { test: true }); - assert.deepEqual(testConnector._socket.sentMessages, [{ seq, type: 'test', data: { test: true }}]); + assert.deepEqual( + testConnector._socket.sentMessages.map(JSON.parse), + [{ seq, type: 'test', data: { test: true }}] + ); }); }); describe(".close()", function() { - it("should send close and call disconnect", function() { + it("should send close", function() { testConnector._socket.sentMessages = []; let called = false; - testConnector.disconnect = () => { called = true; } testConnector.close("test reason"); assert.deepEqual( - testConnector._socket.sentMessages, + testConnector._socket.sentMessages.map(JSON.parse), [{ seq: testConnector._seq - 1, type: 'close', data: { reason: "test reason" } }] ) - assert(called, ".disconnect() was not called"); }); }); @@ -53,7 +54,7 @@ describe("lib/link/connectors", function() { testConnector._socket.sentMessages = []; testConnector._processHandshake({ data: "invalid message" }), assert.deepEqual( - testConnector._socket.sentMessages, + testConnector._socket.sentMessages.map(JSON.parse), [{seq: testConnector._seq - 1, type: 'close', data: { reason: "Invalid handshake" } }] @@ -85,7 +86,7 @@ describe("lib/link/connectors", function() { it("should throw on message received in invalid state", function() { testConnector._state = "new"; assert.throws( - () => testConnector._socket.events.get('message')(), + () => testConnector._socket.events.get("message")("{}"), new Error("Received message in unexpected state new") ); }); @@ -93,15 +94,15 @@ describe("lib/link/connectors", function() { testConnector._state = "handshake"; let called = false; testConnector._processHandshake = () => { called = true; }; - testConnector._socket.events.get('message')(); + testConnector._socket.events.get("message")("{}"); assert(called, "_processHandshake was not called"); }); it("should emit message on message in ready state", async function() { testConnector._socket.sentMessages = []; testConnector._state = "ready"; let result = events.once(testConnector, 'message'); - testConnector._socket.events.get('message')("message"); - assert.deepEqual(await result, ["message"]); + testConnector._socket.events.get("message")('{"message":true}'); + assert.deepEqual(await result, [{ message: true }]); }); }); }); diff --git a/test/master.js b/test/master.js index 3199f58c..2506b5c0 100644 --- a/test/master.js +++ b/test/master.js @@ -63,13 +63,13 @@ describe('Master testing', function() { } }); - describe("class SocketIOServerConnector", function() { - let testConnector = new master._SocketIOServerConnector(new mock.MockSocket()); + describe("class WebSocketServerConnector", function() { + let testConnector = new master._WebSocketServerConnector(new mock.MockSocket()); describe(".disconnect()", function() { it("should call disconnect on the socket", function() { - testConnector._socket.disconnectCalled = false; + testConnector._socket.terminateCalled = false; testConnector.disconnect(); - assert(testConnector._socket.disconnectCalled, "Disconnect was not called"); + assert(testConnector._socket.terminateCalled, "Terminate was not called"); }); }); }); diff --git a/test/mock.js b/test/mock.js index 9410bd4c..f484f309 100644 --- a/test/mock.js +++ b/test/mock.js @@ -7,7 +7,6 @@ class MockSocket { constructor() { this.sentMessages = []; this.events = new Map(); - this.handshake = { address: "socket.test" }; } send(message) { @@ -18,8 +17,8 @@ class MockSocket { this.events.set(event, fn); } - disconnect() { - this.disconnectCalled = true; + terminate() { + this.terminateCalled = true; } close() { From 38c60c221fc98eba0ba5e4da5ad101feb64c751b Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sat, 21 Mar 2020 20:40:51 +0100 Subject: [PATCH 3/7] Use sub-processes in integration testing Spawn master, slave, and clusterctl in separate processes in the clusterio integration tests and leave the test master and slave process available for the whole test duration to allow plugins to use them as well. This removes the need for reimplementing the startup logic in the test code and makes the existing startup code covered by tests. --- package.json | 3 +- plugins/subspace_storage/test/routes.js | 44 +++----- test/client.js | 4 + test/integration/clusterio.js | 137 ++++++++++-------------- test/integration/index.js | 106 ++++++++++++++++++ test/master.js | 58 ---------- test/missing.js | 1 - 7 files changed, 179 insertions(+), 174 deletions(-) create mode 100644 test/integration/index.js diff --git a/package.json b/package.json index 82dde234..9c871792 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,6 @@ "mocha.parallel": "^0.15.5", "nock": "^11.3.0", "nyc": "^14.1.1", - "sinon": "^7.4.1", - "supertest": "^4.0.2" + "sinon": "^7.4.1" } } diff --git a/plugins/subspace_storage/test/routes.js b/plugins/subspace_storage/test/routes.js index 7fceeef4..3a860681 100644 --- a/plugins/subspace_storage/test/routes.js +++ b/plugins/subspace_storage/test/routes.js @@ -1,44 +1,24 @@ const assert = require("assert").strict; -const express = require("express"); -const bodyParser = require("body-parser"); -const request = require("supertest"); -const jwt = require("jsonwebtoken"); - -const authenticate = require('lib/authenticate'); -const database = require("lib/database"); -const prometheus = require("lib/prometheus"); const routes = require("../routes"); +const { get } = require("../../../test/integration"); -const testSecret = "TestSecretDoNotUse"; -authenticate.setAuthSecret(testSecret); - -const token = jwt.sign({ id: "api" }, testSecret, { expiresIn: 600 }); - describe("subspace_storage endpoints", function() { - const app = express(); - app.use(bodyParser.json({ limit: '10mb', })); - app.use(bodyParser.urlencoded({ parameterLimit: 100000, limit: '10mb', extended: true })); - const items = new database.ItemDatabase(); - items.addItem("steel-plate", 20); - const gauge = new prometheus.Gauge("test_gauge", "test", { labels: ["route"] }); - routes.addApiRoutes(app, items, gauge); + describe("GET /api/inventory", function() { + it("should return the masters current inventory", async function() { + let res = await get("/api/inventory"); + let inventory = res.body; + assert.equal(typeof inventory, "object", "Inventory should be an object"); - let persistentMaster = request(app); - describe("#GET /api/inventory", function() { - it("returns the masters current inventory", function() { - return persistentMaster.get("/api/inventory").expect(200).then(function(res) { - let inventory = JSON.parse(res.text); - assert.equal(typeof inventory, "object", "Inventory should be an object"); - assert(inventory.length >= 1, "There should be at least 1 entry in the inventory"); + return; // XXX Disabled until a suitable test interface is added. + assert(inventory.length >= 1, "There should be at least 1 entry in the inventory"); - let contains20SteelPlate = false; - inventory.forEach(itemStack => { - if(itemStack.name == "steel-plate" && itemStack.count >= 20) contains20SteelPlate = true; - }); - assert(contains20SteelPlate, "Please ensure there are at least 20 steel plate in the inventory") + let contains20SteelPlate = false; + inventory.forEach(itemStack => { + if(itemStack.name == "steel-plate" && itemStack.count >= 20) contains20SteelPlate = true; }); + assert(contains20SteelPlate, "Please ensure there are at least 20 steel plate in the inventory") }); }); }); diff --git a/test/client.js b/test/client.js index 7b4a07ab..ebc5bb2c 100644 --- a/test/client.js +++ b/test/client.js @@ -7,6 +7,10 @@ const config = require("lib/config"); const client = require("../client"); describe("Client testing", function() { + before(function() { + config.InstanceConfig.finalize(); + }); + describe("class Instance", function() { let instance; before(async function() { diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index ffd5a869..57a61d94 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -1,101 +1,76 @@ const assert = require("assert").strict; const fs = require("fs-extra"); const path = require("path"); +const validateHTML = require('html5-validator'); +const parallel = require('mocha.parallel'); -const link = require("lib/link"); -const plugin = require("lib/plugin"); -const config = require("lib/config"); -const master = require("../../master"); -const client = require("../../client"); -const clusterctl = require("../../clusterctl"); -const { version } = require("../../package"); +const { get, exec, controlConfigPath, instancesDir } = require("./index"); + + +// Mark that this test takes a lot of time, or depeneds on a test +// that takes a lot of time. +function slowTest(test) { + if (process.env.FAST_TEST) { + test.skip(); + } + + test.timeout(20000); +} describe("Integration of Clusterio", function() { - let testControl; - let testControlConnection; + describe("master http api", function() { + describe("GET /api/getFactorioLocale", function() { + it("should get the basegame factorio locale", async function() { + let res = await get("/api/getFactorioLocale"); + let object = res.body; - let testSlave; - let testSlaveConnection; - - let instancesDir = path.join("test", "temp", "instances"); - - // Mark that this test takes a lot of time, or depeneds on a test - // that takes a lot of time. - function slowTest(test) { - if (process.env.FAST_TEST) { - test.skip(); - } - - test.timeout(20000); - } - - async function createInstanceConfig(id, name, assignedSlave) { - let instanceConfig = new config.InstanceConfig() - await instanceConfig.init(); - instanceConfig.set("instance.id", id); - instanceConfig.set("instance.name", name); - instanceConfig.set("instance.assigned_slave", assignedSlave); - return instanceConfig; - } - - before(async function() { - master._db.slaves = new Map(); - master._db.instances = new Map([ - [11, await createInstanceConfig(11, "foo", 4)], - [21, await createInstanceConfig(21, "bar", 5)], - ]); - - let [controlClient, controlServer] = link.VirtualConnector.makePair(); - testControl = new clusterctl._Control(controlClient); - testControlConnection = new master._ControlConnection({ agent: "test", version }, controlServer); - master._controlConnections.push(testControlConnection); - - await fs.remove(instancesDir); - await fs.ensureDir(instancesDir) - - let slaveConfig = new config.SlaveConfig(); - await slaveConfig.init(); - slaveConfig.set("slave.id", 4); - slaveConfig.set("slave.name", "slave"); - slaveConfig.set("slave.instances_directory", instancesDir); - slaveConfig.set("slave.factorio_directory", "factorio"); - slaveConfig.set("slave.master_url", "http://invalid"); - slaveConfig.set("slave.master_token", "invalid"); - slaveConfig.set("slave.public_address", "invalid"); - - let [slaveClient, slaveServer] = link.VirtualConnector.makePair(); - testSlave = new client._Slave(slaveClient, slaveConfig, await plugin.loadPluginInfos("plugins")); - testSlaveConnection = new master._SlaveConnection({ agent: "test", version, name: "slave", id: 4}, slaveServer); - master._slaveConnections.set(4, testSlaveConnection); + // test that it is looks like a factorio locale + assert.equal(typeof object, "object"); + assert.equal(object["entity-name"]["fish"], "Fish"); + assert.equal(object["entity-name"]["small-lamp"], "Lamp"); + }); + }); }); + parallel("master web interface", function() { + this.timeout(6000); + + let paths = ["/", "/nodes", "/settings", "/nodeDetails"]; + for (let path of paths) { + it(`sends some HTML when accessing ${path}`, async function() { + let res = await get(path); + let validation = await validateHTML(res.body); + let filtered = validation.messages.filter(msg => msg.type !== "info"); + assert( + filtered.length === 0, + "there are HTML errors on the page, please fix: "+JSON.stringify(validation.messages, null, 4) + ); + }); + } + }); + describe("clusterctl", function() { describe("list-slaves", function() { - it("does not throw", async function() { - await clusterctl._commands.get("list-slaves").run({}, testControl); + it("runs", async function() { + await exec(`node clusterctl --config ${controlConfigPath} list-slaves`); }); }); describe("list-instances", function() { - it("does not throw", async function() { - await clusterctl._commands.get("list-instances").run({}, testControl); + it("runs", async function() { + await exec(`node clusterctl --config ${controlConfigPath} list-instances`); }); }); describe("create-instances", function() { - it("creates the instance", async function() { - await clusterctl._commands.get("create-instance").run({id: 44, name: "test"}, testControl); - assert(master._db.instances.has(44), "Instance was not created"); - - // XXX breaks tests. - master._db.instances.get(44).set("subspace_storage.enabled", false); - master._db.instances.get(44).set("research_sync.enabled", false); + it("runs", async function() { + await exec(`node clusterctl --config ${controlConfigPath} create-instance --id 44 --name test`); }); }); describe("assign-instance", function() { - it("creates the instance", async function() { - await clusterctl._commands.get("assign-instance").run({instance: "test", slave: "slave"}, testControl); + it("creates the instance files", async function() { + await exec(`node clusterctl --config ${controlConfigPath} assign-instance --instance test --slave 4`); assert(await fs.exists(path.join(instancesDir, "test")), "Instance was not created"); }); }); @@ -103,22 +78,22 @@ describe("Integration of Clusterio", function() { describe("create-save", function() { it("creates a save", async function() { slowTest(this); - await clusterctl._commands.get("create-save").run({instance: "test"}, testControl); + await exec(`node clusterctl --config ${controlConfigPath} create-save --instance test`); }); }); describe("start-instance", function() { it("starts the instance", async function() { slowTest(this); - await clusterctl._commands.get("start-instance").run({instance: "test"}, testControl); - // TODO check that the instance actually stopped + await exec(`node clusterctl --config ${controlConfigPath} start-instance --instance test`); + // TODO check that the instance actually started }); }); describe("send-rcon", function() { it("sends the command", async function() { slowTest(this); - await clusterctl._commands.get("send-rcon").run({instance: "test", command: "test"}, testControl); + await exec(`node clusterctl --config ${controlConfigPath} send-rcon --instance test --command test`); // TODO check that the command was received }); }); @@ -126,7 +101,7 @@ describe("Integration of Clusterio", function() { describe("stop-instance", function() { it("stops the instance", async function() { slowTest(this); - await clusterctl._commands.get("stop-instance").run({instance: "test"}, testControl); + await exec(`node clusterctl --config ${controlConfigPath} stop-instance --instance test`); // TODO check that the instance actually stopped }); }); @@ -134,7 +109,7 @@ describe("Integration of Clusterio", function() { describe("delete-instance", function() { it("deletes the instance", async function() { slowTest(this); - await clusterctl._commands.get("delete-instance").run({instance: "test"}, testControl); + await exec(`node clusterctl --config ${controlConfigPath} delete-instance --instance test`); assert(!await fs.exists(path.join(instancesDir, "test")), "Instance was not deleted"); }); }); diff --git a/test/integration/index.js b/test/integration/index.js new file mode 100644 index 00000000..d79e124b --- /dev/null +++ b/test/integration/index.js @@ -0,0 +1,106 @@ +const path = require("path"); +const fs = require("fs-extra"); +const child_process = require("child_process"); +const jwt = require("jsonwebtoken"); +const needle = require("needle"); +const util = require("util"); +const events = require("events"); + +const server = require("lib/factorio/server"); + + +async function get(path) { + let res = await needle("get", `https://localhost:4443${path}`, { rejectUnauthorized: false }); + if (res.statusCode != 200) { + throw new Error(`Got response code ${res.statusCode}, content: ${res.body}`); + } + return res; +} + +let masterProcess; +let slaveProcess; + +let instancesDir = path.join("test", "temp", "instances"); +let databaseDir = path.join("test", "temp", "databse"); +let masterConfigPath = path.join("test", "temp", "master-integration.json"); +let slaveConfigPath = path.join("test", "temp", "slave-integration.json"); +let controlConfigPath = path.join("test", "temp", "control-integration.json"); + +async function exec(...args) { + console.log(args[0]); + return await util.promisify(child_process.exec)(...args); +} + +function spawn(name, cmd, waitFor) { + return new Promise((resolve, reject) => { + let process = child_process.spawn(cmd, { shell: true }); + let stdout = new server._LineSplitter((line) => { + line = line.toString("utf8"); + if (line.startsWith(waitFor)) { + resolve(process); + } + console.log(name, line); + }) + let stderr = new server._LineSplitter((line) => { console.log(name, line.toString("utf8")); }) + process.stdout.on("data", chunk => { stdout.data(chunk); }); + process.stdout.on("close", () => { stdout.end(); }); + process.stderr.on("data", chunk => { stderr.data(chunk); }); + process.stderr.on("close", () => { stderr.end(); }); + }); +} + +before(async function() { + this.timeout(20000); + + await fs.remove(databaseDir); + await fs.remove(instancesDir); + + await fs.remove(masterConfigPath); + await fs.remove(slaveConfigPath); + await fs.remove(controlConfigPath); + + let token = jwt.sign({ id: "api" }, "TestSecretDoNotUse"); + await exec(`node master --config ${masterConfigPath} config set master.database_directory ${databaseDir}`); + await exec(`node master --config ${masterConfigPath} config set master.auth_secret TestSecretDoNotUse`); + await exec(`node master --config ${masterConfigPath} config set master.http_port 8880`); + await exec(`node master --config ${masterConfigPath} config set master.https_port 4443`); + + await exec(`node client --config ${slaveConfigPath} config set slave.id 4`); + await exec(`node client --config ${slaveConfigPath} config set slave.name slave`); + await exec(`node client --config ${slaveConfigPath} config set slave.instances_directory ${instancesDir}`); + await exec(`node client --config ${slaveConfigPath} config set slave.master_url "https://localhost:4443/"`); + await exec(`node client --config ${slaveConfigPath} config set slave.master_token "${token}"`); + + await exec(`node clusterctl --config ${controlConfigPath} control-config set control.master_url "https://localhost:4443/"`); + await exec(`node clusterctl --config ${controlConfigPath} control-config set control.master_token "${token}"`); + + masterProcess = await spawn("master:", `node master --config ${masterConfigPath} run`, "All plugins loaded"); + slaveProcess = await spawn("slave:", `node client --config ${slaveConfigPath} start`, "SOCKET | received ready from master"); +}); + +after(async function() { + this.timeout(200000); + if (slaveProcess) { + console.log("Shutting down slave"); + slaveProcess.kill(); + await events.once(slaveProcess, "exit"); + } + if (masterProcess) { + console.log("Shutting down master"); + masterProcess.kill(); + await events.once(masterProcess, "exit"); + } +}); + + + +module.exports = { + get, + exec, + + instancesDir, + databaseDir, + masterConfigPath, + slaveConfigPath, + controlConfigPath, +}; diff --git a/test/master.js b/test/master.js index 2506b5c0..f37b143b 100644 --- a/test/master.js +++ b/test/master.js @@ -1,68 +1,10 @@ const assert = require('assert').strict; -const request = require('supertest'); -const validateHTML = require('html5-validator'); -const parallel = require('mocha.parallel'); -const config = require("lib/config"); -const plugin = require("lib/plugin"); const mock = require('./mock'); const master = require('../master'); -master._db.slaves = new Map(); - -before(async function() { - let pluginInfos = await plugin.loadPluginInfos("plugins"); - config.registerPluginConfigGroups(pluginInfos); - config.finalizeConfigs(); - let masterConfig = new config.MasterConfig(); - await masterConfig.load({ - groups: [{ - name: "master", - fields: { - auth_secret: "TestSecretDoNotUse", - http_port: 8880, - https_port: 4443, - }, - }] - }); - - master._setConfig(masterConfig); -}) - -async function get(path) { - return await request(master.app).get(path).expect(200); -} - -async function getValidate(path) { - let res = await get(path); - let validation = await validateHTML(res.text); - let filtered = validation.messages.filter(msg => msg.type !== "info"); - assert(filtered.length === 0, "there are HTML errors on the page, please fix: "+JSON.stringify(validation.messages, null, 4)); -} - describe('Master testing', function() { - describe('#GET /api/getFactorioLocale', function() { - it('should get the basegame factorio locale', async function() { - let res = await get('/api/getFactorioLocale'); - let object = res.body; - - // test that it is looks like a factorio locale - assert.equal(typeof object, "object"); - assert.equal(object["entity-name"]["fish"], "Fish"); - assert.equal(object["entity-name"]["small-lamp"], "Lamp"); - }); - }); - // describe("#GET /api/") - parallel("#GET static website data", function() { - this.timeout(6000); - - let paths = ["/", "/nodes", "/settings", "/nodeDetails"]; - for (let path of paths) { - it(`sends some HTML when accessing ${path}`, () => getValidate(path)); - } - }); - describe("class WebSocketServerConnector", function() { let testConnector = new master._WebSocketServerConnector(new mock.MockSocket()); describe(".disconnect()", function() { diff --git a/test/missing.js b/test/missing.js index 836dd6af..f87ddc85 100644 --- a/test/missing.js +++ b/test/missing.js @@ -4,4 +4,3 @@ require('lib/manager/modManager'); require('lib/npmPostinstall.js'); require('lib/typedef'); -require('../clusterctl.js'); From 27ef00705b09e70d094ff2f012080d2ba4867c40 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sun, 22 Mar 2020 02:01:54 +0100 Subject: [PATCH 4/7] Set content type for /api/inventory Set the correct content type to JSON to for the /api/inventory endpoints. --- plugins/subspace_storage/routes.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/subspace_storage/routes.js b/plugins/subspace_storage/routes.js index 9bcfdc2a..de0687c6 100644 --- a/plugins/subspace_storage/routes.js +++ b/plugins/subspace_storage/routes.js @@ -29,6 +29,7 @@ function addApiRoutes(app, items, endpointHitCounter) { for (let [name, count] of items._items) { inventory.push({ name, count }); } + res.type("json"); res.send(JSON.stringify(inventory)); }); @@ -45,6 +46,7 @@ function addApiRoutes(app, items, endpointHitCounter) { res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); // Check it and send it + res.type("json"); res.send(JSON.stringify(items.serialise())); }); } From 7aa340675b9afb878e3522ad9cb650dc9bad6df2 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sun, 22 Mar 2020 02:39:20 +0100 Subject: [PATCH 5/7] Gracefully shut down integration master and slave Use SIGINT instead of the default SIGTERM when signalling to shut down the integration master and slave subprocesses. This should let nyc properly write the coverage report before it exits. --- test/integration/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/index.js b/test/integration/index.js index d79e124b..848a3b81 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -82,12 +82,12 @@ after(async function() { this.timeout(200000); if (slaveProcess) { console.log("Shutting down slave"); - slaveProcess.kill(); + slaveProcess.kill("SIGINT"); await events.once(slaveProcess, "exit"); } if (masterProcess) { console.log("Shutting down master"); - masterProcess.kill(); + masterProcess.kill("SIGINT"); await events.once(masterProcess, "exit"); } }); From 9f74d7b2ecbbd85473f8b5559c14b4556b221ec2 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sun, 22 Mar 2020 03:28:05 +0100 Subject: [PATCH 6/7] Fix shutdown logic for slave Shutdown running instances and close the connection to the master server on slave stop. Remove call to process.exit on shutdown as it's no longer needed. --- client.js | 19 ++++++++++++------- test/integration/index.js | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/client.js b/client.js index 3d21c2dd..a49cbddb 100644 --- a/client.js +++ b/client.js @@ -640,10 +640,10 @@ class Slave extends link.Link { } async stop() { - for (let instanceConnection of this.instanceConnections.values()) { - // XXX this neeeds more thought to it - // await instance.stop(); + for (let instanceId of this.instanceConnections.keys()) { + await this.stopInstance(instanceId); } + this.connector.close("shutdown"); } } @@ -841,10 +841,15 @@ async function startClient() { secondSigint = true; console.log("Caught interrupt signal, shutting down"); - slave.stop().then(() => { - // There's currently no shutdown mechanism for instance plugins so - // they keep the event loop alive. - process.exit(); + slave.stop().catch(err => { + console.error(` ++---------------------------------------------------------------+ +| Unexpected error occured while stopping slave, please report | +| it to https://github.com/clusterio/factorioClusterio/issues | ++---------------------------------------------------------------+` + ); + console.error(err); + process.exit(1); }); }); diff --git a/test/integration/index.js b/test/integration/index.js index 848a3b81..668a56b4 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -79,7 +79,7 @@ before(async function() { }); after(async function() { - this.timeout(200000); + this.timeout(20000); if (slaveProcess) { console.log("Shutting down slave"); slaveProcess.kill("SIGINT"); From 1aef3c96096e0ae6edcdf7b557f8b5a9bc070503 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 23 Mar 2020 02:38:40 +0100 Subject: [PATCH 7/7] Do not use shell for spawned integration nodes When using the shell for spawning the master and slave integration processes the shell becomes the process that is returned by the spawn command. This in turn means that the SIGINT that was supposed to be sent to the master and slave processes got ignored by the shell instead. Fix by spawning the node process directly instead of through a shell. --- test/integration/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/integration/index.js b/test/integration/index.js index 668a56b4..793e2b87 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -33,7 +33,9 @@ async function exec(...args) { function spawn(name, cmd, waitFor) { return new Promise((resolve, reject) => { - let process = child_process.spawn(cmd, { shell: true }); + console.log(cmd); + let parts = cmd.split(" "); + let process = child_process.spawn(parts[0], parts.slice(1)); let stdout = new server._LineSplitter((line) => { line = line.toString("utf8"); if (line.startsWith(waitFor)) {