Skip to content

Commit e86a22e

Browse files
author
Kenson Man
committed
Support profile (just like .ssh/config) configuration
Fixing the FileNotFoundError in python 2.7~3.5
1 parent 4aec063 commit e86a22e

12 files changed

+553
-5
lines changed

README.md

+30
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

+8
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>=5.3.1
5+
6+
#The following package used for testing
7+
#pytest
8+
#pytest-cov
9+
#codecov
10+
#flake8
11+
#mock

tests/data/profiles-sample.yaml

+33
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

+99
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

+39-5
Original file line numberDiff line numberDiff line change
@@ -387,12 +387,37 @@ 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) \
395+
and len(profiles['profiles']) > 0 \
396+
and not value:
397+
raise InvalidValueError(
398+
'Argument "profile" is required according to your settings.'
399+
)
400+
if not (value is None or profiles['profiles'] is None):
401+
return profiles['profiles'][int(value)]
402+
return None
403+
390404
def get_args(self):
391-
hostname = self.get_hostname()
392-
port = self.get_port()
393-
username = self.get_value('username')
405+
profile = self.get_profile()
406+
if profile is not None and len(profile) > 0:
407+
hostname = profile.get('host', self.get_hostname())
408+
port = profile.get('port', self.get_port())
409+
username = profile.get('username', self.get_value('username'))
410+
if 'private-key' in profile:
411+
filename = ''
412+
privatekey = profile['private-key']
413+
else:
414+
privatekey, filename = self.get_privatekey()
415+
else:
416+
hostname = self.get_hostname()
417+
port = self.get_port()
418+
username = self.get_value('username')
419+
privatekey, filename = self.get_privatekey()
394420
password = self.get_argument('password', u'')
395-
privatekey, filename = self.get_privatekey()
396421
passphrase = self.get_argument('passphrase', u'')
397422
totp = self.get_argument('totp', u'')
398423

@@ -488,7 +513,16 @@ def head(self):
488513
pass
489514

490515
def get(self):
491-
self.render('index.html', debug=self.debug, font=self.font)
516+
profiles = self.settings.get('profiles')
517+
if profiles and len(profiles) > 0:
518+
self.render(
519+
'profiles.html',
520+
profiles=profiles,
521+
debug=self.debug,
522+
font=self.font
523+
)
524+
else:
525+
self.render('index.html', debug=self.debug, font=self.font)
492526

493527
@tornado.gen.coroutine
494528
def post(self):

webssh/settings.py

+36
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
@@ -12,6 +16,11 @@
1216
)
1317
from webssh._version import __version__
1418

19+
try:
20+
FileNotFoundError
21+
except NameError:
22+
FileNotFoundError = IOError
23+
1524

1625
def print_version(flag):
1726
if flag:
@@ -73,6 +82,30 @@ def get_url(self, filename, dirs):
7382
return os.path.join(*(dirs + [filename]))
7483

7584

85+
def get_profiles():
86+
filename = os.getenv('PROFILES', None)
87+
if filename:
88+
if not filename.startswith(os.sep):
89+
filename = os.path.join(os.path.abspath(os.sep), filename)
90+
try:
91+
if not os.path.exists(filename):
92+
raise FileNotFoundError()
93+
with open(filename, 'r') as fp:
94+
result = yaml.load(fp, Loader=SafeLoader)
95+
if result:
96+
idx = 0
97+
for p in result['profiles']:
98+
p['index'] = idx
99+
idx += 1
100+
result['required'] = bool(result.get('required', 'False'))
101+
return result
102+
except FileNotFoundError:
103+
logging.warning('Cannot found file profiles: {0}'.format(filename))
104+
except Exception:
105+
logging.warning('Unexpected error', exc_info=True)
106+
return None
107+
108+
76109
def get_app_settings(options):
77110
settings = dict(
78111
template_path=os.path.join(base_dir, 'webssh', 'templates'),
@@ -87,6 +120,9 @@ def get_app_settings(options):
87120
),
88121
origin_policy=get_origin_setting(options)
89122
)
123+
settings['profiles'] = get_profiles()
124+
if not settings['profiles']:
125+
del settings['profiles']
90126
return settings
91127

92128

0 commit comments

Comments
 (0)