pull/297/merge
kensonman 2023-01-31 11:58:57 +04:00 committed by GitHub
commit 74e66dc2f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 553 additions and 7 deletions

View File

@ -3,7 +3,6 @@ language: python
python:
- "2.7"
- "3.4"
- "3.5"
- "3.6"
- "3.7"

View File

@ -203,6 +203,36 @@ Running as a standalone server
```bash
wssh --port=8080 --sslport=4433 --certfile='cert.crt' --keyfile='cert.key' --xheaders=False --policy=reject
```
### Profiling
Due to security, we should not disclose our private keys to anybody. Especially transfer
the private key and the passphrase in the same transaction, although the HTTPS protocol
can protect the transaction data.
This feature can provide the selectable profiles (just like ~/.ssh/config), it provides
the features just like the SSH Client config file (normally located at ~/.ssh/config) like this:
```yaml
required: False #If true, the profile is required to be selected before connect
profiles:
- name: The label will be shown on the profiles dropdown box
description: "It will be shown on the tooltip"
host: my-server.com
port: 22
username: user
private-key: |
-----BEGIN OPENSSH PRIVATE KEY-----
ABCD........
......
......
-----END OPENSSH PRIVATE KEY-----
- name: Profile 2
description: "It will shown on the tooltip"
host: my-server.com
port: 22
username: user2
```
### Tips

View File

@ -1,3 +1,11 @@
paramiko==2.10.4
tornado==5.1.1; python_version < '3.5'
tornado==6.1.0; python_version >= '3.5'
PyYAML>=5.3.1
#The following package used for testing
#pytest
#pytest-cov
#codecov
#flake8
#mock

View File

@ -27,7 +27,6 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',

View File

@ -0,0 +1,33 @@
required: true #If true, user have to select one of the profiles
profiles:
- name: sample1
description: "Long description"
host: localhost
port: 22
#optional, if specified, the username field should not be shown on the template
username: robey
- name: sample2
description: "Long description"
host: localhost
port: 22
#optional, if specified, the username field should not be shown on the template
username: robey
#optional, if specified.
#The below private key is clone from ./tests/data/user_rsa_key
private-key: |
-----BEGIN RSA PRIVATE KEY-----
MIICXQIBAAKBgQDI7iK3d8eWYZlYloat94c5VjtFY7c/0zuGl8C7uMnZ3t6i2G99
66hEW0nCFSZkOW5F0XKEVj+EUCHvo8koYC6wiohAqWQnEwIoOoh7GSAcB8gP/qaq
+adIl/Rvlby/mHakj+y05LBND6nFWHAn1y1gOFFKUXSJNRZPXSFy47gqzwIBIwKB
gQCbANjz7q/pCXZLp1Hz6tYHqOvlEmjK1iabB1oqafrMpJ0eibUX/u+FMHq6StR5
M5413BaDWHokPdEJUnabfWXXR3SMlBUKrck0eAer1O8m78yxu3OEdpRk+znVo4DL
guMeCdJB/qcF0kEsx+Q8HP42MZU1oCmk3PbfXNFwaHbWuwJBAOQ/ry/hLD7AqB8x
DmCM82A9E59ICNNlHOhxpJoh6nrNTPCsBAEu/SmqrL8mS6gmbRKUaya5Lx1pkxj2
s/kWOokCQQDhXCcYXjjWiIfxhl6Rlgkk1vmI0l6785XSJNv4P7pXjGmShXfIzroh
S8uWK3tL0GELY7+UAKDTUEVjjQdGxYSXAkEA3bo1JzKCwJ3lJZ1ebGuqmADRO6UP
40xH977aadfN1mEI6cusHmgpISl0nG5YH7BMsvaT+bs1FUH8m+hXDzoqOwJBAK3Z
X/za+KV/REya2z0b+GzgWhkXUGUa/owrEBdHGriQ47osclkUgPUdNqcLmaDilAF4
1Z4PHPrI5RJIONAx+JECQQC/fChqjBgFpk6iJ+BOdSexQpgfxH/u/457W10Y43HR
soS+8btbHqjQkowQ/2NTlUfWvqIlfxs6ZbFsIp/HrhZL
-----END RSA PRIVATE KEY-----

99
tests/test_profiles.py Normal file
View File

@ -0,0 +1,99 @@
import pytest, os, re, yaml, random
from tornado.options import options
from tornado.testing import AsyncTestCase, AsyncHTTPTestCase
from webssh.main import make_app, make_handlers
from webssh.settings import get_app_settings
from tests.utils import make_tests_data_path
from yaml.loader import SafeLoader
class TestYAMLLoading(object):
def test_profile_samples(self):
if 'PROFILES' in os.environ: del os.environ['PROFILES']
assert 'profiles' not in get_app_settings(options)
os.environ['PROFILES']=make_tests_data_path('profiles-sample.yaml')
assert 'profiles' in get_app_settings(options)
profiles=get_app_settings(options)['profiles']['profiles']
assert profiles[0]['name']=='sample1'
assert profiles[0]['description']=='Long description'
assert profiles[0]['host']=='localhost'
assert profiles[0]['port']==22
assert profiles[0]['username']=='robey'
assert profiles[1]['name']=='sample2'
assert profiles[1]['description']=='Long description'
assert profiles[1]['host']=='localhost'
assert profiles[1]['port']==22
assert profiles[1]['username']=='robey'
assert profiles[1]['private-key']==open(make_tests_data_path('user_rsa_key'), 'r').read()
del os.environ['PROFILES']
class _TestBasic_(object):
running = [True]
sshserver_port = 2200
body = 'hostname={host}&port={port}&profile={profile}&username={username}&password={password}'
headers = {'Cookie': '_xsrf=yummy'}
def _getApp_(self, **kwargs):
loop = self.io_loop
options.debug = False
options.policy = random.choice(['warning', 'autoadd'])
options.hostfile = ''
options.syshostfile = ''
options.tdstream = ''
options.delay = 0.1
#options.profiles=make_tests_data_path('tests/data/profiles-sample.yaml')
app = make_app(make_handlers(loop, options), get_app_settings(options))
return app
class TestWebGUIWithProfiles(AsyncHTTPTestCase, _TestBasic_):
def get_app(self):
try:
os.environ['PROFILES']=make_tests_data_path('profiles-sample.yaml')
return self._getApp_()
finally:
del os.environ['PROFILES']
def test_get_app_settings(self):
try:
os.environ['PROFILES']=make_tests_data_path('profiles-sample.yaml')
settings=get_app_settings(options)
assert 'profiles' in settings
profiles=settings['profiles']['profiles']
assert profiles[0]['name']=='sample1'
assert profiles[0]['description']=='Long description'
assert profiles[0]['host']=='localhost'
assert profiles[0]['port']==22
assert profiles[0]['username']=='robey'
assert profiles[1]['name']=='sample2'
assert profiles[1]['description']=='Long description'
assert profiles[1]['host']=='localhost'
assert profiles[1]['port']==22
assert profiles[1]['username']=='robey'
assert profiles[1]['private-key']==open(make_tests_data_path('user_rsa_key'), 'r').read()
finally:
del os.environ['PROFILES']
def test_without_profiles(self):
rep = self.fetch('/')
assert rep.code==200, 'Testing server response status code: {0}'.format(rep.code)
assert str(rep.body).index('<!-- PROFILES -->')>=0, 'Expected the "profiles.html" but "index.html"'
class TestWebGUIWithoutProfiles(AsyncHTTPTestCase, _TestBasic_):
def get_app(self):
if 'PROFILES' in os.environ: del os.environ['PROFILES']
return self._getApp_()
def test_get_app_settings(self):
if 'PROFILES' in os.environ: del os.environ['PROFILES']
settings=get_app_settings(options)
assert 'profiles' not in settings
def test_with_profiles(self):
rep = self.fetch('/')
assert rep.code==200, 'Testing server response status code: {0}'.format(rep.code)
with pytest.raises(ValueError):
str(rep.body).index('<!-- PROFILES -->')
assert False, 'Expected the origin "index.html" but "profiles.html"'

View File

@ -387,12 +387,37 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
hostname, port)
)
def get_profile(self):
profiles = self.settings.get('profiles', None)
if profiles: # If the profiles is configurated
value = self.get_argument('profile', None)
if profiles.get('required', False) \
and len(profiles['profiles']) > 0 \
and not value:
raise InvalidValueError(
'Argument "profile" is required according to your settings.'
)
if not (value is None or profiles['profiles'] is None):
return profiles['profiles'][int(value)]
return None
def get_args(self):
hostname = self.get_hostname()
port = self.get_port()
username = self.get_value('username')
profile = self.get_profile()
if profile is not None and len(profile) > 0:
hostname = profile.get('host', self.get_hostname())
port = profile.get('port', self.get_port())
username = profile.get('username', self.get_value('username'))
if 'private-key' in profile:
filename = ''
privatekey = profile['private-key']
else:
privatekey, filename = self.get_privatekey()
else:
hostname = self.get_hostname()
port = self.get_port()
username = self.get_value('username')
privatekey, filename = self.get_privatekey()
password = self.get_argument('password', u'')
privatekey, filename = self.get_privatekey()
passphrase = self.get_argument('passphrase', u'')
totp = self.get_argument('totp', u'')
@ -488,7 +513,16 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler):
pass
def get(self):
self.render('index.html', debug=self.debug, font=self.font)
profiles = self.settings.get('profiles')
if profiles and len(profiles) > 0:
self.render(
'profiles.html',
profiles=profiles,
debug=self.debug,
font=self.font
)
else:
self.render('index.html', debug=self.debug, font=self.font)
@tornado.gen.coroutine
def post(self):

View File

@ -3,6 +3,10 @@ import os.path
import ssl
import sys
import os
import yaml
from yaml.loader import SafeLoader
from tornado.options import define
from webssh.policy import (
load_host_keys, get_policy_class, check_policy_setting
@ -12,6 +16,11 @@ from webssh.utils import (
)
from webssh._version import __version__
try:
FileNotFoundError
except NameError:
FileNotFoundError = IOError
def print_version(flag):
if flag:
@ -73,6 +82,30 @@ class Font(object):
return '/'.join(dirs + [filename])
def get_profiles():
filename = os.getenv('PROFILES', None)
if filename:
if not filename.startswith(os.sep):
filename = os.path.join(os.path.abspath(os.sep), filename)
try:
if not os.path.exists(filename):
raise FileNotFoundError()
with open(filename, 'r') as fp:
result = yaml.load(fp, Loader=SafeLoader)
if result:
idx = 0
for p in result['profiles']:
p['index'] = idx
idx += 1
result['required'] = bool(result.get('required', 'False'))
return result
except FileNotFoundError:
logging.warning('Cannot found file profiles: {0}'.format(filename))
except Exception:
logging.warning('Unexpected error', exc_info=True)
return None
def get_app_settings(options):
settings = dict(
template_path=os.path.join(base_dir, 'webssh', 'templates'),
@ -87,6 +120,9 @@ def get_app_settings(options):
),
origin_policy=get_origin_setting(options)
)
settings['profiles'] = get_profiles()
if not settings['profiles']:
del settings['profiles']
return settings

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,56 @@
/*
* Bootstrap Cookie Alert by Wruczek
* https://github.com/Wruczek/Bootstrap-Cookie-Alert
* Released under MIT license
*/
(function () {
"use strict";
var cookieAlert = document.querySelector(".cookiealert");
var acceptCookies = document.querySelector(".acceptcookies");
if (!cookieAlert) {
return;
}
cookieAlert.offsetHeight; // Force browser to trigger reflow (https://stackoverflow.com/a/39451131)
// Show the alert if we cant find the "acceptCookies" cookie
if (!getCookie("acceptCookies")) {
cookieAlert.classList.add("show");
}
// When clicking on the agree button, create a 1 year
// cookie to remember user's choice and close the banner
acceptCookies.addEventListener("click", function () {
setCookie("acceptCookies", true, 365);
cookieAlert.classList.remove("show");
// dispatch the accept event
window.dispatchEvent(new Event("cookieAlertAccept"))
});
// Cookie functions from w3schools
function setCookie(cname, cvalue, exdays) {
var d = new Date();
d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
var expires = "expires=" + d.toUTCString();
document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
}
function getCookie(cname) {
var name = cname + "=";
var decodedCookie = decodeURIComponent(document.cookie);
var ca = decodedCookie.split(';');
for (var i = 0; i < ca.length; i++) {
var c = ca[i];
while (c.charAt(0) === ' ') {
c = c.substring(1);
}
if (c.indexOf(name) === 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
})();

File diff suppressed because one or more lines are too long

2
webssh/static/js/js.cookie.min.js vendored Normal file
View File

@ -0,0 +1,2 @@
/*! js-cookie v3.0.1 | MIT */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self,function(){var n=e.Cookies,o=e.Cookies=t();o.noConflict=function(){return e.Cookies=n,o}}())}(this,(function(){"use strict";function e(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var o in n)e[o]=n[o]}return e}return function t(n,o){function r(t,r,i){if("undefined"!=typeof document){"number"==typeof(i=e({},o,i)).expires&&(i.expires=new Date(Date.now()+864e5*i.expires)),i.expires&&(i.expires=i.expires.toUTCString()),t=encodeURIComponent(t).replace(/%(2[346B]|5E|60|7C)/g,decodeURIComponent).replace(/[()]/g,escape);var c="";for(var u in i)i[u]&&(c+="; "+u,!0!==i[u]&&(c+="="+i[u].split(";")[0]));return document.cookie=t+"="+n.write(r,t)+c}}return Object.create({set:r,get:function(e){if("undefined"!=typeof document&&(!arguments.length||e)){for(var t=document.cookie?document.cookie.split("; "):[],o={},r=0;r<t.length;r++){var i=t[r].split("="),c=i.slice(1).join("=");try{var u=decodeURIComponent(i[0]);if(o[u]=n.read(c,u),e===u)break}catch(e){}}return e?o[e]:o}},remove:function(t,n){r(t,"",e({},n,{expires:-1}))},withAttributes:function(n){return t(this.converter,e({},this.attributes,n))},withConverter:function(n){return t(e({},this.converter,n),this.attributes)}},{attributes:{value:Object.freeze(o)},converter:{value:Object.freeze(n)}})}({read:function(e){return'"'===e[0]&&(e=e.slice(1,-1)),e.replace(/(%[\dA-F]{2})+/gi,decodeURIComponent)},write:function(e){return encodeURIComponent(e).replace(/%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g,decodeURIComponent)}},{path:"/"})}));

View File

@ -0,0 +1,70 @@
(function($){
$(document).ready(function(){
$('[data-toggle="tooltip"]').tooltip();
$('form').validate({'ignore':'.ignore-validation'});
$('.profile-item').click(function(evt){
console.log('Selected a profile: '+$(this).text());
let dd=$(this).parents('div.dropdown:first');
$(dd).find('button:first').text($(this).text());
$('input:first').val($(this).attr('value'));
let profile=$(this).attr('value');
if(profile=='')profile='-1';
let found=false;
for(var i=0; i<profiles.length; i++)
if(profiles[i]['index']==profile){
profile=profiles[i];
found=true;
break;
}
$('form#connect').trigger('reset');
$('.fld-private-key').show();
$('.fld-password').show()
$('input[type=text],input[type=password],input[type=number]').val('').removeAttr('readonly');
if(found){
$('input[name=profile]').val(profile['index']);
if(profile['host'])$('input[name=hostname]').val('somewhere.com').attr('readonly', 'readonly');
if(profile['port'])$('input[name=port]').val('65535').attr('readonly', 'readonly');
if(profile['username'])$('input[name=username]').val('somebody').attr('readonly', 'readonly');
if(profile['private-key']){
$('.fld-private-key').hide();
$('input[name=passphrase]').focus().select();
$('.fld-password').hide()
}else{
$('input[name=password]').focus().select();
}
}
if(Boolean(Cookies.get('acceptCookies'))){
console.debug('Store the profile: '+profile['index']+' - '+profile['name']);
let expired=new Date();
expired.setTime(expired.getTime()+30*86400000); //expired=now+30days; 86400000=1000*60*60*24
Cookies.set('profileIndex', profile['index'], {'expires':expired, 'path':'/'});
Cookies.set('profileName', profile['name'], {'expires':expired, 'path':'/'});
}
return this;
});
let lastIndex=Cookies.get('profileIndex');
let lastName =Cookies.get('profileName');
if(Boolean(Cookies.get('acceptCookies')) && lastIndex!=undefined && lastName!=undefined){
console.debug('Restore the last selected profile: '+lastIndex+' - '+lastName);
let found=false;
$('.profile-item').each(function(idx, val){
if($(this).attr('value')==lastIndex && $(this).text()==lastName){
found=true;
$(this).trigger('click');
console.info('Restored the last profile['+lastIndex+'] - '+lastName);
}
});
if(!found)console.info('Profile index and name mismatched!');
}
console.log('/static/js/profiles.js loaded');
});
})(jQuery);

View File

@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title> WebSSH </title>
<link href="static/img/favicon.png" rel="icon" type="image/png">
<link href="static/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
<link href="static/css/xterm.min.css" rel="stylesheet" type="text/css"/>
<link href="static/css/fullscreen.min.css" rel="stylesheet" type="text/css"/>
<link href="static/css/cookiealert.css" rel="stylesheet" type="text/css"/>
<style>
.row {
margin-top: 15px;
margin-bottom: 10px;
}
.container {
margin-top: 20px;
}
.btn {
margin-top: 15px;
}
.btn-danger {
margin-left: 5px;
}
{% if font.family %}
@font-face {
font-family: '{{ font.family }}';
src: url('{{ font.url }}');
}
body {
font-family: '{{ font.family }}';
}
{% end %}
</style>
</head>
<body>
<div id="waiter" style="display: none"> Connecting ... </div>
<!-- PROFILES -->
<div class="container form-container" style="display: none">
<form id="connect" action="" method="post" enctype="multipart/form-data"{% if debug %} novalidate{% end %}>
<div class="row">
<div class="col">
<label for="Profile">Profile</label>
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="profilesBtn" data-toggle="dropdown" aria-expanded="false">
Select a profile
</button>
<div class="dropdown-menu" aria-labelledby="profilesBtn">
<a class="dropdown-item profile-item" href="#" value="">[[ Clear ]]</a>
{%for p in profiles['profiles']%}<a class="dropdown-item profile-item" href="#" value="{{p['index']}}" {%if 'description' in p%}data-toggle="tooltip" title="{{p['description']}}" tooltip-delay="3"{%end%}>{{p['name']}}</a>{%end%}
</div>
<input type="hidden" name="profile" value="" {%if profiles['required']%}required{%end%}/>
</div>
</div>
</div>
<div class="row">
<div class="col">
<label for="Hostname">Hostname</label>
<input class="form-control" type="text" id="hostname" name="hostname" value="" required>
</div>
<div class="col">
<label for="Port">Port</label>
<input class="form-control" type="number" id="port" name="port" placeholder="22" value="" min=1 max=65535>
</div>
</div>
<div class="row">
<div class="col">
<label for="Username">Username</label>
<input class="form-control" type="text" id="username" name="username" value="" required>
</div>
<div class="col fld-password">
<label for="Password">Password</label>
<input class="form-control" type="password" id="password" name="password" value="">
</div>
</div>
<div class="row">
<div class="col fld-private-key">
<label for="Username">Private Key</label>
<input class="form-control" type="file" id="privatekey" name="privatekey" value="">
</div>
<div class="col fld-passphrase">
<label for="Passphrase">Passphrase</label>
<input class="form-control" type="password" id="passphrase" name="passphrase" value="">
</div>
</div>
<div class="row">
<div class="col">
<label for="totp">Totp (time-based one-time password)</label>
<input class="form-control" type="password" id="totp" name="totp" value="">
</div>
<div class="col">
</div>
</div>
<input type="hidden" id="term" name="term" value="xterm-256color">
{% module xsrf_form_html() %}
<button type="submit" class="btn btn-primary">Connect</button>
<button type="reset" class="btn btn-danger">Reset</button>
</form>
</div>
<div class="container">
<div id="status" style="color: red;"></div>
<div id="terminal"></div>
</div>
<!-- START Bootstrap-Cookie-Alert -->
<div class="alert text-center cookiealert" role="alert">
<b>Do you like cookies?</b> &#x1F36A; We use cookies to ensure you get the best experience on our website. It will be used to store your preferred profile only! <a href="https://cookiesandyou.com/" target="_blank">Learn more</a>
<button type="button" class="btn btn-primary btn-sm acceptcookies">I agree</button>
</div>
<!-- END Bootstrap-Cookie-Alert -->
<script type="text/javascript"><!--
const profiles=[{%for p in profiles['profiles']%}{
"index": {{p['index']}},
"name": "{{p['name']}}",
"host": {{'true' if 'host' in p else 'false'}},
"port": {{'true' if 'port' in p else 'false'}},
"username": {{'true' if 'username' in p else 'false'}},
"private-key": {{'true' if ('private-key' in p and len(p['private-key'])>0) else 'false'}},
},{%end%}
];
//></script>
<script src="static/js/jquery.min.js"></script>
<script src="static/js/jquery.validation-1.19.3.min.js"></script>
<script src="static/js/popper.min.js"></script>
<script src="static/js/bootstrap.min.js"></script>
<script src="static/js/xterm.min.js"></script>
<script src="static/js/xterm-addon-fit.min.js"></script>
<script src="static/js/cookiealert.js"></script>
<script src="static/js/js.cookie.min.js"></script>
<script src="static/js/main.js"></script>
<script src="static/js/profiles.js"></script>
</body>
</html>