Resume session upon disconnect

This commit is contained in:
Wildan M 2024-10-27 02:33:18 +07:00
parent 9c0ba04b31
commit 89d9429648
12 changed files with 252 additions and 182 deletions

BIN
app/bun.lockb Executable file → Normal file

Binary file not shown.

View file

@ -18,6 +18,7 @@
<div id="dropupContent" class="dropup-content">
<a id="logBtn"><i class="fas fa-clipboard fa-fw"></i> Start Log</a>
<a id="downloadLogBtn"><i class="fas fa-download fa-fw"></i> Download Log</a>
<a id="restartBtn"><i class="fas fa-rotate-right fa-fw"></i> Restart Session</a>
<a id="reauthBtn" style="display: none;"><i class="fas fa-key fa-fw"></i> Switch User</a>
<a id="credentialsBtn" style="display: none;"><i class="fas fa-key fa-fw"></i> Credentials</a>
</div>

File diff suppressed because one or more lines are too long

View file

@ -341,7 +341,7 @@ body, html {
position: absolute;
background-color: #f1f1f1;
font-size: 16px;
min-width: 160px;
min-width: 180px;
bottom: 18px;
z-index: 101;
}

View file

@ -18,6 +18,7 @@
<div id="dropupContent" class="dropup-content">
<a id="logBtn"><i class="fas fa-clipboard fa-fw"></i> Start Log</a>
<a id="downloadLogBtn"><i class="fas fa-download fa-fw"></i> Download Log</a>
<a id="restartBtn"><i class="fas fa-rotate-right fa-fw"></i> Restart Session</a>
<a id="reauthBtn" style="display: none;"><i class="fas fa-key fa-fw"></i> Switch User</a>
<a id="credentialsBtn" style="display: none;"><i class="fas fa-key fa-fw"></i> Credentials</a>
</div>

View file

@ -122,7 +122,7 @@ body, html {
position: absolute;
background-color: #f1f1f1;
font-size: 16px;
min-width: 160px;
min-width: 180px;
bottom: 18px;
z-index: 101;
}

View file

@ -3,9 +3,9 @@ import { io } from 'socket.io-client';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { library, dom } from '@fortawesome/fontawesome-svg-core';
import { faBars, faClipboard, faDownload, faKey, faCog } from '@fortawesome/free-solid-svg-icons';
import { faBars, faClipboard, faDownload, faRotateRight, faKey, faCog } from '@fortawesome/free-solid-svg-icons';
library.add(faBars, faClipboard, faDownload, faKey, faCog);
library.add(faBars, faClipboard, faDownload, faRotateRight, faKey, faCog);
dom.watch();
const debug = require('debug')('WebSSH2');
@ -35,6 +35,7 @@ const term = new Terminal();
const logBtn = document.getElementById('logBtn');
const credentialsBtn = document.getElementById('credentialsBtn');
const reauthBtn = document.getElementById('reauthBtn');
const restartBtn = document.getElementById('restartBtn');
const downloadLogBtn = document.getElementById('downloadLogBtn');
const status = document.getElementById('status');
const header = document.getElementById('header');
@ -52,20 +53,25 @@ const socket = io({
});
// reauthenticate
function reauthSession () { // eslint-disable-line
function reauthSession() { // eslint-disable-line
debug('re-authenticating');
socket.emit('control', 'reauth');
window.location.href = '/ssh/reauth';
return false;
}
function restartSession() { // eslint-disable-line
debug('restarting');
socket.emit('control', 'reauth');
return false;
}
// cross browser method to "download" an element to the local system
// used for our client-side logging feature
function downloadLog () { // eslint-disable-line
function downloadLog() { // eslint-disable-line
if (loggedData === true) {
myFile = `WebSSH2-${logDate.getFullYear()}${
logDate.getMonth() + 1
}${logDate.getDate()}_${logDate.getHours()}${logDate.getMinutes()}${logDate.getSeconds()}.log`;
myFile = `WebSSH2-${logDate.getFullYear()}${logDate.getMonth() + 1
}${logDate.getDate()}_${logDate.getHours()}${logDate.getMinutes()}${logDate.getSeconds()}.log`;
// regex should eliminate escape sequences from being logged.
const blob = new Blob(
[
@ -91,15 +97,14 @@ function downloadLog () { // eslint-disable-line
}
// Set variable to toggle log data from client/server to a varialble
// for later download
function toggleLog () { // eslint-disable-line
function toggleLog() { // eslint-disable-line
if (sessionLogEnable === true) {
sessionLogEnable = false;
loggedData = true;
logBtn.innerHTML = '<i class="fas fa-clipboard fa-fw"></i> Start Log';
currentDate = new Date();
sessionLog = `${sessionLog}\r\n\r\nLog End for ${sessionFooter}: ${currentDate.getFullYear()}/${
currentDate.getMonth() + 1
}/${currentDate.getDate()} @ ${currentDate.getHours()}:${currentDate.getMinutes()}:${currentDate.getSeconds()}\r\n`;
sessionLog = `${sessionLog}\r\n\r\nLog End for ${sessionFooter}: ${currentDate.getFullYear()}/${currentDate.getMonth() + 1
}/${currentDate.getDate()} @ ${currentDate.getHours()}:${currentDate.getMinutes()}:${currentDate.getSeconds()}\r\n`;
logDate = currentDate;
term.focus();
return false;
@ -110,16 +115,15 @@ function toggleLog () { // eslint-disable-line
downloadLogBtn.style.color = '#000';
downloadLogBtn.addEventListener('click', downloadLog);
currentDate = new Date();
sessionLog = `Log Start for ${sessionFooter}: ${currentDate.getFullYear()}/${
currentDate.getMonth() + 1
}/${currentDate.getDate()} @ ${currentDate.getHours()}:${currentDate.getMinutes()}:${currentDate.getSeconds()}\r\n\r\n`;
sessionLog = `Log Start for ${sessionFooter}: ${currentDate.getFullYear()}/${currentDate.getMonth() + 1
}/${currentDate.getDate()} @ ${currentDate.getHours()}:${currentDate.getMinutes()}:${currentDate.getSeconds()}\r\n\r\n`;
logDate = currentDate;
term.focus();
return false;
}
// replay password to server, requires
function replayCredentials () { // eslint-disable-line
function replayCredentials() { // eslint-disable-line
socket.emit('control', 'replayCredentials');
debug(`control: replayCredentials`);
term.focus();
@ -130,6 +134,7 @@ function replayCredentials () { // eslint-disable-line
// when dom is changed, listeners are abandonded
function drawMenu() {
logBtn.addEventListener('click', toggleLog);
restartBtn.addEventListener('click', restartSession)
if (allowreauth) {
reauthBtn.addEventListener('click', reauthSession);
reauthBtn.style.display = 'block';
@ -144,19 +149,27 @@ function drawMenu() {
}
}
function resizeScreen() {
function resizeScreen(warmup = false) {
fitAddon.fit();
if (warmup) {
socket.emit('resize', { cols: term.cols - 1, rows: term.rows });
}
socket.emit('resize', { cols: term.cols, rows: term.rows });
debug(`resize: ${JSON.stringify({ cols: term.cols, rows: term.rows })}`);
}
window.addEventListener('resize', resizeScreen, false);
window.addEventListener('resize', () => resizeScreen(), false);
var hasResize = false;
term.onData((data) => {
socket.emit('data', data);
});
socket.on('data', (data: string | Uint8Array) => {
if (!hasResize) {
hasResize = true;
setTimeout(() => resizeScreen(true), 1000);
}
term.write(data);
if (sessionLogEnable) {
sessionLog += data;
@ -252,7 +265,13 @@ socket.on('disconnect', (err: any) => {
status.style.backgroundColor = 'red';
status.innerHTML = `WEBSOCKET SERVER DISCONNECTED: ${err}`;
}
socket.io.reconnection(false);
var i = setInterval(() => {
if (!socket.connected) {
socket.connect()
} else {
clearInterval(i);
}
}, 3000);
countdown.classList.remove('active');
});

View file

@ -11,7 +11,7 @@
"user": {
"name": null,
"password": null,
"privatekey": null,
"privateKey": null,
"overridebasic": false
},
"ssh": {

View file

@ -43,7 +43,7 @@ const configDefault = {
user: {
name: null,
password: null,
privatekey: null,
privateKey: null,
overridebasic: false,
},
ssh: {

View file

@ -69,6 +69,161 @@ async function checkSubnet(socket) {
}
}
/**
* @type {Map<string, {
conn: SSH;
isLogin: () => boolean;
changeSocket: (newSocket: any) => void;
}>}
*/
const sshMap = new Map();
/**
*
* @param {import ("socket.io-client").Socket} socket
* @returns
*/
function setupNewConnection(socket) {
const conn = new SSH();
let login = false;
let stream;
conn.on('banner', (data) => {
// need to convert to cr/lf for proper formatting
socket.emit('data', data.replace(/\r?\n/g, '\r\n').toString('utf-8'));
});
function handshake() {
socket.emit('setTerminalOpts', socket.request.session.ssh.terminal);
socket.emit('menu');
socket.emit('allowreauth', socket.request.session.ssh.allowreauth);
socket.emit('title', `ssh://${socket.request.session.ssh.host}`);
if (socket.request.session.ssh.header.background)
socket.emit('headerBackground', socket.request.session.ssh.header.background);
if (socket.request.session.ssh.header.name)
socket.emit('header', socket.request.session.ssh.header.name);
socket.emit(
'footer',
`ssh://${socket.request.session.username}@${socket.request.session.ssh.host}:${socket.request.session.ssh.port}`
);
socket.on('control', (controlData) => {
if (!stream) return;
if (controlData === 'replayCredentials' && socket.request.session.ssh.allowreplay) {
stream.write(`${socket.request.session.userpassword}\n`);
}
if (controlData === 'reauth' && socket.request.session.username && login === true) {
auditLog(
socket,
`LOGOUT user=${socket.request.session.username} from=${socket.handshake.address} host=${socket.request.session.ssh.host}:${socket.request.session.ssh.port}`
);
login = false;
socket.disconnect(true);
conn.end()
}
webssh2debug(socket, `SOCKET CONTROL: ${controlData}`);
});
socket.on('resize', (data) => {
if (!stream) return;
stream.setWindow(data.rows, data.cols);
webssh2debug(socket, `SOCKET RESIZE: ${JSON.stringify([data.rows, data.cols])}`);
});
socket.on('data', (data) => {
if (!stream) return;
stream.write(data);
});
}
conn.on('handshake', handshake);
conn.on('ready', () => {
webssh2debug(
socket,
`CONN READY: LOGIN: user=${socket.request.session.username} from=${socket.handshake.address} host=${socket.request.session.ssh.host} port=${socket.request.session.ssh.port} allowreplay=${socket.request.session.ssh.allowreplay} term=${socket.request.session.ssh.term}`
);
auditLog(
socket,
`LOGIN user=${socket.request.session.username} from=${socket.handshake.address} host=${socket.request.session.ssh.host}:${socket.request.session.ssh.port}`
);
login = true;
socket.emit('status', 'SSH CONNECTION ESTABLISHED');
socket.emit('statusBackground', 'green');
socket.emit('allowreplay', socket.request.session.ssh.allowreplay);
const { term, cols, rows } = socket.request.session.ssh;
conn.shell({ term, cols, rows }, (err, s) => {
if (err) {
logError(socket, `EXEC ERROR`, err);
conn.end();
socket.disconnect(true);
return;
}
socket.once('disconnect', (reason) => {
webssh2debug(socket, `CLIENT SOCKET DISCONNECT: ${util.inspect(reason)}`);
});
socket.on('error', (errMsg) => {
if (!socket) return;
webssh2debug(socket, `SOCKET ERROR: ${errMsg}`);
logError(socket, 'SOCKET ERROR', errMsg);
conn.end();
login = false;
socket.disconnect(true);
});
stream = s;
stream.on('data', (data) => {
if (!socket) return;
socket.emit('data', data.toString('utf-8'));
});
stream.on('close', (code, signal) => {
if (!socket) return;
webssh2debug(socket, `STREAM CLOSE: ${util.inspect([code, signal])}`);
if (socket.request.session?.username) {
auditLog(
socket,
`LOGOUT user=${socket.request.session.username} from=${socket.handshake.address} host=${socket.request.session.ssh.host}:${socket.request.session.ssh.port}`
);
}
if (code !== 0 && typeof code !== 'undefined')
logError(socket, 'STREAM CLOSE', util.inspect({ message: [code, signal] }));
socket.disconnect(true);
});
stream.stderr.on('data', (data) => {
console.error(`STDERR: ${data}`);
});
});
});
conn.on('end', (err) => {
if (err) logError(socket, 'CONN END BY HOST', err);
webssh2debug(socket, 'CONN END BY HOST');
socket.disconnect(true);
});
conn.on('close', (err) => {
if (err) logError(socket, 'CONN CLOSE', err);
webssh2debug(socket, 'CONN CLOSE');
socket.disconnect(true);
});
conn.on('error', (err) => connError(socket, err));
conn.on('keyboard-interactive', (_name, _instructions, _instructionsLang, _prompts, finish) => {
webssh2debug(socket, 'CONN keyboard-interactive');
finish([socket.request.session.userpassword]);
});
return {
conn, isLogin: () => login, changeSocket: (newSocket) => {
if (!socket.disconnected) {
socket.disconnect();
}
socket = newSocket;
// to display after resuming connection
if (login && stream) {
handshake();
stream.write("\n");
}
}
};
}
// public
module.exports = function appSocket(socket) {
let login = false;
@ -84,158 +239,52 @@ module.exports = function appSocket(socket) {
}
});
async function setupConnection() {
// if websocket connection arrives without an express session, kill it
if (!socket.request.session) {
socket.emit('401 UNAUTHORIZED');
webssh2debug(socket, 'SOCKET: No Express Session / REJECTED');
socket.disconnect(true);
return;
}
// If configured, check that requsted host is in a permitted subnet
if (socket.request.session?.ssh?.allowedSubnets?.length > 0) {
checkSubnet(socket);
}
const conn = new SSH();
conn.on('banner', (data) => {
// need to convert to cr/lf for proper formatting
socket.emit('data', data.replace(/\r?\n/g, '\r\n').toString('utf-8'));
});
conn.on('handshake', () => {
socket.emit('setTerminalOpts', socket.request.session.ssh.terminal);
socket.emit('menu');
socket.emit('allowreauth', socket.request.session.ssh.allowreauth);
socket.emit('title', `ssh://${socket.request.session.ssh.host}`);
if (socket.request.session.ssh.header.background)
socket.emit('headerBackground', socket.request.session.ssh.header.background);
if (socket.request.session.ssh.header.name)
socket.emit('header', socket.request.session.ssh.header.name);
socket.emit(
'footer',
`ssh://${socket.request.session.username}@${socket.request.session.ssh.host}:${socket.request.session.ssh.port}`
);
});
conn.on('ready', () => {
webssh2debug(
socket,
`CONN READY: LOGIN: user=${socket.request.session.username} from=${socket.handshake.address} host=${socket.request.session.ssh.host} port=${socket.request.session.ssh.port} allowreplay=${socket.request.session.ssh.allowreplay} term=${socket.request.session.ssh.term}`
);
auditLog(
socket,
`LOGIN user=${socket.request.session.username} from=${socket.handshake.address} host=${socket.request.session.ssh.host}:${socket.request.session.ssh.port}`
);
login = true;
socket.emit('status', 'SSH CONNECTION ESTABLISHED');
socket.emit('statusBackground', 'green');
socket.emit('allowreplay', socket.request.session.ssh.allowreplay);
const { term, cols, rows } = socket.request.session.ssh;
conn.shell({ term, cols, rows }, (err, stream) => {
if (err) {
logError(socket, `EXEC ERROR`, err);
conn.end();
socket.disconnect(true);
return;
}
socket.once('disconnect', (reason) => {
webssh2debug(socket, `CLIENT SOCKET DISCONNECT: ${util.inspect(reason)}`);
conn.end();
socket.request.session.destroy();
});
socket.on('error', (errMsg) => {
webssh2debug(socket, `SOCKET ERROR: ${errMsg}`);
logError(socket, 'SOCKET ERROR', errMsg);
conn.end();
socket.disconnect(true);
});
socket.on('control', (controlData) => {
if (controlData === 'replayCredentials' && socket.request.session.ssh.allowreplay) {
stream.write(`${socket.request.session.userpassword}\n`);
}
if (controlData === 'reauth' && socket.request.session.username && login === true) {
auditLog(
socket,
`LOGOUT user=${socket.request.session.username} from=${socket.handshake.address} host=${socket.request.session.ssh.host}:${socket.request.session.ssh.port}`
);
login = false;
conn.end();
socket.disconnect(true);
}
webssh2debug(socket, `SOCKET CONTROL: ${controlData}`);
});
socket.on('resize', (data) => {
stream.setWindow(data.rows, data.cols);
webssh2debug(socket, `SOCKET RESIZE: ${JSON.stringify([data.rows, data.cols])}`);
});
socket.on('data', (data) => {
stream.write(data);
});
stream.on('data', (data) => {
socket.emit('data', data.toString('utf-8'));
});
stream.on('close', (code, signal) => {
webssh2debug(socket, `STREAM CLOSE: ${util.inspect([code, signal])}`);
if (socket.request.session?.username && login === true) {
auditLog(
socket,
`LOGOUT user=${socket.request.session.username} from=${socket.handshake.address} host=${socket.request.session.ssh.host}:${socket.request.session.ssh.port}`
);
login = false;
}
if (code !== 0 && typeof code !== 'undefined')
logError(socket, 'STREAM CLOSE', util.inspect({ message: [code, signal] }));
socket.disconnect(true);
conn.end();
});
stream.stderr.on('data', (data) => {
console.error(`STDERR: ${data}`);
});
});
});
conn.on('end', (err) => {
if (err) logError(socket, 'CONN END BY HOST', err);
webssh2debug(socket, 'CONN END BY HOST');
socket.disconnect(true);
});
conn.on('close', (err) => {
if (err) logError(socket, 'CONN CLOSE', err);
webssh2debug(socket, 'CONN CLOSE');
socket.disconnect(true);
});
conn.on('error', (err) => connError(socket, err));
conn.on('keyboard-interactive', (_name, _instructions, _instructionsLang, _prompts, finish) => {
webssh2debug(socket, 'CONN keyboard-interactive');
finish([socket.request.session.userpassword]);
});
if (
socket.request.session.username &&
(socket.request.session.userpassword || socket.request.session.privatekey) &&
socket.request.session.ssh
) {
// console.log('hostkeys: ' + hostkeys[0].[0])
const { ssh } = socket.request.session;
ssh.username = socket.request.session.username;
ssh.password = socket.request.session.userpassword;
ssh.tryKeyboard = true;
ssh.debug = debug('ssh2');
conn.connect(ssh);
} else {
webssh2debug(
socket,
`CONN CONNECT: Attempt to connect without session.username/password or session varialbles defined, potentially previously abandoned client session. disconnecting websocket client.\r\nHandshake information: \r\n ${util.inspect(
socket.handshake
)}`
);
socket.emit('ssherror', 'WEBSOCKET ERROR - Refresh the browser and try again');
socket.request.session.destroy();
socket.disconnect(true);
}
// if websocket connection arrives without an express session, kill it
if (!socket.request.session) {
socket.emit('401 UNAUTHORIZED');
webssh2debug(socket, 'SOCKET: No Express Session / REJECTED');
socket.disconnect(true);
return;
}
setupConnection();
if (
!socket.request.session.username ||
!(socket.request.session.userpassword || socket.request.session.privateKey) ||
!socket.request.session.ssh
) {
webssh2debug(
socket,
`CONN CONNECT: Attempt to connect without session.username/password or session varialbles defined, potentially previously abandoned client session. disconnecting websocket client.\r\nHandshake information: \r\n ${util.inspect(
socket.handshake
)}`
);
socket.emit('ssherror', 'WEBSOCKET ERROR - Refresh the browser and try again');
socket.request.session.destroy();
socket.disconnect(true);
return;
}
// If configured, check that requsted host is in a permitted subnet
if (socket.request.session?.ssh?.allowedSubnets?.length > 0) {
checkSubnet(socket);
}
var connMap = sshMap.get(socket.request.session.username);
if (!connMap) {
connMap = setupNewConnection(socket);
sshMap.set(socket.request.session.username, connMap);
} else {
connMap.changeSocket(socket);
}
const { conn, isLogin } = connMap;
const { ssh } = socket.request.session;
ssh.username = socket.request.session.username;
ssh.password = socket.request.session.userpassword;
ssh.tryKeyboard = true;
ssh.debug = debug('ssh2');
if (!isLogin())
conn.connect(ssh);
};

View file

@ -5,15 +5,15 @@
const debug = require('debug')('WebSSH2');
const Auth = require('basic-auth');
let defaultCredentials = { username: null, password: null, privatekey: null };
let defaultCredentials = { username: null, password: null, privateKey: null };
exports.setDefaultCredentials = function setDefaultCredentials({
name: username,
password,
privatekey,
privateKey,
overridebasic,
}) {
defaultCredentials = { username, password, privatekey, overridebasic };
defaultCredentials = { username, password, privateKey, overridebasic };
};
exports.basicAuth = function basicAuth(req, res, next) {
@ -21,7 +21,7 @@ exports.basicAuth = function basicAuth(req, res, next) {
// If Authorize: Basic header exists and the password isn't blank
// AND config.user.overridebasic is false, extract basic credentials
// from client]
const { username, password, privatekey, overridebasic } = defaultCredentials;
const { username, password, privateKey, overridebasic } = defaultCredentials;
if (myAuth && myAuth.pass !== '' && !overridebasic) {
req.session.username = myAuth.name;
req.session.userpassword = myAuth.pass;
@ -29,9 +29,9 @@ exports.basicAuth = function basicAuth(req, res, next) {
} else {
req.session.username = username;
req.session.userpassword = password;
req.session.privatekey = privatekey;
req.session.privateKey = privateKey;
}
if (!req.session.userpassword && !req.session.privatekey) {
if (!req.session.userpassword && !req.session.privateKey) {
res.statusCode = 401;
debug('basicAuth credential request (401)');
res.setHeader('WWW-Authenticate', 'Basic realm="WebSSH"');

0
bun.lockb Executable file → Normal file
View file