diff --git a/core/exodus.js b/core/exodus.js new file mode 100644 index 00000000..271253e7 --- /dev/null +++ b/core/exodus.js @@ -0,0 +1,220 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('../core/menu_module.js').MenuModule; +const resetScreen = require('../core/ansi_term.js').resetScreen; +const Config = require('./config.js').config; +const Errors = require('./enig_error.js').Errors; +const Log = require('./logger.js').log; +const getEnigmaUserAgent = require('./misc_util.js').getEnigmaUserAgent; + +// deps +const async = require('async'); +const _ = require('lodash'); +const joinPath = require('path').join; +const crypto = require('crypto'); +const moment = require('moment'); +const https = require('https'); +const querystring = require('querystring'); +const fs = require('fs'); +const SSHClient = require('ssh2').Client; + +/* + Configuration block: + + + someDoor: { + module: exodus + config: { + // defaults + ticketHost: oddnetwork.org + ticketPort: 1984 + ticketPath: /exodus + rejectUnauthorized: false // set to true to allow untrusted CA's (dangerous!) + sshHost: oddnetwork.org + sshPort: 22 + sshUser: exodus + sshKeyPem: /path/to/enigma-bbs/misc/exodus.id_rsa + + // optional + caPem: /path/to/cacerts.pem // see https://curl.haxx.se/docs/caextract.html + + // required + board: XXXX + key: XXXX + door: some_door + } + } +*/ + +exports.moduleInfo = { + name : 'Exodus', + desc : 'Exodus Door Server Access Module - https://oddnetwork.org/exodus/', + author : 'NuSkooler', +}; + +exports.getModule = class ExodusModule extends MenuModule { + constructor(options) { + super(options); + + this.config = options.menuConfig.config || {}; + this.config.ticketHost = this.config.ticketHost || 'oddnetwork.org'; + this.config.ticketPort = this.config.ticketPort || 1984, + this.config.ticketPath = this.config.ticketPath || '/exodus'; + this.config.rejectUnauthorized = _.get(this.config, 'rejectUnauthorized', true); + this.config.sshHost = this.config.sshHost || this.config.ticketHost; + this.config.sshPort = this.config.sshPort || 22; + this.config.sshUser = this.config.sshUser || 'exodus_server'; + this.config.sshKeyPem = this.config.sshKeyPem || joinPath(Config.paths.misc, 'exodus.id_rsa'); + } + + initSequence() { + + const self = this; + let clientTerminated = false; + + async.waterfall( + [ + function validateConfig(callback) { + // very basic validation on optionals + async.each( [ 'board', 'key', 'door' ], (key, next) => { + return _.isString(self.config[key]) ? next(null) : next(Errors.MissingConfig(`Config requires "${key}"!`)); + }, callback); + }, + function loadCertAuthorities(callback) { + if(!_.isString(self.config.caPem)) { + return callback(null, null); + } + + fs.readFile(self.config.caPem, (err, certAuthorities) => { + return callback(err, certAuthorities); + }); + }, + function getTicket(certAuthorities, callback) { + const now = moment.utc().unix(); + const sha256 = crypto.createHash('sha256').update(`${self.config.key}${now}`).digest('hex'); + const token = `${sha256}|${now}`; + + const postData = querystring.stringify({ + token : token, + board : self.config.board, + user : self.client.user.username, + door : self.config.door, + }); + + const reqOptions = { + hostname : self.config.ticketHost, + port : self.config.ticketPort, + path : self.config.ticketPath, + rejectUnauthorized : self.config.rejectUnauthorized, + method : 'POST', + headers : { + 'Content-Type' : 'application/x-www-form-urlencoded', + 'Content-Length' : postData.length, + 'User-Agent' : getEnigmaUserAgent(), + } + }; + + if(certAuthorities) { + reqOptions.ca = certAuthorities; + } + + let ticket = ''; + const req = https.request(reqOptions, res => { + res.on('data', data => { + ticket += data; + }); + + res.on('end', () => { + if(ticket.length !== 36) { + return callback(Errors.Invalid(`Invalid Exodus ticket: ${ticket}`)); + } + + return callback(null, ticket); + }); + }); + + req.on('error', err => { + return callback(Errors.General(`Exodus error: ${err.message}`)); + }); + + req.write(postData); + req.end(); + }, + function loadPrivateKey(ticket, callback) { + fs.readFile(self.config.sshKeyPem, (err, privateKey) => { + return callback(err, ticket, privateKey); + }); + }, + function establishSecureConnection(ticket, privateKey, callback) { + + let pipeRestored = false; + let pipedStream; + + function restorePipe() { + if(pipedStream && !pipeRestored && !clientTerminated) { + self.client.term.output.unpipe(pipedStream); + self.client.term.output.resume(); + } + } + + self.client.term.write(resetScreen()); + self.client.term.write('Connecting to Exodus server, please wait...\n'); + + const sshClient = new SSHClient(); + + const shellOptions = { + env : { + exodus : ticket, + } + }; + + sshClient.on('ready', () => { + self.client.once('end', () => { + self.client.log.info('Connection ended. Terminating Exodus connection'); + clientTerminated = true; + return sshClient.end(); + }); + + sshClient.shell(shellOptions, (err, stream) => { + + pipedStream = stream; // :TODO: ewwwwwwwww hack + self.client.term.output.pipe(stream); + + stream.on('data', d => { + return self.client.term.rawWrite(d); + }); + + stream.on('close', () => { + restorePipe(); + return sshClient.end(); + }); + }); + }); + + sshClient.on('close', () => { + restorePipe(); + return callback(null); + }); + + sshClient.connect({ + host : self.config.sshHost, + port : self.config.sshPort, + username : self.config.sshUser, + privateKey : privateKey, + }); + } + ], + err => { + if(err) { + self.client.log.warn( { error : err.message }, 'Exodus error'); + } + + if(!clientTerminated) { + self.prevMenu(); + } + } + ); + } +}; diff --git a/core/ftn_util.js b/core/ftn_util.js index c48cc222..c482650f 100644 --- a/core/ftn_util.js +++ b/core/ftn_util.js @@ -1,17 +1,18 @@ /* jslint node: true */ 'use strict'; -let Config = require('./config.js').config; -let Address = require('./ftn_address.js'); -let FNV1a = require('./fnv1a.js'); +let Config = require('./config.js').config; +let Address = require('./ftn_address.js'); +let FNV1a = require('./fnv1a.js'); +const getCleanEnigmaVersion = require('./misc_util.js').getCleanEnigmaVersion; -let _ = require('lodash'); -let iconv = require('iconv-lite'); -let moment = require('moment'); +let _ = require('lodash'); +let iconv = require('iconv-lite'); +let moment = require('moment'); //let uuid = require('node-uuid'); -let os = require('os'); +let os = require('os'); -let packageJson = require('../package.json'); +let packageJson = require('../package.json'); // :TODO: Remove "Ftn" from most of these -- it's implied in the module exports.stringToNullPaddedBuffer = stringToNullPaddedBuffer; @@ -146,11 +147,7 @@ function getMessageIdentifier(message, address) { // in which (; ; ) is used instead // function getProductIdentifier() { - const version = packageJson.version - .replace(/\-/g, '.') - .replace(/alpha/,'a') - .replace(/beta/,'b'); - + const version = getCleanEnigmaVersion(); const nodeVer = process.version.substr(1); // remove 'v' prefix return `ENiGMA1/2 ${version} (${os.platform()}; ${os.arch()}; ${nodeVer})`; diff --git a/core/misc_util.js b/core/misc_util.js index 1f3a11df..afe33dee 100644 --- a/core/misc_util.js +++ b/core/misc_util.js @@ -1,12 +1,17 @@ /* jslint node: true */ 'use strict'; -var paths = require('path'); +const paths = require('path'); -exports.isProduction = isProduction; -exports.isDevelopment = isDevelopment; -exports.valueWithDefault = valueWithDefault; -exports.resolvePath = resolvePath; +const os = require('os'); +const packageJson = require('../package.json'); + +exports.isProduction = isProduction; +exports.isDevelopment = isDevelopment; +exports.valueWithDefault = valueWithDefault; +exports.resolvePath = resolvePath; +exports.getCleanEnigmaVersion = getCleanEnigmaVersion; +exports.getEnigmaUserAgent = getEnigmaUserAgent; function isProduction() { var env = process.env.NODE_ENV || 'dev'; @@ -27,4 +32,21 @@ function resolvePath(path) { path = (process.env.HOME || mswCombined || process.env.HOMEPATH || process.env.HOMEDIR || process.cwd()) + path.substr(1); } return paths.resolve(path); +} + +function getCleanEnigmaVersion() { + return packageJson.version + .replace(/\-/g, '.') + .replace(/alpha/,'a') + .replace(/beta/,'b') + ; +} + +// See also ftn_util.js getTearLine() & getProductIdentifier() +function getEnigmaUserAgent() { + // can't have 1/2 or ½ in User-Agent according to RFC 1945 :( + const version = getCleanEnigmaVersion(); + const nodeVer = process.version.substr(1); // remove 'v' prefix + + return `ENiGMA-BBS/${version} (${os.platform()}; ${os.arch()}; ${nodeVer})`; } \ No newline at end of file diff --git a/core/servers/content/web.js b/core/servers/content/web.js index 048de693..31c617e2 100644 --- a/core/servers/content/web.js +++ b/core/servers/content/web.js @@ -116,7 +116,7 @@ exports.getModule = class WebServerModule extends ServerModule { // additional options Object.assign(options, Config.contentServers.web.https.options || {} ); - this.httpsServer = https.createServer(options, this.routeRequest); + this.httpsServer = https.createServer(options, (req, resp) => this.routeRequest(req, resp) ); } } diff --git a/misc/exodus.id_rsa b/misc/exodus.id_rsa new file mode 100644 index 00000000..356aba63 --- /dev/null +++ b/misc/exodus.id_rsa @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAmpwn/vJ1CAIkVnQGDZumvEDsMyFSHioGO5RM/T2Id6XLX91r +feJI6w48yqLV+HgKLUK7eeTOzb/l4VShH9AzOqTbAxwfZ6fgzV2cI/wZxO0S6QpS +7IrwcVK1Bm7Wu45Kp7LcGHB66nHSb+wqIYkZobIc8Z9arClJxV4AzgaUxjJrk0wT +hW81r5TicbTG7zm+bOMLO/mln+HA/EOtx/yfKDcfkl+mLzzbMpojor+KdwuKJUb1 ++r4PhPVl6pZgOuQIl37Qh5SPY3mMjwyXW/tUe+ZmPpfOm3CKf/pTLsA45QzUbNBY +GPLjbEcMJ4R5T3c2LXCKR+Wi9/pCkeZT7/1BbQIDAQABAoIBAQCCasrKIddahAQG +8SPSAsQo9FLJ5oeQbj6Hr1cqHueola/yE6KCs4hyzrW08JqxVwCuoSXncnyHziGp +a2vmnAc6pqkf/G75TwEv+pClQhiyppBXB6Bfa+vai7ury39TAnoy74r9CpSEgrLS +OlJnq3B1lvsXTiZ8Ju/Vjq/7Gk4QyFOVPugbmjhUtuCiyRXV9V2o/HUzZGtaXDp5 +n+XOfb90mLtPhtIRC6wmgMkhlRPpGir+NN0DWQ1oBWZO+TockIFusVInOTEXY4ui +V+JJ3KRwfaogzJMnDcqkiCck6bMT8E85ucRScsJjpENsUyEjFAoRV2grbguc/rdx +dgG5BMx5AoGBAMgCDFGwCctHfRvRXIac5goxYuTkVYjEh4yxj8d/Y+0HmDJiH5HM +tiUAtsgq/KYKJKM9U0PJWdPW3DPJa+wDVPQSlIqUOiXEpwLA+yhXuAvTqia9chuI +vaP1Ze/4yfW2eQg+3Ji0vC9VEr1eoRnAwJI+fDE3fRCvoPohlT4+zOhvAoGBAMXk +ksy5DdtUOvt0wss7R030dEtHP/Hs+qheQJOhl+GLlQt5BKP6NsdM3OKXyXYLddOc +xrKSWdjtiWOtap0D7o7cBFv44EmgzSvM2QltYxF4phPaNn2zPC/Mkvs1EaYnMtw4 +boKNDWbwixpCapheAE+lfA96DfqU/KyVaXls9MnjAoGAaL+B2ipbBsZ7BF2imrGD +XOU+iOf4z/c1kn7P8UiLefEXSZPQOti+sCRulejFhuQbCg8tE3xZejO2Ab1Es0eP +b4BnoSg+R9d1LGELaLaAIlmJbF6da0QzJbJ437QpeXFGdAYQHD3TrOpeNSVhNA6a +DD2DZ3dLHbkNktKRyhaz1CsCgYBMJbIfOK4OUZEIpVs3XK4JXyFIvjfq3aduFiZ/ +KFULIuzNJ1oTxvpBImB0iLeqxqomLVN/7zTHdk/BnT9C//pR2nOK+G9FpayNSBvT +ttXCKUyuou8I22kzc2Kzay5JYxf9CXHspl4b2D+OcTQXQUSZYTIlum+alq3LswqN +ANIIxQKBgHauoT79sViuB/wHcp2W/mek0p9aLkgQKt+riPJ4vKXc8DtapTgQzXkk +6yQCOSD8T9DcVGBcap9n6T21NOyDQwM0gg+DoHVeYqBrAa93jufOi7EY3MFrkjH6 +tC0crKBcUkxu43zhY4DkHLxId5btSPH57U+lhrJGjKXdvlJrGGOM +-----END RSA PRIVATE KEY-----