Skip to content

Commit edde53f

Browse files
author
Kenson Man
committed
Support profile (just like .ssh/config) configuration
1 parent 4aec063 commit edde53f

File tree

12 files changed

+533
-5
lines changed

12 files changed

+533
-5
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,36 @@ Running as a standalone server
203203
```bash
204204
wssh --port=8080 --sslport=4433 --certfile='cert.crt' --keyfile='cert.key' --xheaders=False --policy=reject
205205
```
206+
207+
### Profiling
208+
209+
Due to security, we should not disclose our private keys to anybody. Especially transfer
210+
the private key and the passphrase in the same transaction, although the HTTPS protocol
211+
can protect the transaction data.
212+
213+
This feature can provide the selectable profiles (just like ~/.ssh/config), it provides
214+
the features just like the SSH Client config file (normally located at ~/.ssh/config) like this:
215+
216+
```yaml
217+
required: False #If true, the profile is required to be selected before connect
218+
profiles:
219+
- name: The label will be shown on the profiles dropdown box
220+
description: "It will be shown on the tooltip"
221+
host: my-server.com
222+
port: 22
223+
username: user
224+
private-key: |
225+
-----BEGIN OPENSSH PRIVATE KEY-----
226+
ABCD........
227+
......
228+
......
229+
-----END OPENSSH PRIVATE KEY-----
230+
- name: Profile 2
231+
description: "It will shown on the tooltip"
232+
host: my-server.com
233+
port: 22
234+
username: user2
235+
```
206236
207237
208238
### Tips

requirements.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
paramiko==2.10.4
22
tornado==5.1.1; python_version < '3.5'
33
tornado==6.1.0; python_version >= '3.5'
4+
PyYAML==6.0
5+
6+
#The following package used for testing
7+
#pytest
8+
#pytest-cov
9+
#codecov
10+
#flake8
11+
#mock

tests/data/profiles-sample.yaml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
required: true #If true, user have to select one of the profiles
2+
profiles:
3+
- name: sample1
4+
description: "Long description"
5+
host: localhost
6+
port: 22
7+
#optional, if specified, the username field should not be shown on the template
8+
username: robey
9+
10+
- name: sample2
11+
description: "Long description"
12+
host: localhost
13+
port: 22
14+
#optional, if specified, the username field should not be shown on the template
15+
username: robey
16+
#optional, if specified.
17+
#The below private key is clone from ./tests/data/user_rsa_key
18+
private-key: |
19+
-----BEGIN RSA PRIVATE KEY-----
20+
MIICXQIBAAKBgQDI7iK3d8eWYZlYloat94c5VjtFY7c/0zuGl8C7uMnZ3t6i2G99
21+
66hEW0nCFSZkOW5F0XKEVj+EUCHvo8koYC6wiohAqWQnEwIoOoh7GSAcB8gP/qaq
22+
+adIl/Rvlby/mHakj+y05LBND6nFWHAn1y1gOFFKUXSJNRZPXSFy47gqzwIBIwKB
23+
gQCbANjz7q/pCXZLp1Hz6tYHqOvlEmjK1iabB1oqafrMpJ0eibUX/u+FMHq6StR5
24+
M5413BaDWHokPdEJUnabfWXXR3SMlBUKrck0eAer1O8m78yxu3OEdpRk+znVo4DL
25+
guMeCdJB/qcF0kEsx+Q8HP42MZU1oCmk3PbfXNFwaHbWuwJBAOQ/ry/hLD7AqB8x
26+
DmCM82A9E59ICNNlHOhxpJoh6nrNTPCsBAEu/SmqrL8mS6gmbRKUaya5Lx1pkxj2
27+
s/kWOokCQQDhXCcYXjjWiIfxhl6Rlgkk1vmI0l6785XSJNv4P7pXjGmShXfIzroh
28+
S8uWK3tL0GELY7+UAKDTUEVjjQdGxYSXAkEA3bo1JzKCwJ3lJZ1ebGuqmADRO6UP
29+
40xH977aadfN1mEI6cusHmgpISl0nG5YH7BMsvaT+bs1FUH8m+hXDzoqOwJBAK3Z
30+
X/za+KV/REya2z0b+GzgWhkXUGUa/owrEBdHGriQ47osclkUgPUdNqcLmaDilAF4
31+
1Z4PHPrI5RJIONAx+JECQQC/fChqjBgFpk6iJ+BOdSexQpgfxH/u/457W10Y43HR
32+
soS+8btbHqjQkowQ/2NTlUfWvqIlfxs6ZbFsIp/HrhZL
33+
-----END RSA PRIVATE KEY-----

tests/test_profiles.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import pytest, os, re, yaml, random
2+
from tornado.options import options
3+
from tornado.testing import AsyncTestCase, AsyncHTTPTestCase
4+
from webssh.main import make_app, make_handlers
5+
from webssh.settings import get_app_settings
6+
from tests.utils import make_tests_data_path
7+
from yaml.loader import SafeLoader
8+
9+
class TestYAMLLoading(object):
10+
def test_profile_samples(self):
11+
if 'PROFILES' in os.environ: del os.environ['PROFILES']
12+
assert 'profiles' not in get_app_settings(options)
13+
14+
os.environ['PROFILES']=make_tests_data_path('profiles-sample.yaml')
15+
assert 'profiles' in get_app_settings(options)
16+
profiles=get_app_settings(options)['profiles']['profiles']
17+
assert profiles[0]['name']=='sample1'
18+
assert profiles[0]['description']=='Long description'
19+
assert profiles[0]['host']=='localhost'
20+
assert profiles[0]['port']==22
21+
assert profiles[0]['username']=='robey'
22+
23+
assert profiles[1]['name']=='sample2'
24+
assert profiles[1]['description']=='Long description'
25+
assert profiles[1]['host']=='localhost'
26+
assert profiles[1]['port']==22
27+
assert profiles[1]['username']=='robey'
28+
assert profiles[1]['private-key']==open(make_tests_data_path('user_rsa_key'), 'r').read()
29+
del os.environ['PROFILES']
30+
31+
class _TestBasic_(object):
32+
running = [True]
33+
sshserver_port = 2200
34+
body = 'hostname={host}&port={port}&profile={profile}&username={username}&password={password}'
35+
headers = {'Cookie': '_xsrf=yummy'}
36+
37+
def _getApp_(self, **kwargs):
38+
loop = self.io_loop
39+
options.debug = False
40+
options.policy = random.choice(['warning', 'autoadd'])
41+
options.hostfile = ''
42+
options.syshostfile = ''
43+
options.tdstream = ''
44+
options.delay = 0.1
45+
#options.profiles=make_tests_data_path('tests/data/profiles-sample.yaml')
46+
app = make_app(make_handlers(loop, options), get_app_settings(options))
47+
return app
48+
49+
class TestWebGUIWithProfiles(AsyncHTTPTestCase, _TestBasic_):
50+
def get_app(self):
51+
try:
52+
os.environ['PROFILES']=make_tests_data_path('profiles-sample.yaml')
53+
return self._getApp_()
54+
finally:
55+
del os.environ['PROFILES']
56+
57+
58+
def test_get_app_settings(self):
59+
try:
60+
os.environ['PROFILES']=make_tests_data_path('profiles-sample.yaml')
61+
settings=get_app_settings(options)
62+
assert 'profiles' in settings
63+
profiles=settings['profiles']['profiles']
64+
assert profiles[0]['name']=='sample1'
65+
assert profiles[0]['description']=='Long description'
66+
assert profiles[0]['host']=='localhost'
67+
assert profiles[0]['port']==22
68+
assert profiles[0]['username']=='robey'
69+
70+
assert profiles[1]['name']=='sample2'
71+
assert profiles[1]['description']=='Long description'
72+
assert profiles[1]['host']=='localhost'
73+
assert profiles[1]['port']==22
74+
assert profiles[1]['username']=='robey'
75+
assert profiles[1]['private-key']==open(make_tests_data_path('user_rsa_key'), 'r').read()
76+
finally:
77+
del os.environ['PROFILES']
78+
79+
def test_without_profiles(self):
80+
rep = self.fetch('/')
81+
assert rep.code==200, 'Testing server response status code: {0}'.format(rep.code)
82+
assert str(rep.body).index('<!-- PROFILES -->')>=0, 'Expected the "profiles.html" but "index.html"'
83+
84+
class TestWebGUIWithoutProfiles(AsyncHTTPTestCase, _TestBasic_):
85+
def get_app(self):
86+
if 'PROFILES' in os.environ: del os.environ['PROFILES']
87+
return self._getApp_()
88+
89+
def test_get_app_settings(self):
90+
if 'PROFILES' in os.environ: del os.environ['PROFILES']
91+
settings=get_app_settings(options)
92+
assert 'profiles' not in settings
93+
94+
def test_with_profiles(self):
95+
rep = self.fetch('/')
96+
assert rep.code==200, 'Testing server response status code: {0}'.format(rep.code)
97+
with pytest.raises(ValueError):
98+
str(rep.body).index('<!-- PROFILES -->')
99+
assert False, 'Expected the origin "index.html" but "profiles.html"'

webssh/handler.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -387,12 +387,33 @@ def lookup_hostname(self, hostname, port):
387387
hostname, port)
388388
)
389389

390+
def get_profile(self):
391+
profiles=self.settings.get('profiles', None)
392+
if profiles: #if the profiles is configurated
393+
value=self.get_argument('profile', None)
394+
if profiles.get('required', False) and len(profiles['profiles'])>0 and not value:
395+
raise InvalidValueError('Argument "profile" is required according to your settings.')
396+
if not (value is None or profiles['profiles'] is None):
397+
return profiles['profiles'][int(value)]
398+
return None
399+
390400
def get_args(self):
391-
hostname = self.get_hostname()
392-
port = self.get_port()
393-
username = self.get_value('username')
401+
profile=self.get_profile()
402+
if profile is not None and len(profile)>0:
403+
hostname=profile['host'] if 'host' in profile else self.get_hostname()
404+
port=profile['port'] if 'port' in profile else self.get_port()
405+
username=profile['username'] if 'username' in profile else self.get_value('username')
406+
if 'private-key' in profile:
407+
filename=''
408+
privatekey=profile['private-key']
409+
else:
410+
privatekey, filename = self.get_privatekey()
411+
else:
412+
hostname = self.get_hostname()
413+
port = self.get_port()
414+
username = self.get_value('username')
415+
privatekey, filename = self.get_privatekey()
394416
password = self.get_argument('password', u'')
395-
privatekey, filename = self.get_privatekey()
396417
passphrase = self.get_argument('passphrase', u'')
397418
totp = self.get_argument('totp', u'')
398419

@@ -488,7 +509,10 @@ def head(self):
488509
pass
489510

490511
def get(self):
491-
self.render('index.html', debug=self.debug, font=self.font)
512+
if self.settings.get('profiles') is not None and len(self.settings.get('profiles'))>0:
513+
self.render('profiles.html', profiles=self.settings.get('profiles'), debug=self.debug, font=self.font)
514+
else:
515+
self.render('index.html', debug=self.debug, font=self.font)
492516

493517
@tornado.gen.coroutine
494518
def post(self):

webssh/settings.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
import ssl
44
import sys
55

6+
import os
7+
import yaml
8+
from yaml.loader import SafeLoader
9+
610
from tornado.options import define
711
from webssh.policy import (
812
load_host_keys, get_policy_class, check_policy_setting
@@ -72,6 +76,26 @@ def get_family(self, filename):
7276
def get_url(self, filename, dirs):
7377
return os.path.join(*(dirs + [filename]))
7478

79+
def get_profiles():
80+
filename=os.getenv('PROFILES', None)
81+
if filename:
82+
if not filename.startswith(os.sep): filename=os.path.join(os.path.abspath(os.sep), filename)
83+
try:
84+
if not os.path.exists(filename): raise FileNotFoundError()
85+
with open(filename, 'r') as fp:
86+
result=yaml.load(fp, Loader=SafeLoader)
87+
if result:
88+
idx=0
89+
for p in result['profiles']:
90+
p['index']=idx
91+
idx+=1
92+
result['required']=bool(result.get('required', 'False'))
93+
return result
94+
except FileNotFoundError:
95+
logging.warning('Cannot found file profiles: {0}'.format(filename))
96+
except:
97+
logging.warning('Unexpected error', exc_info=True)
98+
return None
7599

76100
def get_app_settings(options):
77101
settings = dict(
@@ -87,6 +111,8 @@ def get_app_settings(options):
87111
),
88112
origin_policy=get_origin_setting(options)
89113
)
114+
settings['profiles']=get_profiles()
115+
if not settings['profiles']: del settings['profiles']
90116
return settings
91117

92118

0 commit comments

Comments
 (0)