├── .dockerignore ├── .gitignore ├── lib ├── test.js ├── listFiles.js ├── transload.js └── runCommand.js ├── .travis.yml ├── TODO.md ├── Dockerfile ├── static └── main.css ├── README.md ├── package.json ├── server.js └── admin.pug /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | static/pure.css 3 | -------------------------------------------------------------------------------- /lib/test.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('..'); 4 | console.log("Compiled without errors."); 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | language: node_js 4 | 5 | node_js: 6 | - "4" 7 | 8 | env: 9 | matrix: 10 | - FACTORIO_VERSION=0.14.20 11 | 12 | before_install: 13 | - export PANEL_VERSION=$(node -e 'console.log(require("./package").version)') 14 | 15 | script: 16 | - npm test 17 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | #TODO 2 | ##Must Do 3 | - [ ] Add ablity to configer server from console 4 | - [ ] Add ablity to configer map from console 5 | - [ ] prettify 6 | 7 | ##Want To Do 8 | - [ ] Add better server admistraion tools 9 | - [ ] player list 10 | - [ ] ban and give admin from console player list 11 | - [ ] set regular commands 12 | - [ ] add ablity to adminstrate multiple servers 13 | - [ ] seperate login details per server 14 | -------------------------------------------------------------------------------- /lib/listFiles.js: -------------------------------------------------------------------------------- 1 | var es = require('event-stream'); 2 | var vfs = require('vinyl-fs'); 3 | 4 | function listFiles(path, mountPath, callback) { 5 | var files = []; 6 | vfs.src(path, {read: false}) 7 | .pipe(es.through(function(file){ 8 | file.dirname = mountPath; 9 | files.push(file); 10 | }, function(end){ 11 | callback(null, files); 12 | })); 13 | } 14 | 15 | module.exports = listFiles; 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:4.6.1-slim 2 | MAINTAINER Jesse Clark 3 | 4 | # Install Factorio 5 | ENV FACTORIO_VERSION 0.14.20 6 | RUN cd /usr/local && \ 7 | curl -sL "http://www.factorio.com/get-download/${FACTORIO_VERSION}/headless/linux64" \ 8 | | tar xzv && \ 9 | printf '#!/bin/sh\n/usr/local/factorio/bin/x64/factorio $@\n' > /usr/local/bin/factorio && \ 10 | chmod +x /usr/local/bin/factorio 11 | 12 | # Install app 13 | ADD package.json /app/package.json 14 | WORKDIR /app 15 | RUN npm install --silent --production 16 | ADD . /app 17 | 18 | # Set environment 19 | ENV PORT 8000 20 | EXPOSE 8000 21 | 22 | ENV FACTORIO_PORT 34197 23 | EXPOSE 34197/udp 24 | 25 | VOLUME /usr/local/factorio/saves 26 | VOLUME /usr/local/factorio/mods 27 | 28 | CMD node /app 29 | -------------------------------------------------------------------------------- /static/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #222; 3 | color: #bbb; 4 | } 5 | b { 6 | color: white; 7 | } 8 | a { 9 | color: #cc7627; 10 | } 11 | a:hover { 12 | color: #e67e23; 13 | } 14 | input[type="text"],input[type="password"],input[type="number"],button,select { 15 | color: #666; 16 | background: #ddd; 17 | } 18 | h1,h2,h3,h4,h5,h6 { 19 | color: #ddd; 20 | text-align: center; 21 | } 22 | .pure-form legend { 23 | color: #ddd; 24 | } 25 | .container { 26 | max-width: 1024px; 27 | margin: auto; 28 | } 29 | .pane { 30 | background: #444; 31 | padding: 0 0.5em; 32 | margin: 0.5em; 33 | border-top: 2px solid #666; 34 | border-left: 2px solid #555; 35 | border-right: 2px solid #272727; 36 | border-bottom: 2px solid #171717; 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # factorio-control-panel 2 | Web admin interface for [Factorio](http://factorio.com/) headless game server 3 | 4 | [![Build Status](https://travis-ci.org/Bertieio/factorio-control-panel.svg?branch=master)](https://travis-ci.org/bertieio/factorio-control-panel) 5 | 6 | ### Installation (Linux server) 7 | 8 | - Install [Factorio headless server](http://www.factorio.com/download-headless/stable) and [Node.js](https://nodejs.org/en/download/). 9 | 10 | - This is currently a dev build please feel free to fork and push updates 11 | 12 | - Set environment variables and start the web server 13 | 14 | export FACTORIO_DIR='/usr/local/factorio' 15 | export ADMIN_PASSWORD='******' 16 | export PORT=8000 17 | factorio-control-panel 18 | ### Usage 19 | 20 | - Navigate to the control panel at [http://localhost:8000](http://localhost:8000). 21 | 22 | - Upload save files and mods for the server to use. Other players can download them from here. 23 | 24 | - Click "Start Server." This and other admin actions require the `ADMIN_PASSWORD` set above. 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "factorio-control-panel", 3 | "version": "1.5.4", 4 | "description": "Web server to manage headless Factorio game server", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/bertieio/factorio-control-panel.git" 8 | }, 9 | "main": "server.js", 10 | "bin": { 11 | "factorio-control-panel": "server.js" 12 | }, 13 | "scripts": { 14 | "test": "lib/test.js", 15 | "postinstall": "ln -sf ../node_modules/purecss/build/pure.css static/" 16 | }, 17 | "author": "Bertie Scott ", 18 | "license": "MIT", 19 | "dependencies": { 20 | "ansi_up": "^1.3.0", 21 | "basic-auth": "^1.0.4", 22 | "bluebird": "^3.4.0", 23 | "body-parser": "^1.15.1", 24 | "content-disposition": "^0.5.1", 25 | "debug": "^2.2.0", 26 | "event-stream": "^3.3.2", 27 | "express": "^4.13.4", 28 | "moment": "^2.13.0", 29 | "morgan": "^1.7.0", 30 | "multer": "^1.1.0", 31 | "pug": "^2.0.0-alpha7", 32 | "purecss": "^0.6.0", 33 | "request": "^2.72.0", 34 | "vinyl-fs": "^2.4.3" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/transload.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var request = require('request'); 3 | var contentDisposition = require('content-disposition'); 4 | var sysPath = require('path'); 5 | var sysURL = require('url'); 6 | var debug = require('debug')('transload'); 7 | 8 | function transload(options) { 9 | return (req, res, next)=>{ 10 | var fileURL = req.body.fileURL; 11 | var parsedURL = sysURL.parse(fileURL); 12 | if (parsedURL.protocol == 'file') { 13 | res.status(403); 14 | return next('protocol not permitted'); 15 | } 16 | debug('requesting %s', fileURL); 17 | request(fileURL) 18 | .on('error', next) 19 | .on('response', (response)=>{ 20 | var dispo = contentDisposition.parse(response.headers['content-disposition']); 21 | var filename = dispo.parameters.filename; 22 | if (!filename) { 23 | filename = sysPath.basename(sysURL.parse(fileURL).pathname); 24 | } 25 | var filePath = sysPath.join(options.dir, filename); 26 | debug('writing %s', filePath); 27 | response.pipe(fs.createWriteStream(filePath)); 28 | 29 | res.setHeader('Refresh', '1;.'); 30 | res.send('Installed '+filename); 31 | }) 32 | } 33 | } 34 | 35 | module.exports = transload; 36 | -------------------------------------------------------------------------------- /lib/runCommand.js: -------------------------------------------------------------------------------- 1 | var ansi_up = require('ansi_up'); 2 | var child_process = require('child_process'); 3 | var es = require('event-stream'); 4 | 5 | module.exports.header = '
';
 6 | module.exports.footer = '
Back to Control Panel'; 7 | 8 | function pipeOutput(child, res) { 9 | res.type('html'); 10 | res.writeContinue(); 11 | for (var i = 0; i < 20; i++) { 12 | res.write(" \n"); 13 | } 14 | res.write(module.exports.header); 15 | 16 | es.merge([child.stdout, child.stderr]) 17 | .pipe(es.through(function(text){ 18 | var html = ansi_up.ansi_to_html(ansi_up.escape_for_html(''+text)); 19 | this.emit('data', html); 20 | }, function(end){ 21 | this.emit('data', module.exports.footer); 22 | this.emit('end'); 23 | })) 24 | .pipe(res); 25 | } 26 | module.exports.pipeOutput = pipeOutput; 27 | 28 | function middleware(req, res, next) { 29 | res.runCommand = (cmd, args, env)=> { 30 | var child = child_process.spawn(cmd, args, env); 31 | pipeOutput(child, res); 32 | res.write('$ '+cmd+' '+args.join(' ')+'\n'); 33 | 34 | child.on('error', (err)=>{ 35 | res.write(''+err.toString()+''); 36 | }); 37 | 38 | return child; 39 | }; 40 | next(); 41 | } 42 | module.exports.middleware = middleware; 43 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var express = require('express'); 4 | var bodyParser = require('body-parser'); 5 | var basicAuth = require('basic-auth'); 6 | var multer = require('multer'); 7 | var morgan = require('morgan'); 8 | var crypto = require('crypto'); 9 | var pug = require('pug'); 10 | var moment = require('moment'); 11 | var Promise = require('bluebird'); 12 | 13 | var runCommand = require('./lib/runCommand'); 14 | var listFiles = Promise.promisify(require('./lib/listFiles')); 15 | var transload = require('./lib/transload'); 16 | 17 | var paths = {}; 18 | paths.base = process.env.FACTORIO_DIR || '/usr/local/factorio'; 19 | paths.saves = paths.base+'/saves'; 20 | paths.mods = paths.base+'/mods'; 21 | paths.exe = paths.base+'/bin/x64/factorio'; 22 | paths.config = paths.base+'/data'; 23 | 24 | var configLoaded = false; 25 | var configName = 'server-settings.example.json'; 26 | 27 | function saveNameToPath(name) { 28 | return paths.saves + '/' + name.replace(/^[.]+/g, '_') + '.zip'; 29 | } 30 | 31 | function configPath(con) { 32 | if (con === "" || con === null){ 33 | con = configName; 34 | } 35 | return paths.config + '/' + con; 36 | } 37 | 38 | var salt = crypto.randomBytes(32); 39 | var passwordHash = crypto.pbkdf2Sync(process.env.ADMIN_PASSWORD || '', salt, 10000, 512, 'sha512'); 40 | 41 | var runningServer = null; 42 | 43 | 44 | var app = express(); 45 | 46 | app.use(morgan('common')); 47 | app.use('/saves', express.static(paths.saves)); 48 | app.use('/mods', express.static(paths.mods)); 49 | app.use('/static', express.static(__dirname+'/static')); 50 | 51 | var admin = express.Router(); 52 | app.use('/', admin); 53 | 54 | function sortTags(tags){ 55 | var tagsf = "" 56 | for(i = 0; i < tags.length -1; i++){ 57 | tagsf = tagsf + tags[i] + ","; 58 | }; 59 | tagsf = tagsf + tags[i] 60 | return tagsf 61 | } 62 | 63 | admin.get('/', (req, res, next)=>{ 64 | var fs = require("fs"); 65 | var confi = fs.readFileSync(configPath(configName)); 66 | parsedConfig = JSON.parse(confi); 67 | parsedConfig.tags = sortTags(parsedConfig.tags) 68 | 69 | var saves = []; 70 | var mods = []; 71 | Promise.all([ 72 | listFiles(paths.saves+'/*.zip', 'saves') 73 | .then((files)=>{ 74 | saves = files; 75 | }), 76 | listFiles(paths.mods+'/*.zip', 'mods') 77 | .then((files)=>{ 78 | mods = files; 79 | }) 80 | ]) 81 | .then(()=>{ 82 | var options = { 83 | pretty: true, 84 | cache: process.env.NODE_ENV != 'debug' 85 | }; 86 | adminTemplate = pug.compileFile(__dirname+'/admin.pug', options); 87 | context = { 88 | parsedConfig: parsedConfig, 89 | moment: moment, 90 | runningServer: runningServer, 91 | saves: saves, 92 | mods: mods 93 | }; 94 | html = adminTemplate(context); 95 | res.send(html); 96 | }); 97 | }); 98 | 99 | admin.use((req, res, next)=>{ 100 | // allow read-only methods 101 | if (['GET', 'HEAD', 'OPTIONS'].indexOf(req.method) !== -1) { 102 | return next(); 103 | } 104 | // require password for other methods 105 | var user = basicAuth(req) || {pass: ''}; 106 | crypto.pbkdf2(user.pass, salt, 10000, 512, 'sha512', (err, hash)=>{ 107 | if (err) { 108 | return next(err); 109 | } 110 | if (Buffer.compare(hash, passwordHash) !== 0) { 111 | res.set('WWW-Authenticate', 'Basic realm=Authorization Required'); 112 | res.sendStatus(401); 113 | } 114 | // password was correct 115 | next(); 116 | }) 117 | }); 118 | 119 | admin.use(runCommand.middleware); 120 | 121 | admin.get('/version', (req, res, next)=>{ 122 | res.runCommand(paths.exe, ['--version']); 123 | }); 124 | 125 | admin.use(bodyParser.urlencoded({extended: false})); 126 | 127 | admin.post('/create-save', (req, res, next)=>{ 128 | var saveName = req.body.saveName; 129 | if (saveName) { 130 | res.runCommand(paths.exe, ['--create', saveNameToPath(saveName)]); 131 | } 132 | else { 133 | res.status(400).send("You must specify a save name"); 134 | } 135 | }); 136 | 137 | admin.post('/saves', (req, res, next)=>{ 138 | var storage = multer.diskStorage({ 139 | destination: (req, file, callback)=>{ 140 | callback(null, paths.saves); 141 | }, 142 | filename: (req, file, callback)=>{ 143 | callback(null, file.originalname); 144 | } 145 | }); 146 | var upload = multer({storage: storage}).single('file'); 147 | upload(req, res, (err)=>{ 148 | if (err) { 149 | return next(err); 150 | } 151 | res.setHeader('Refresh', '1;.'); 152 | res.redirect(201, '.'); 153 | }); 154 | }); 155 | 156 | admin.post('/transload-mod', transload({dir: paths.mods})); 157 | 158 | admin.post('/mods', (req, res, next)=>{ 159 | var storage = multer.diskStorage({ 160 | destination: (req, file, callback)=>{ 161 | callback(null, paths.mods); 162 | }, 163 | filename: (req, file, callback)=>{ 164 | callback(null, file.originalname); 165 | } 166 | }); 167 | var upload = multer({storage: storage}).single('file'); 168 | upload(req, res, (err)=>{ 169 | if (err) { 170 | return next(err); 171 | } 172 | res.setHeader('Refresh', '1;.') 173 | res.redirect(201, '.'); 174 | }); 175 | }); 176 | 177 | admin.post('/start-server', (req, res, next)=>{ 178 | req.body.saveName = saveNameToPath(req.body.saveName); 179 | req.body.configfile = configPath(req.body.configfile); 180 | if (runningServer != null) { 181 | res.send("sorry, server is already running"); 182 | } 183 | else { 184 | var supportedArgs = { 185 | saveName: '--start-server', 186 | latencyMS: '--latency-ms', 187 | autosaveInterval: '--autosave-interval', 188 | autosaveSlots: '--autosave-slots', 189 | port: '--port', 190 | configfile: '--server-settings' 191 | } 192 | var supportedFlags = { 193 | peerToPeer: '--peer-to-peer', 194 | noAutoPause: '--no-auto-pause' 195 | } 196 | 197 | var args = []; 198 | for (var i in supportedArgs) { 199 | if (req.body[i]) { 200 | args.push(supportedArgs[i]); 201 | args.push(req.body[i]); 202 | } 203 | } 204 | for (var i in supportedFlags) { 205 | if (req.body[i]) { 206 | args.push(supportedFlags[i]); 207 | } 208 | } 209 | 210 | runningServer = res.runCommand(paths.exe, args); 211 | runningServer.startDate = new Date(); 212 | runningServer.port = req.body.port || '34197'; 213 | runningServer.on('exit', (code, signal)=>{ 214 | runningServer = null; 215 | }); 216 | } 217 | }); 218 | 219 | admin.post('/stop-server', (req, res, next)=>{ 220 | if (runningServer == null) { 221 | res.send("sorry, server is not running"); 222 | } 223 | else { 224 | runCommand.pipeOutput(runningServer, res); 225 | runningServer.kill('SIGTERM'); 226 | } 227 | }); 228 | 229 | admin.post('/console-command', (req, res, next)=>{ 230 | if (runningServer == null) { 231 | res.send("sorry, server is not running"); 232 | } 233 | else { 234 | runCommand.pipeOutput(runningServer, res); 235 | runningServer.stdin.write(req.body.command); 236 | } 237 | }); 238 | 239 | module.exports = app; 240 | 241 | if (module == require.main) { 242 | var server = app.listen(process.env.PORT || 8000, ()=>{ 243 | console.log('HTTP server is running on port %s', server.address().port); 244 | }); 245 | } 246 | -------------------------------------------------------------------------------- /admin.pug: -------------------------------------------------------------------------------- 1 | - title = "Factorio Server Control Panel for " + parsedConfig.name + "!" 2 | 3 | doctype html 4 | html 5 | head 6 | meta(charset="utf-8") 7 | title= title 8 | link(rel='stylesheet' href='static/pure.css') 9 | link(rel='stylesheet' href='static/main.css') 10 | body 11 | .container 12 | h1= title 13 | 14 | .pure-g 15 | .pure-u-1-3 16 | .pane 17 | h3 Mods 18 | - if (mods.length == 0) 19 | p 20 | i None 21 | - else 22 | ul 23 | each mod in mods 24 | li 25 | a(href=mod.path)= mod.stem 26 | 27 | form.pure-form.pure-form-stacked(method="POST" action="transload-mod") 28 | fieldset 29 | legend Install a mod 30 | label URL: 31 | input(type="text" name="fileURL") 32 | br 33 | button(type="submit") Transload 34 | form.pure-form.pure-form-stacked(method="POST" action="mods" enctype="multipart/form-data") 35 | fieldset 36 | legend Upload a mod 37 | input(type="file" name="file") 38 | br 39 | button(type="submit") Upload 40 | 41 | .pure-u-1-3 42 | .pane 43 | h3 Saved Games 44 | - if (saves.length == 0) 45 | p 46 | i None 47 | - else 48 | ul 49 | each save in saves 50 | li 51 | a(href=save.path)= save.stem 52 | time(datetime=save.stat.mtime)= '('+moment(save.stat.mtime).fromNow()+')' 53 | 54 | form.pure-form.pure-form-stacked(method="POST" action="create-save") 55 | fieldset 56 | legend Create new save file 57 | label Name: 58 | input(type="text" name="saveName") 59 | br 60 | button(type="submit") Create 61 | form.pure-form.pure-form-stacked(method="POST" action="saves" enctype="multipart/form-data") 62 | fieldset 63 | legend Upload a save file 64 | input(type="file" name="file") 65 | br 66 | button(type="submit") Upload 67 | 68 | .pure-u-1-3 69 | .pane 70 | h3 Game Server 71 | - if (runningServer) 72 | form.pure-form.pure-form-stacked(method="POST" action="stop-server") 73 | fieldset 74 | legend Server is Running 75 | p= " UDP port "+runningServer.port 76 | p Started 77 | time(datetime=runningServer.startDate)= moment(runningServer.startDate).fromNow() 78 | button(type="submit") Stop 79 | 80 | form.pure-form.pure-form-stacked(method="POST" action="console-command") 81 | fieldset 82 | label Send console command: 83 | input(type="text" name="command") 84 | br 85 | button(type="submit") Send 86 | 87 | - else 88 | form.pure-form.pure-form-stacked(method="POST" action="start-server") 89 | fieldset 90 | legend Server is not running 91 | label Save file: 92 | select(name='saveName') 93 | each save in saves 94 | option(value=save.stem)= save.stem 95 | br 96 | label Latency (ms): 97 | input(type="text" name="latencyMS") 98 | br 99 | label Autosave Interval (minutes): 100 | input(type="text" name="autosaveInterval" placeholder="2") 101 | br 102 | label Autosave Slots: 103 | input(type="text" name="autosaveSlots" placeholder="3") 104 | br 105 | label 106 | input(type="text" name="configfile" placeholder="server-settings.example.json") 107 | br 108 | label Port: 109 | input(type="number" name="port" placeholder="34197") 110 | br 111 | label 112 | input(type="checkbox" name="peerToPeer") 113 | | Use Peer-to-Peer 114 | label 115 | input(type="checkbox" name="noAutoPause") 116 | | No Auto-pause 117 | br 118 | button(type="submit") Start Server 119 | 120 | 121 | .pure-u-1-3 122 | .pane 123 | h3 Game Settings 124 | - if (runningServer) 125 | | please stop the server before editing settings 126 | - else 127 | form.pure-form.pure-form-stacked(method="POST" action="save-settings") 128 | fieldset 129 | label Server Name 130 | input(type="text" name="serverName" value=parsedConfig.name) 131 | br 132 | label Server Description 133 | input(type="text" name="serverDescription" value=parsedConfig.description) 134 | br 135 | label Tags, Seperate with commas 136 | input(type="text" name="serverTags" value=parsedConfig.tags) 137 | br 138 | label Max players (0 for unlimited) 139 | input(type="number" name="serverMaxPlayers" min="0" value=parsedConfig.max_players) 140 | br 141 | h4(style="float: left; text-align: left") Visablity 142 | br 143 | br 144 | br 145 | br 146 | label Public 147 | input(type="checkbox" name="serverVisablityPublic" checked=parsedConfig.visibility.public) 148 | br 149 | label LAN 150 | input(type="checkbox" name="serverVisablityLAN" checked=parsedConfig.visibility.lan) 151 | br 152 | h4(style="float: left; text-align: left") Your factorio.com login credentials. Required for games with visibility public 153 | br 154 | label Username 155 | input(type="text" name="serverUsername" value=parsedConfig.username) 156 | br 157 | label Password 158 | input(type="password" name="serverPassword" value=parsedConfig.password) 159 | br 160 | label Token (used instead of a password and username) 161 | input(type="text" name="serverToken" value=parsedConfig.token) 162 | br 163 | label Game Password 164 | input(type="password" name="serverGamePassword" value=parsedConfig.game_password) 165 | br 166 | label Required user verification 167 | input(type="checkbox" name="serverUserVerification" checked=parsedConfig.require_user_verification) 168 | br 169 | label Max Upload in KB/s (0 for unlimited) 170 | input(type="number" name="serverMaxUpload" value=parsedConfig.max_upload_in_kilobytes_per_second) 171 | br 172 | label Minimum latency in ticks (1 tick = 16ms, 0 for unlimited) 173 | input(type="number" name="serverMinTick" value=parsedConfig.minimum_latency_in_ticks) 174 | br 175 | label Ignore player limit for returning players 176 | input(type="checkbox" name="serverIgnoreRetuningPlayers" checked=parsedConfig.ignore_player_limit_for_returning_players) 177 | br 178 | label Allow Commands 179 | select(name=serverAllowCommands value=parsedConfig.allow_commands) 180 | option false 181 | option admins-only 182 | option true 183 | --------------------------------------------------------------------------------