diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 5b90bce6..2f46abd8 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -14,6 +14,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: node-version: - 14.x diff --git a/config.js.example b/config.js.example index 2731db86..1c973199 100644 --- a/config.js.example +++ b/config.js.example @@ -13,6 +13,14 @@ module.exports = { '@mod1', '@mod2', ], + virtualServer: { + enabled: false, // If virtual servers should be used + fileExtensions: [ // Extra files in root of server folder that should be copied to virtual servers + '.json' + ], + folders: [ // Extra folders in root of server folder that should be linked to virtual servers + ] + }, admins: [], // add steam IDs here to enable #login without password auth: { // If both username and password is set, HTTP Basic Auth will be used. You may use an array to specify more than one user. username: '', // Username for HTTP Basic Auth diff --git a/lib/server.js b/lib/server.js index 65f39166..72820a01 100644 --- a/lib/server.js +++ b/lib/server.js @@ -6,6 +6,7 @@ var slugify = require('slugify') var ArmaServer = require('arma-server') +var virtualServer = require('./virtualServer') var config = require('../config.js') var queryInterval = 5000 @@ -134,6 +135,27 @@ Server.prototype.start = function () { return this } + var self = this + + if (config.virtualServer && config.virtualServer.enabled) { + virtualServer.create(config, self.mods) + .then((serverFolder) => { + self.virtualServerFolder = serverFolder + self.realStart(serverFolder) + }) + .catch((err) => { + console.error('Error creating virtual server folder:', err) + }) + } else { + self.realStart(config.path) + } +} + +Server.prototype.realStart = function (path) { + if (this.instance) { + return this + } + var parameters = this.getParameters() var server = new ArmaServer.Server({ additionalConfigurationOptions: this.getAdditionalConfigurationOptions(), @@ -154,7 +176,7 @@ Server.prototype.start = function () { parameters: parameters, password: this.password, passwordAdmin: this.admin_password, - path: this.config.path, + path: path, persistent: this.persistent ? 1 : 0, platform: this.config.type, players: this.max_players, @@ -196,8 +218,13 @@ Server.prototype.start = function () { self.instance = null self.stopHeadlessClients() - - self.emit('state') + .then(() => { + if (self.virtualServerFolder) { + virtualServer.remove(self.virtualServerFolder) + self.virtualServerFolder = null + } + self.emit('state') + }) }) instance.on('error', function (err) { @@ -210,14 +237,14 @@ Server.prototype.start = function () { self.queryStatus() }, queryInterval) - this.startHeadlessClients() + this.startHeadlessClients(path) this.emit('state') return this } -Server.prototype.startHeadlessClients = function () { +Server.prototype.startHeadlessClients = function (path) { var parameters = this.getParameters() var self = this var headlessClientInstances = _.times(this.number_of_headless_clients, function (i) { @@ -228,7 +255,7 @@ Server.prototype.startHeadlessClients = function () { mods: self.mods, parameters: parameters, password: self.password, - path: self.config.path, + path: path, platform: self.config.type, port: self.port }) @@ -293,9 +320,26 @@ Server.prototype.stop = function (cb) { } Server.prototype.stopHeadlessClients = function () { - this.headlessClientInstances.map(function (headlessClientInstance) { - headlessClientInstance.kill() - }) + return Promise.all(this.headlessClientInstances.map(function (headlessClientInstance) { + var handled = false + return new Promise(function (resolve, reject) { + headlessClientInstance.on('close', function () { + if (!handled) { + handled = true + resolve() + } + }) + + setTimeout(function () { + if (!handled) { + handled = true + resolve() + } + }, 5000) + + headlessClientInstance.kill() + }) + })) } Server.prototype.toJSON = function () { diff --git a/lib/virtualServer.js b/lib/virtualServer.js new file mode 100644 index 00000000..9ea7ca77 --- /dev/null +++ b/lib/virtualServer.js @@ -0,0 +1,140 @@ +var fs = require('fs') +var fsExtra = require('fs.extra') +var _ = require('lodash') +var glob = require('glob') +var os = require('os') +var path = require('path') + +const requiredFileExtensions = [ + '.dll', + '.exe', + '.so', + '.txt' // Steam app id +] + +const serverFolders = [ + 'addons', + 'aow', + 'argo', + 'battleye', + 'contact', + 'csla', + 'curator', + 'dll', + 'dta', + 'enoch', + 'expansion', + 'gm', + 'heli', + 'jets', + 'kart', + 'linux64', + 'mark', + 'mpmissions', + 'orange', + 'tacops', + 'tank' +] + +function copyKeys (config, serverFolder, mods) { + // Copy needed keys, file symlinks on Windows are sketchy + const keysFolder = path.join(serverFolder, 'keys') + return fs.promises.mkdir(keysFolder, { recursive: true }) + .then(() => { + const defaultKeysPath = path.join(config.path, 'keys') + const defaultKeysPromise = fs.promises.readdir(defaultKeysPath) + .then((files) => files.filter((file) => path.extname(file) === '.bikey')) + .then((files) => files.map((file) => path.join(defaultKeysPath, file))) + + const modKeysPromise = Promise.all(mods.map(mod => { + return new Promise((resolve, reject) => { + const modPath = path.join(config.path, mod) + glob(`${modPath}/**/*.bikey`, function (err, files) { + if (err) { + return reject(err) + } + + return resolve(files) + }) + }) + })).then((modsFiles) => modsFiles.flat()) + + return Promise.all([defaultKeysPromise, modKeysPromise].map((promise) => { + return promise.then((keyFiles) => { + return Promise.all(keyFiles.map((keyFile) => { + return fs.promises.copyFile(keyFile, path.join(keysFolder, path.basename(keyFile))) + })) + }) + })).catch((err) => { + console.error('Error copying keys:', err) + }) + }) +} + +function copyFiles (config, serverFolder) { + const configFileExtensions = (config.virtualServer && config.virtualServer.fileExtensions) || [] + const allowedFileExtensions = _.uniq(requiredFileExtensions.concat(configFileExtensions)) + + return fs.promises.readdir(config.path) + .then((files) => { + // Copy needed files, file symlinks on Windows are sketchy + const serverFiles = files.filter((file) => allowedFileExtensions.indexOf(path.extname(file)) >= 0 || path.basename(file) === 'arma3server' || path.basename(file) === 'arma3server_x64') + return Promise.all(serverFiles.map((file) => { + return fs.promises.copyFile(path.join(config.path, file), path.join(serverFolder, file)) + })) + }) +} + +function createModFolders (config, serverFolder, mods) { + // Create virtual folders from default Arma and mods + const configFolders = (config.virtualServer && config.virtualServer.folders) || [] + const serverMods = config.serverMods || [] + const symlinkFolders = _.uniq(serverFolders + .concat(mods) + .concat(configFolders) + .concat(serverMods) + .map(function (folder) { + return folder.split(path.sep)[0] + }) + ) + + return Promise.all(symlinkFolders.map((symlink) => { + return fs.promises.access(path.join(config.path, symlink)) + .then(() => { + return fs.promises.symlink(path.join(config.path, symlink), path.join(serverFolder, symlink), 'junction') + .catch((err) => { + console.error('Could create symlink for', symlink, 'due to', err) + }) + }) + .catch(() => {}) + })) +} + +module.exports.create = function (config, mods) { + return fs.promises.mkdtemp(path.join(os.tmpdir(), 'arma-server-')) + .then((serverFolder) => { + console.log('Created virtual server folder:', serverFolder) + + return Promise.all([ + copyKeys(config, serverFolder, mods), + copyFiles(config, serverFolder), + createModFolders(config, serverFolder, mods) + ]).then(() => { + return serverFolder + }) + }) +} + +module.exports.remove = function (folder, cb) { + if (folder) { + fsExtra.rmrf(folder, function (err) { + if (err) { + console.log('Error removing virtual server folder', err) + } + + if (cb) { + cb(err) + } + }) + } +} diff --git a/test/lib/virtualServer.js b/test/lib/virtualServer.js new file mode 100644 index 00000000..43514e05 --- /dev/null +++ b/test/lib/virtualServer.js @@ -0,0 +1,273 @@ +var async = require('async') +var fs = require('fs') +var fsExtra = require('fs.extra') +var path = require('path') +var os = require('os') +var should = require('should') + +var virtualServer = require('../../lib/virtualServer.js') + +var basicServerFiles = [ + '@mod/addons/addon.pbo', + '@mod/keys/mod.bikey', + '@mod/optionals/@nested_mod/addons/nested_addon.pbo', + '@mod/optionals/@nested_mod/keys/nested_mod.bikey', + 'addons/data_f.pbo', + 'arma3server', + 'arma3server.exe', + 'arma3server_x64', + 'arma3server_x64.exe', + 'dta/product.bin', + 'keys/a3.bikey', + 'libsteam.so', + 'mpmissions/test.vr.pbo', + 'steam.dll', + 'steam_appid.txt' +] + +function createEmptyFile (file, cb) { + fs.open(file, 'w', function (err, fd) { + if (err) { + return cb(err) + } + + fs.close(fd, cb) + }) +} + +function createTempServerFolder (files, cb) { + fs.mkdtemp(path.join(os.tmpdir(), 'arma-server-test-'), function (err, serverFolder) { + if (err) { + return cb(err) + } + + async.forEach(files, function (file, cb) { + var fileFolder = path.dirname(file) + if (fileFolder) { + fsExtra.mkdirp(path.join(serverFolder, fileFolder), function (err) { + if (err) { + return cb(err) + } + + createEmptyFile(path.join(serverFolder, file), cb) + }) + } else { + createEmptyFile(path.join(serverFolder, file), cb) + } + }, function (err, files) { + cb(err, serverFolder) + }) + }) +} + +describe('VirtualServer', function () { + var serverFolder + var tempServerFolder + + function createVirtualServer (mods, done) { + createTempServerFolder(basicServerFiles, function (err, folder) { + if (err) { + return done(err) + } + + serverFolder = folder + + return virtualServer.create({ + path: serverFolder + }, mods) + .then(function (serverFolder) { + tempServerFolder = serverFolder + done() + }) + .catch(done) + }) + } + + function removeVirtualServer (done) { + if (tempServerFolder) { + virtualServer.remove(tempServerFolder, function (err) { + if (err) { + return done(err) + } + + fsExtra.rmrf(serverFolder, done) + }) + } else if (serverFolder) { + fsExtra.rmrf(serverFolder, done) + } else { + done() + } + } + + function checkForFile (file, cb) { + fs.access(path.join(tempServerFolder, file), function (err) { + should.not.exist(err) + cb(err) + }) + } + + function checkForLink (file, cb) { + fs.lstat(path.join(tempServerFolder, file), function (err, stats) { + if (err) { + return cb(err) + } + + should(stats.isSymbolicLink()).be.exactly(true) + cb() + }) + } + + describe('basic server', function () { + before(function (done) { + var mods = [] + createVirtualServer(mods, done) + }) + + after(function (done) { + removeVirtualServer(done) + }) + + it('should copy Linux binary', function (done) { + checkForFile('arma3server.exe', done) + }) + + it('should copy Linux x64 binary', function (done) { + checkForFile('arma3server_x64.exe', done) + }) + + it('should copy Linux libsteam.so', function (done) { + checkForFile('libsteam.so', done) + }) + + it('should copy Windows binary', function (done) { + checkForFile('arma3server.exe', done) + }) + + it('should copy Windows x64 binary', function (done) { + checkForFile('arma3server_x64.exe', done) + }) + + it('should copy Windows steam.dll', function (done) { + checkForFile('steam.dll', done) + }) + + it('should link addons folder', function (done) { + checkForLink('addons', done) + }) + + it('should have addons folder with data_f.pbo', function (done) { + checkForFile('addons/data_f.pbo', done) + }) + + it('should link dta folder', function (done) { + checkForLink('dta', done) + }) + + it('should have dta folder with product.bin', function (done) { + checkForFile('dta/product.bin', done) + }) + + it('should have keys folder with a3.bikey', function (done) { + checkForFile('keys/a3.bikey', done) + }) + + it('should link mpmissions folder', function (done) { + checkForLink('mpmissions', done) + }) + + it('should have mpmissions folder with test.vr.pbo', function (done) { + checkForFile('mpmissions/test.vr.pbo', done) + }) + }) + + describe('mod', function () { + before(function (done) { + var mods = [ + '@mod' + ] + createVirtualServer(mods, done) + }) + + after(function (done) { + removeVirtualServer(done) + }) + + it('should link @mod folder', function (done) { + checkForLink('@mod', done) + }) + + it('should have @mod folder with addons folder containing addon.pbo', function (done) { + checkForFile('@mod/addons/addon.pbo', done) + }) + + it('should have keys folder with mod.bikey', function (done) { + checkForFile('keys/mod.bikey', done) + }) + }) + + describe('nested mod', function () { + before(function (done) { + var mods = [ + path.join('@mod', 'optionals', '@nested_mod') + ] + createVirtualServer(mods, done) + }) + + after(function (done) { + removeVirtualServer(done) + }) + + it('should link @mod', function (done) { + checkForLink('@mod', done) + }) + + it('should have @mod folder with optionals @nested_mod folder with addons folder containing nested_addon.pbo', function (done) { + checkForFile('@mod/optionals/@nested_mod/addons/nested_addon.pbo', done) + }) + + it('should not have mod.bikey in keys', function (done) { + fs.access(path.join(tempServerFolder, 'keys', 'mod.bikey'), function (err) { + should.exist(err) + done() + }) + }) + + it('should have keys folder with nested_mod.bikey', function (done) { + checkForFile('keys/nested_mod.bikey', done) + }) + }) + + describe('multiple mods', function () { + before(function (done) { + var mods = [ + '@mod', + path.join('@mod', 'optionals', '@nested_mod') + ] + createVirtualServer(mods, done) + }) + + after(function (done) { + removeVirtualServer(done) + }) + + it('should link @mod', function (done) { + checkForLink('@mod', done) + }) + + it('should have @mod folder with addons folder containing addon.pbo', function (done) { + checkForFile('@mod/addons/addon.pbo', done) + }) + + it('should have @mod folder with optionals @nested_mod folder with addons folder containing nested_addon.pbo', function (done) { + checkForFile('@mod/optionals/@nested_mod/addons/nested_addon.pbo', done) + }) + + it('should have keys folder with nested_mod.bikey', function (done) { + checkForFile('keys/mod.bikey', done) + }) + + it('should have keys folder with nested_mod.bikey', function (done) { + checkForFile('keys/nested_mod.bikey', done) + }) + }) +})