Compare commits

...

31 Commits

Author SHA1 Message Date
KaKi87 26b6c802c4 🔒 Add 'ed25519' SSH host key type (cherry-pick 2a544f5) 7 months ago
KaKi87 aa37bd5fa6 👽 Update Uptime Robot API key 9 months ago
KaKi87 9ffdcce2fa 📝 Improve 0596a4e 1 year ago
KaKi87 0596a4ee88 📝 Add shields.io badges 1 year ago
Gitea 8cd1bc1740 🐛 Fix b5acab8 1 year ago
KaKi87 b5acab8d86 Add web SSH feature 1 year ago
Tiana Lemesle 2e6ce727ff Add SSH session title 1 year ago
Tiana Lemesle dce064e381 Add SSH server banner 2 years ago
Tiana Lemesle cba4e51cc4 🐛 Reset host removal button on cancel 2 years ago
KaKi87 0cff0bb951 💄 Display hosts chaining in hosts table 2 years ago
KaKi87 7518e5d188 💬 As per f26e421 2 years ago
KaKi87 46fb63a779 💄 Use yellow color for dangerous buttons, keep red color for error display 2 years ago
KaKi87 66be32b978 Implement host chaining as per d937234 2 years ago
KaKi87 1c18a83652 🚸 Hide previous success/error message before host connection test 2 years ago
KaKi87 c6843db06f 🥅 Catch SSH connect errors 2 years ago
KaKi87 f26e4215fe 🚚 Rename host 'user' property to 'username' 2 years ago
KaKi87 e8c0a8d60e ♻️ Merge 'test' & 'chainedSession' methods 2 years ago
KaKi87 d937234a80 Introduce host chaining w/ 'chainedSession' method + remove old 'session' method 2 years ago
KaKi87 34ebec80aa ✏️ Typo 2 years ago
KaKi87 302b251e7e 🐛 Fix table default color reset 2 years ago
KaKi87 972b047c06 💄 Add green color to submit buttons 2 years ago
KaKi87 2689b32b69 ♻️ Reverse disclaimer buttons order (cf. 2f9393e) 2 years ago
KaKi87 2cea26e564 🔥 Remove partially supported gray color 2 years ago
KaKi87 f2e3de1ac3 💄 Automatically increase hosts table height 2 years ago
KaKi87 5de713f6b1 🎨 Fix JB errors by JSDoc 2 years ago
KaKi87 2f9393e79c Add 'quit' buttons 2 years ago
KaKi87 176e1f5e7c 💄 Increase hosts table width 2 years ago
KaKi87 0528e1c472 🐛 Prevent login w/o credentials 2 years ago
KaKi87 0f7d8bf04f 🔧 Add data/.gitkeep 2 years ago
KaKi87 064b93a638 ⬆️ Upgrade argon2 dependency 2 years ago
KaKi87 05bffb787a ✏️ Typo 2 years ago
  1. 6
      README.md
  2. 3
      config.example.json
  3. 0
      data/.gitkeep
  4. 9
      index-web.js
  5. 7
      index.js
  6. 2
      lib/account.js
  7. 98
      lib/ssh.js
  8. 3
      lib/tui/helper.js
  9. 146
      lib/tui/index.js
  10. 6
      package.json
  11. 3
      test/account.js
  12. 1138
      yarn.lock

@ -1,5 +1,9 @@
# SSHception
[![](https://shields.kaki87.net/website.svg?url=https://ssh.kaki87.net&label=ssh.kaki87.net)](https://ssh.kaki87.net/)
![](https://shields.kaki87.net/uptimerobot/status/m790890781-fce7fd221b94d8ba97374fa6?label=ssh%20kaki87.net%20-p%203100)
[![](https://shields.kaki87.net/badge/license-MIT-green)](./LICENSE.md)
This project is, at the same time, an SSH server, an SSH client, and a password manager.
## Getting started
@ -55,7 +59,7 @@ The standard TOTP 2FA is supported.
#### No direct connection.
Since this project is a server-side service, the SSH connection to your target host is not initiated from your client itself but from the SSHception then streamed to you.
Since this project is a server-side service, the SSH connection to your target host is not initiated from your client itself but from the SSHception server-side client then streamed to you.
Therefore, your SSH client-side logs will always log your connections to the SSHception server and your SSH server-side logs will always log the connections from the SSHception server.

@ -1,3 +1,4 @@
{
"sshPort": 1234
"sshPort": 1234,
"webPort": 4321
}

@ -0,0 +1,9 @@
const
childProcess = require('child_process'),
path = require('path'),
{ sshPort, webPort } = require('./config.json');
childProcess.spawn(
path.join(__dirname, './node_modules/.bin/wetty'),
[`--ssh-host=localhost`, `--ssh-port=${sshPort}`, `--port=${webPort}`, `--base=/`],
{ stdio: 'inherit', stdin: 'inherit' }
);

@ -1,20 +1,23 @@
require('console-stamp')(console);
const
{ name, version } = require('./package.json'),
{ sshPort } = require('./config'),
{ Server } = require('ssh2'),
fs = require('fs'),
tui = require('./lib/tui');
new Server({
hostKeys: [ fs.readFileSync('./host.key') ]
hostKeys: [ fs.readFileSync('./host.rsa.key'), fs.readFileSync('./host.ed25519.key') ],
banner: `${name} v${version} | Register with a new password or login with an existing one`
}, client => {
client.on('authentication', async authentication => {
if(authentication.method !== 'password')
return authentication.reject(['password']);
const { username, password } = authentication;
if(!username || !password) return authentication.reject();
authentication.accept();
client.once('session', async accept => await tui(accept(), username, password));
client.once('session', async accept => await tui(accept(), username, password, () => client.end()));
});
client.on('error', error => {
if(error.code === 'ECONNRESET') return;

@ -65,7 +65,7 @@ const Account = function(username, password){
throw new Error('INVALID_ADDRESS');
if(typeof host.port !== 'number' || !Number.isInteger(host.port))
throw new Error('INVALID_PORT');
if(typeof host.user !== 'string')
if(typeof host.username !== 'string')
throw new Error('INVALID_USER');
if(typeof host.password !== 'string')
throw new Error('INVALID_PASSWORD');

@ -1,43 +1,65 @@
const { Client } = require('ssh2');
module.exports = {
test: ({ address, port, user, password }) => new Promise((resolve, reject) => {
const client = new Client();
client.on('error', ({ level: error }) => {
if(error === 'client-authentication')
reject('WRONG_CREDENTIALS');
else if(['client-socket', 'client-timeout'].includes(error))
reject('WRONG_DESTINATION');
else
reject();
});
client.on('ready', () => {
client.end();
resolve();
});
client.connect({
host: address,
port,
username: user,
password
});
}),
session: ({ address, port, user, password }, onReady, onClose, onError) => {
const client = new Client();
client.on('ready', () => client.shell((error, clientStream) => {
if(error) return onError(error);
clientStream.on('unpipe', () => {
onClose(clientStream);
client.end();
});
onReady(clientStream);
}));
client.on('error', onError);
client.connect({
host: address,
port,
username: user,
password
});
chainedSession: (hostsChain, onReady, onClose, onError, isTest) => {
const clients = [];
(async () => {
for(let i = 0; i < hostsChain.length; i++){
const
prevClient = clients[i-1],
{ address, port, username, password } = hostsChain[i],
nextHost = hostsChain[i+1],
client = clients[i] = new Client();
client.on('error', onError);
await new Promise(resolve => {
client.on('ready', () => {
if(nextHost) resolve();
else {
if(isTest){
client.end();
onReady();
}
else client.shell((error, clientStream) => {
if(error) return onError(error);
clientStream.on('unpipe', () => {
onClose(clientStream);
client.end();
});
onReady(clientStream);
});
}
});
if(prevClient){
client.on('end', () => prevClient.end());
prevClient.forwardOut('127.0.0.1', 12345 + i, address, port, (error, prevClientStream) => {
if(error) onError(error);
try {
client.connect({
sock: prevClientStream,
username,
password
});
}
catch(error){
onError(error);
}
});
}
else {
try {
client.connect({
host: address,
port,
username,
password
});
}
catch(error){
onError(error);
}
}
});
}
})();
}
};

@ -126,7 +126,7 @@ module.exports = screen => ({
{ top, width, height, columnsWidth, headers, data },
parent = screen
) => {
const _width = screen.width * width / 100;
const _width = Math.floor(screen.width * width / 100);
const _table = contrib.table({
parent,
keys,
@ -136,6 +136,7 @@ module.exports = screen => ({
border,
columnSpacing: 0,
columnWidth: columnsWidth.map(colW => Math.floor(_width * colW / 100)),
fg: white,
selectedFg: black,
selectedBg: white
});

@ -37,6 +37,7 @@ const getStream = session => new Promise(resolve => {
});
const getScreen = stream => blessed.screen({
title: 'SSHception',
smartCSR: true,
terminal: 'xterm-256color',
input: stream.stdout,
@ -52,37 +53,47 @@ const getViews = screen => {
});
const { form, text, button, textbox, checkbox, flexContainer, table } = helper(screen);
return {
disclaimer: () => new Promise(resolve => {
disclaimer: () => new Promise((resolve, reject) => {
const { name, version, description, homepage, author, license } = require('../../package');
const disclaimerForm = form();
text({ content: `${name} v${version} | ${description}\n\nBy ${author.name} | ${homepage}\n\nDistributed under the ${license} licence.` });
let n = 6;
const submitButton = button({ top: n, content: 'Proceed' }, undefined, disclaimerForm);
text({ top: n + 4, content: 'Quit to abort.' });
const actions = flexContainer({ top: 6 }, disclaimerForm);
const cancelButton = button({ content: 'Cancel' }, undefined, actions);
cancelButton.on('press', reject);
button({ content: 'Proceed' }, { color: 'green' }, actions)
.on('press', () => disclaimerForm.submit());
disclaimerForm.on('submit', () => resolve());
submitButton.on('press', () => disclaimerForm.submit());
screen.render();
submitButton.focus();
cancelButton.focus();
}),
wrongPassword: () => {
wrongPassword: quit => {
text(
{ content: '[!] Wrong password.\nPlease quit and try again.' },
{ color: 'red', borders: true }
);
const quitButton = button({ top: 5, content: 'Quit' });
quitButton.on('press', quit);
quitButton.focus();
screen.render();
},
pwnedPassword: () => {
pwnedPassword: quit => {
text(
{ content: '[!] Your password has been breached.\nSee https://haveibeenpwned.com/Passwords for more details.\nPlease quit and try again.' },
{ content: '[!] Your password has been breached.\nSee https://haveibeenpwned.com/passwords for more details.\nPlease quit and try again.' },
{ color: 'red', borders: true }
);
const quitButton = button({ top: 5, content: 'Quit' });
quitButton.on('press', quit);
quitButton.focus();
screen.render();
},
invalidUsername: () => {
invalidUsername: quit => {
text(
{ content: '[!] Your username is invalid.\nOnly alphanumeric characters are allowed.\nPlease quit and try again.' },
{ color: 'red', borders: true }
);
const quitButton = button({ top: 5, content: 'Quit' });
quitButton.on('press', quit);
quitButton.focus();
screen.render();
},
loginOtp: (isCancellable, callback) => {
@ -91,7 +102,7 @@ const getViews = screen => {
let n = 2;
const codeInput = textbox({ top: n, name: 'code' }, otpLoginForm);
const actions = flexContainer({ top: n += 4 }, otpLoginForm);
button({ content: 'Cancel', hidden: !isCancellable }, { color: 'gray' }, actions)
button({ content: 'Cancel', hidden: !isCancellable }, undefined, actions)
.on('press', () => callback({ isCancelled: true }));
button({ content: 'Submit' }, undefined, actions)
.on('press', () => otpLoginForm.submit());
@ -113,7 +124,7 @@ const getViews = screen => {
let n = 2;
const repeatPasswordInput = textbox({ top: n, name: 'repeatPassword', censor: true }, registrationForm);
checkbox({ top: n += 4, name: 'enableOtp', content: 'Enable OTP (next step)' }, registrationForm);
const submitButton = button({ top: n += 2, content: 'Submit' }, undefined, registrationForm);
const submitButton = button({ top: n += 2, content: 'Submit' }, { color: 'green' }, registrationForm);
const passwordMismatchError = text(
{ top: n + 4, content: 'This password does not match the one you entered via SSH.', hidden: true },
{ color: 'red' },
@ -142,9 +153,9 @@ const getViews = screen => {
text({ top: n += qrText.$height + 2, content: 'Input OTP code to confirm :' }, otpSetupForm);
const codeInput = textbox({ top: n += 2, name: 'code' }, otpSetupForm);
const actions = flexContainer({ top: n += 4 }, otpSetupForm);
button({ content: 'Cancel' }, { color: 'gray' }, actions)
button({ content: 'Cancel' }, undefined, actions)
.on('press', () => callback({ isCancelled: true }));
button({ content: 'Submit' }, undefined, actions)
button({ content: 'Submit' }, { color: 'green' }, actions)
.on('press', () => otpSetupForm.submit());
const successText = text({ top: n += 4, content: 'OTP successfully enabled', hidden: true }, { color: 'green' }, otpSetupForm);
const errorText = text({ top: n, content: 'Wrong OTP code', hidden: true }, { color: 'red' }, otpSetupForm);
@ -173,15 +184,20 @@ const getViews = screen => {
.on('press', () => callback({ action: 'edit-userpass' }));
button({ content: 'Delete account' }, undefined, mainActionsFormContainer)
.on('press', () => callback({ action: 'delete-account' }));
button({ content: 'Quit' }, undefined, mainActionsFormContainer)
.on('press', () => callback({ action: 'quit' }));
let selectedHost;
const tableHeight = Math.floor(screen.height / 4);
const tableHeight = screen.height - 15;
const hostsTable = table({
top: n += 1,
width: 50,
width: 75,
height: tableHeight,
columnsWidth: [ 50, 50 ],
headers: [ 'Host', 'URI' ],
data: hosts.map(host => [ host.name, `${host.user}@${host.address}:${host.port}` ])
data: hosts.map(host => [
host.chain ? `${host.name} (via ${host.chain.split(',').join(' -> ')})` : host.name,
`${host.username}@${host.address}:${host.port}`
])
});
selectButton.on('press', () => {
hostsTable.focus();
@ -196,11 +212,16 @@ const getViews = screen => {
}, { borders: true });
const selectedHostText = text(undefined, undefined, selectedHostForm);
const selectedHostActions = flexContainer({ top: 2, width: 75 }, selectedHostForm);
const cancelButton = button({ content: 'Cancel' }, { color: 'gray' }, selectedHostActions);
const cancelButton = button({ content: 'Cancel' }, undefined, selectedHostActions);
const connectButton = button({ content: 'Connect' }, { color: 'green' }, selectedHostActions);
button({ content: 'Edit' }, undefined, selectedHostActions)
.on('press', () => callback({ action: 'edit', host: selectedHost }));
const removeButton = button({ content: 'Remove' }, { color: 'yellow' }, selectedHostActions);
cancelButton.on('press', () => {
selectedHostForm.hide();
screen.render();
hostsTable.focus();
removeButton.setContent('Remove');
});
hostsTable.rows.on('select', (undefined, index) => {
selectedHost = hosts[index];
@ -209,15 +230,11 @@ const getViews = screen => {
screen.render();
cancelButton.focus();
});
const connectButton = button({ content: 'Connect' }, undefined, selectedHostActions)
connectButton.on('press', () => {
connectButton.setContent('Connecting');
screen.render();
callback({ action: 'connect', host: selectedHost });
});
button({ content: 'Edit' }, undefined, selectedHostActions)
.on('press', () => callback({ action: 'edit', host: selectedHost }));
const removeButton = button({ content: 'Remove' }, { color: 'red' }, selectedHostActions);
removeButton.on('press', () => {
if(removeButton.content === 'Remove'){
removeButton.setContent('Confirm');
@ -232,7 +249,7 @@ const getViews = screen => {
else
addButton.focus();
},
setHost: ({ name, address, port, user, password } = {}, callback) => {
setHost: ({ name, address, port, username, password, chain } = {}, callback) => {
let action;
const hostForm = form();
text({ content: 'Add host' }, { borderBottom: true }, hostForm);
@ -243,19 +260,21 @@ const getViews = screen => {
const addressInput = textbox({ top: n += 2, name: 'address', content: address }, hostForm);
text({ top: n += 4, content: 'Port :' }, hostForm);
textbox({ top: n += 2, name: 'port', content: port }, hostForm);
text({ top: n += 4, content: 'User :' }, hostForm);
const userInput = textbox({ top: n += 2, name: 'user', content: user }, hostForm);
text({ top: n += 4, content: 'Username :' }, hostForm);
const userInput = textbox({ top: n += 2, name: 'username', content: username }, hostForm);
text({ top: n += 4, content: 'Password :' }, hostForm);
textbox({ top: n += 2, name: 'password', censor: true, content: password }, hostForm);
text({ top: n += 4, content: 'Chain (optional, comma-separated) :' }, hostForm);
textbox({ top: n += 2, name: 'chain', content: chain }, hostForm);
const actions = flexContainer({ top: n += 4, width: 100 }, hostForm);
button({ content: 'Cancel' }, { color: 'gray' }, actions)
button({ content: 'Cancel' }, undefined, actions)
.on('press', () => callback({ action: 'cancel' }));
const testButton = button({ content: 'Test' }, undefined, actions);
testButton.on('press', () => {
action = 'test';
hostForm.submit();
});
button({ content: 'Submit' }, undefined, actions).on('press', () => {
button({ content: 'Submit' }, { color: 'green' }, actions).on('press', () => {
action = 'submit';
hostForm.submit();
});
@ -269,16 +288,18 @@ const getViews = screen => {
{ color: 'red' },
hostForm
);
hostForm.on('submit', ({ name, address, port, user, password }) => {
hostForm.on('submit', ({ name, address, port, username, password, chain }) => {
port = parseInt(port);
if(action === 'test'){
successText.hide();
errorText.hide();
testButton.setContent('Testing');
screen.render();
}
// noinspection JSUnusedGlobalSymbols
callback({
action,
host: { name, address, port, user, password },
host: { name, address, port, username, password, chain },
res: res => {
if(action === 'test'){
testButton.setContent('Test');
@ -289,8 +310,9 @@ const getViews = screen => {
else {
successText.hide();
const error = ({
WRONG_CREDENTIALS: 'wrong user or password',
WRONG_DESTINATION: 'wrong address or port'
WRONG_CREDENTIALS: 'wrong username or password',
WRONG_DESTINATION: 'wrong address or port',
INVALID_CHAIN: 'invalid hosts chain'
})[res];
errorText.setContent(`SSH connection failed : ${error || `unknown error (${res})`}`);
errorText.show();
@ -344,9 +366,9 @@ const getViews = screen => {
textbox({ top: n += 2, name: 'otpCode' }, editUserPassForm);
}
const actions = flexContainer({ top: n += 4 }, editUserPassForm);
button({ content: 'Cancel' }, { color: 'gray' }, actions)
button({ content: 'Cancel' }, undefined, actions)
.on('press', () => callback({ isCancelled: true }));
button({ content: 'Submit' }, undefined, actions)
button({ content: 'Submit' }, { color: 'green' }, actions)
.on('press', () => editUserPassForm.submit());
const errorText = text(
{ top: n + 4 },
@ -383,9 +405,9 @@ const getViews = screen => {
}
checkbox({ top: n += 4, name: 'confirm', content: 'I understand this cannot be undone.' }, deleteAccountForm);
const actions = flexContainer({ top: n += 2 }, deleteAccountForm);
button({ content: 'Cancel' }, { color: 'gray' }, actions)
button({ content: 'Cancel' }, undefined, actions)
.on('press', () => callback({ isCancelled: true }));
const submitButton = button({ content: 'Submit' }, { color: 'red' }, actions)
const submitButton = button({ content: 'Submit' }, { color: 'yellow' }, actions)
submitButton.on('press', () => deleteAccountForm.submit());
const errorText = text(
{ top: n + 4 },
@ -420,7 +442,7 @@ const getViews = screen => {
}
};
const getControllers = stream => {
const getControllers = (stream, end) => {
let screen, views;
const init = () => {
screen = getScreen(stream);
@ -432,6 +454,7 @@ const getControllers = stream => {
const account = require('../account');
(async () => {
try {
/** @type Account */
const user = await account.login(username, password);
if(user.isOtpLocked()){
views.loginOtp(false, ({ code, codeError }) => {
@ -449,7 +472,13 @@ const getControllers = stream => {
if(error.message === 'WRONG_USERNAME'){
try {
let isConfirmed = false;
await views.disclaimer();
try {
await views.disclaimer();
}
catch(_){
return end();
}
/** @type Account */
const user = await account.register(username, password);
views.register({ username }, async ({ repeatPassword, passwordMismatchError, enableOtp }) => {
if(repeatPassword !== password) return passwordMismatchError();
@ -473,13 +502,13 @@ const getControllers = stream => {
}
catch(error){
if(error.message === 'PWNED_PASSWORD')
views.pwnedPassword();
views.pwnedPassword(end);
else if(error.message === 'INVALID_USERNAME')
views.invalidUsername();
views.invalidUsername(end);
}
}
else if(error.message === 'WRONG_PASSWORD')
views.wrongPassword();
views.wrongPassword(end);
}
})();
}),
@ -492,8 +521,28 @@ const getControllers = stream => {
switch(action2){
case 'cancel': return _controllers.main(user);
case 'test': {
const { address, port, user, password } = host2;
ssh.test({ address, port, user, password }).then(() => res(true)).catch(res);
const
{ address, port, username, password, chain } = host2,
hosts = user.getHosts(),
hostsChain = [
...(chain ? chain.split(',').map(name => hosts.find(host => host.name === name)) : []),
{ address, port, username, password }
];
if(hostsChain.includes(undefined)) return res('INVALID_CHAIN');
ssh.chainedSession(
hostsChain,
() => res(true),
undefined,
({ level: error }) => {
if(error === 'client-authentication')
res('WRONG_CREDENTIALS');
else if(['client-socket', 'client-timeout'].includes(error))
res('WRONG_DESTINATION');
else
res();
},
true
);
return;
}
case 'submit': {
@ -517,7 +566,13 @@ const getControllers = stream => {
return _controllers.main(user);
}
case 'connect': {
return ssh.session(host, clientStream => {
const
{ chain } = host,
hosts = user.getHosts();
return ssh.chainedSession([
...(chain ? chain.split(',').map(name => hosts.find(host => host.name === name)) : []),
host
], clientStream => {
screen.destroy();
clientStream.stdout.pipe(stream.stdin);
stream.stdin.pipe(clientStream.stdout);
@ -593,6 +648,7 @@ const getControllers = stream => {
}
});
}
case 'quit': end();
}
});
}
@ -600,9 +656,9 @@ const getControllers = stream => {
return _controllers;
};
module.exports = async (session, username, password) => {
module.exports = async (session, username, password, end) => {
const stream = await getStream(session);
const controllers = getControllers(stream);
const controllers = getControllers(stream, end);
const user = await controllers.auth(username, password);
controllers.main(user);
};

@ -19,7 +19,8 @@
"cryptr": "^6.0.2",
"hibp": "^9.0.0",
"qrcode-terminal": "^0.12.0",
"ssh2": "^0.8.9"
"ssh2": "^0.8.9",
"wetty": "^2.1.1"
},
"devDependencies": {
"chai": "^4.2.0",
@ -28,8 +29,9 @@
"mocha": "^7.1.2"
},
"scripts": {
"keygen": "ssh-keygen -f host -N '' && mv host host.key",
"keygen": "ssh-keygen -f host.rsa.key -N '' && ssh-keygen -t ed25519 -f host.ed25519.key -N ''",
"start": "node index.js",
"start-web": "node index-web.js",
"test": "./node_modules/.bin/mocha"
}
}

@ -110,7 +110,7 @@ describe('account', () => {
name: 'host',
address: 'host',
port: 22,
user: 'user',
username: 'username',
password: 'password'
};
@ -197,6 +197,7 @@ describe('account', () => {
it('should have changed password', () => expect(account.login('username2', password2)).to.be.fulfilled);
after('reset username & password', async () => {
/** @type Account */
const _account = await account.login('username2', password2);
await _account.setUsername(password2, 'username');
await _account.setPassword(password2, password);

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save