/* jslint node: true */ 'use strict'; // ENiGMA½ const { MenuModule } = require('./menu_module.js'); const { resetScreen } = require('./ansi_term.js'); const Config = require('./config.js').get; const { Errors } = require('./enig_error.js'); const Log = require('./logger.js').log; const { getEnigmaUserAgent } = require('./misc_util.js'); const { trackDoorRunBegin, trackDoorRunEnd } = require('./door_util.js'); // 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-extra'); 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; let doorTracking; function restorePipe() { if (pipedStream && !pipeRestored && !clientTerminated) { self.client.term.output.unpipe(pipedStream); self.client.term.output.resume(); if (doorTracking) { trackDoorRunEnd(doorTracking); } } } self.client.term.write(resetScreen()); self.client.term.write( 'Connecting to Exodus server, please wait...\n' ); const sshClient = new SSHClient(); const window = { rows: self.client.term.termHeight, cols: self.client.term.termWidth, width: 0, height: 0, term: 'vt100', // Want to pass |self.client.term.termClient| here, but we end up getting hung up on :( }; const options = { 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(window, options, (err, stream) => { doorTracking = trackDoorRunBegin( self.client, `exodus_${self.config.door}` ); 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(); }); stream.on('error', err => { Log.warn( { error: err.message }, 'Exodus SSH client stream error' ); }); }); }); 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(); } } ); } };