From df2a896139c3a590ee6ed27446df75361ffe3125 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Thu, 20 May 2021 12:47:20 -0400 Subject: [PATCH] chore: refactor ./app/server/app.js safeShutdown and setupSession --- app/package-lock.json | 8 ++ app/package.json | 3 +- app/server/app.js | 185 ++++++++++--------------------------- app/server/safeShutdown.js | 47 ++++++++++ app/server/setupSession.js | 71 ++++++++++++++ 5 files changed, 175 insertions(+), 139 deletions(-) create mode 100644 app/server/safeShutdown.js create mode 100644 app/server/setupSession.js diff --git a/app/package-lock.json b/app/package-lock.json index feaff6c..cdc6360 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -6357,6 +6357,14 @@ "passport-strategy": "1.x.x" } }, + "passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4=", + "requires": { + "passport-strategy": "1.x.x" + } + }, "passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", diff --git a/app/package.json b/app/package.json index a32e2f7..e57c06a 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "webssh2", - "version": "0.5.0-dev-0", + "version": "0.5.0-dev-1", "ignore": [ ".gitignore" ], @@ -42,6 +42,7 @@ "passport": "^0.4.1", "passport-custom": "^1.1.1", "passport-http": "^0.3.0", + "passport-local": "^1.0.0", "read-config-ng": "^3.0.2", "serve-favicon": "^2.5.0", "socket.io": "^4.1.1", diff --git a/app/server/app.js b/app/server/app.js index ef707cd..a66e405 100644 --- a/app/server/app.js +++ b/app/server/app.js @@ -1,6 +1,3 @@ -/* jshint esversion: 6, asi: true, node: true */ -/* eslint no-unused-expressions: ["error", { "allowShortCircuit": true, "allowTernary": true }], - no-console: ["error", { allow: ["warn", "error"] }] */ // app.js // eslint-disable-next-line import/order @@ -17,32 +14,27 @@ const logger = require('morgan'); const passport = require('passport'); const { BasicStrategy } = require('passport-http'); const CustomStrategy = require('passport-custom').Strategy; +const LocalStrategy = require('passport-local').Strategy; const app = express(); const server = require('http').Server(app); -const validator = require('validator'); const favicon = require('serve-favicon'); const io = require('socket.io')(server, { serveClient: false, path: '/ssh/socket.io', origins: config.http.origins, }); -const session = require('express-session')({ - secret: config.session.secret, - name: config.session.name, - resave: false, - saveUninitialized: false, - unset: 'destroy', -}); +const session = require('express-session')(config.session); +const { setupSession } = require('./setupSession'); const appSocket = require('./socket'); const expressOptions = require('./expressOptions'); -const myutil = require('./util'); +const safeShutdown = require('./safeShutdown'); // Static credentials strategy // when config.user.overridebasic is true, those credentials // are used instead of HTTP basic auth. passport.use( - 'custom', + 'overridebasic', new CustomStrategy((req, done) => { if (config.user.overridebasic) { const user = { @@ -72,31 +64,34 @@ passport.use( }) ); -// safe shutdown -let shutdownMode = false; -let shutdownInterval = 0; -let connectionCount = 0; -// eslint-disable-next-line consistent-return -function safeShutdownGuard(req, res, next) { - if (shutdownMode) { - res.status(503).end('Service unavailable: Server shutting down'); - } else { - return next(); - } -} -// clean stop -function stopApp(reason) { - shutdownMode = false; - // eslint-disable-next-line no-console - if (reason) console.log(`Stopping: ${reason}`); - if (shutdownInterval) clearInterval(shutdownInterval); - io.close(); - server.close(); -} +// Local auth strategy +// for taking credentials from GET/POST +passport.use( + new LocalStrategy((username, password, done) => { + const user = { + username, + password, + }; + debug( + `myAuth.name: ${username.yellow.bold.underline} and password ${ + password ? 'exists'.yellow.bold.underline : 'is blank'.underline.red.bold + }` + ); + return done(null, user); + }) +); + +passport.serializeUser((user, done) => { + done(null, user); +}); + +passport.deserializeUser((user, done) => { + done(null, user); +}); module.exports = { server, config }; // express -app.use(safeShutdownGuard); +app.use(safeShutdown.safeShutdownGuard); app.use(session); app.use(passport.initialize()); app.use(passport.session()); @@ -122,85 +117,23 @@ app.get('/ssh/reauth', (req, res) => { ); }); -passport.serializeUser((user, done) => { - done(null, user); -}); +// This route allows for collection of credentials from POST/GET +app.get( + '/ssh/login/host/:host?', + passport.authenticate(['overridebasic', 'local'], { session: true }), + (req, res) => { + setupSession(req, config); + res.sendFile(path.join(path.join(publicPath, 'client.htm'))); + } +); -passport.deserializeUser((user, done) => { - done(null, user); -}); - -// eslint-disable-next-line complexity +// This route allows for collection of credentials from HTTP Basic app.get( '/ssh/host/:host?', - passport.authenticate(['custom', 'basic'], { session: true }), + passport.authenticate(['overridebasic', 'basic'], { session: true }), (req, res) => { - req.session.username = req.user.username; - req.session.userpassword = req.user.password; + setupSession(req, config); res.sendFile(path.join(path.join(publicPath, 'client.htm'))); - // capture, assign, and validate variables - req.session.ssh = { - host: - config.ssh.host || - (validator.isIP(`${req.params.host}`) && req.params.host) || - (validator.isFQDN(req.params.host) && req.params.host) || - (/^(([a-z]|[A-Z]|[0-9]|[!^(){}\-_~])+)?\w$/.test(req.params.host) && req.params.host), - port: - (validator.isInt(`${req.query.port}`, { min: 1, max: 65535 }) && req.query.port) || - config.ssh.port, - localAddress: config.ssh.localAddress, - localPort: config.ssh.localPort, - header: { - name: req.query.header || config.header.text, - background: req.query.headerBackground || config.header.background, - }, - algorithms: config.algorithms, - keepaliveInterval: config.ssh.keepaliveInterval, - keepaliveCountMax: config.ssh.keepaliveCountMax, - allowedSubnets: config.ssh.allowedSubnets, - term: - (/^(([a-z]|[A-Z]|[0-9]|[!^(){}\-_~])+)?\w$/.test(req.query.sshterm) && req.query.sshterm) || - config.ssh.term, - terminal: { - cursorBlink: validator.isBoolean(`${req.query.cursorBlink}`) - ? myutil.parseBool(req.query.cursorBlink) - : config.terminal.cursorBlink, - scrollback: - validator.isInt(`${req.query.scrollback}`, { min: 1, max: 200000 }) && - req.query.scrollback - ? req.query.scrollback - : config.terminal.scrollback, - tabStopWidth: - validator.isInt(`${req.query.tabStopWidth}`, { min: 1, max: 100 }) && - req.query.tabStopWidth - ? req.query.tabStopWidth - : config.terminal.tabStopWidth, - bellStyle: - req.query.bellStyle && ['sound', 'none'].indexOf(req.query.bellStyle) > -1 - ? req.query.bellStyle - : config.terminal.bellStyle, - }, - allowreplay: - config.options.challengeButton || - (validator.isBoolean(`${req.headers.allowreplay}`) - ? myutil.parseBool(req.headers.allowreplay) - : false), - allowreauth: config.options.allowreauth || false, - mrhsession: - validator.isAlphanumeric(`${req.headers.mrhsession}`) && req.headers.mrhsession - ? req.headers.mrhsession - : 'none', - serverlog: { - client: config.serverlog.client || false, - server: config.serverlog.server || false, - }, - readyTimeout: - (validator.isInt(`${req.query.readyTimeout}`, { min: 1, max: 300000 }) && - req.query.readyTimeout) || - config.ssh.readyTimeout, - }; - if (req.session.ssh.header.name) validator.escape(req.session.ssh.header.name); - if (req.session.ssh.header.background) validator.escape(req.session.ssh.header.background); } ); @@ -220,41 +153,17 @@ io.on('connection', appSocket); // socket.io // expose express session with socket.request.session io.use((socket, next) => { - socket.request.res ? session(socket.request, socket.request.res, next) : next(next); // eslint disable-line + socket.request.res ? session(socket.request, socket.request.res, next) : next(next); }); io.on('connection', (socket) => { - connectionCount += 1; - socket.on('disconnect', () => { - connectionCount -= 1; - if (connectionCount <= 0 && shutdownMode) { - stopApp('All clients disconnected'); + if (io.of('/').sockets.size <= 1 && safeShutdown.shutdownMode) { + safeShutdown.stopApp(io, server, 'All clients disconnected'); } }); }); +// trap SIGTERM and SIGINT (CTRL-C) and handle shutdown gracefully const signals = ['SIGTERM', 'SIGINT']; -signals.forEach((signal) => - process.on(signal, () => { - if (shutdownMode) stopApp('Safe shutdown aborted, force quitting'); - else if (connectionCount > 0) { - let remainingSeconds = config.safeShutdownDuration; - shutdownMode = true; - const message = - connectionCount === 1 ? ' client is still connected' : ' clients are still connected'; - console.error(connectionCount + message); - console.error(`Starting a ${remainingSeconds} seconds countdown`); - console.error('Press Ctrl+C again to force quit'); - - shutdownInterval = setInterval(() => { - remainingSeconds -= 1; - if (remainingSeconds <= 0) { - stopApp('Countdown is over'); - } else { - io.sockets.emit('shutdownCountdownUpdate', remainingSeconds); - } - }, 1000); - } else stopApp(); - }) -); +signals.forEach((signal) => process.on(signal, () => safeShutdown.doShutdown(io, server, config))); diff --git a/app/server/safeShutdown.js b/app/server/safeShutdown.js new file mode 100644 index 0000000..c775850 --- /dev/null +++ b/app/server/safeShutdown.js @@ -0,0 +1,47 @@ +// safeShutdown.js + +// safe shutdown +let shutdownMode = false; +let shutdownInterval = 0; +// eslint-disable-next-line consistent-return +exports.safeShutdownGuard = (req, res, next) => { + if (shutdownMode) { + res.status(503).end('Service unavailable: Server shutting down'); + } else { + return next(); + } +}; +// clean stop +const stopApp = (io, server, reason) => { + shutdownMode = false; + // eslint-disable-next-line no-console + if (reason) console.log(`Stopping: ${reason}`); + if (shutdownInterval) clearInterval(shutdownInterval); + return process.exit(0); +}; + +exports.doShutdown = (io, server, config) => { + if (shutdownMode) stopApp(io, server, 'Safe shutdown aborted, force quitting'); + else if (io.of('/').sockets.size > 0) { + let remainingSeconds = config.safeShutdownDuration; + shutdownMode = true; + const message = + io.of('/').sockets.size === 1 ? ' client is still connected' : ' clients are still connected'; + console.error(io.of('/').sockets.size + message); + console.error(`Starting a ${remainingSeconds} seconds countdown`); + console.error('Press Ctrl+C again to force quit'); + + shutdownInterval = setInterval(() => { + remainingSeconds -= 1; + if (remainingSeconds <= 0) { + console.error('shutdown remaining seconds 0'); + stopApp('Countdown is over'); + } else { + io.sockets.emit('shutdownCountdownUpdate', remainingSeconds); + } + }, 1000); + } else stopApp(io, server); +}; + +exports.stopApp = stopApp; +exports.shutdownMode = shutdownMode; diff --git a/app/server/setupSession.js b/app/server/setupSession.js new file mode 100644 index 0000000..87b8f55 --- /dev/null +++ b/app/server/setupSession.js @@ -0,0 +1,71 @@ +// setupSession.js + +const validator = require('validator'); +const myutil = require('./util'); + +// private +// capture, assign, and validate variables for later use +exports.setupSession = function setupSession(req, config) { + req.session.username = req.user.username; + req.session.userpassword = req.user.password; + req.session.ssh = { + host: + config.ssh.host || + (validator.isIP(`${req.params.host}`) && req.params.host) || + (validator.isFQDN(req.params.host) && req.params.host) || + (/^(([a-z]|[A-Z]|[0-9]|[!^(){}\-_~])+)?\w$/.test(req.params.host) && req.params.host), + port: + (validator.isInt(`${req.query.port}`, { min: 1, max: 65535 }) && req.query.port) || + config.ssh.port, + localAddress: config.ssh.localAddress, + localPort: config.ssh.localPort, + header: { + name: req.query.header || config.header.text, + background: req.query.headerBackground || config.header.background, + }, + algorithms: config.algorithms, + keepaliveInterval: config.ssh.keepaliveInterval, + keepaliveCountMax: config.ssh.keepaliveCountMax, + allowedSubnets: config.ssh.allowedSubnets, + term: + (/^(([a-z]|[A-Z]|[0-9]|[!^(){}\-_~])+)?\w$/.test(req.query.sshterm) && req.query.sshterm) || + config.ssh.term, + terminal: { + cursorBlink: validator.isBoolean(`${req.query.cursorBlink}`) + ? myutil.parseBool(req.query.cursorBlink) + : config.terminal.cursorBlink, + scrollback: + validator.isInt(`${req.query.scrollback}`, { min: 1, max: 200000 }) && req.query.scrollback + ? req.query.scrollback + : config.terminal.scrollback, + tabStopWidth: + validator.isInt(`${req.query.tabStopWidth}`, { min: 1, max: 100 }) && req.query.tabStopWidth + ? req.query.tabStopWidth + : config.terminal.tabStopWidth, + bellStyle: + req.query.bellStyle && ['sound', 'none'].indexOf(req.query.bellStyle) > -1 + ? req.query.bellStyle + : config.terminal.bellStyle, + }, + allowreplay: + config.options.challengeButton || + (validator.isBoolean(`${req.headers.allowreplay}`) + ? myutil.parseBool(req.headers.allowreplay) + : false), + allowreauth: config.options.allowreauth || false, + mrhsession: + validator.isAlphanumeric(`${req.headers.mrhsession}`) && req.headers.mrhsession + ? req.headers.mrhsession + : 'none', + serverlog: { + client: config.serverlog.client || false, + server: config.serverlog.server || false, + }, + readyTimeout: + (validator.isInt(`${req.query.readyTimeout}`, { min: 1, max: 300000 }) && + req.query.readyTimeout) || + config.ssh.readyTimeout, + }; + if (req.session.ssh.header.name) validator.escape(req.session.ssh.header.name); + if (req.session.ssh.header.background) validator.escape(req.session.ssh.header.background); +};