Skip to content

Commit

Permalink
Support profile (just like .ssh/config) configuration
Browse files Browse the repository at this point in the history
  • Loading branch information
Kenson Man committed Oct 20, 2022
1 parent 4aec063 commit 0b2b2b8
Show file tree
Hide file tree
Showing 12 changed files with 548 additions and 5 deletions.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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.4.1

#The following package used for testing
#pytest
#pytest-cov
#codecov
#flake8
#mock
33 changes: 33 additions & 0 deletions tests/data/profiles-sample.yaml
Original file line number Diff line number Diff line change
@@ -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 changes: 99 additions & 0 deletions tests/test_profiles.py
Original file line number Diff line number Diff line change
@@ -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"'
44 changes: 39 additions & 5 deletions webssh/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,12 +387,37 @@ def lookup_hostname(self, hostname, port):
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'')

Expand Down Expand Up @@ -488,7 +513,16 @@ def head(self):
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):
Expand Down
31 changes: 31 additions & 0 deletions webssh/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
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
Expand Down Expand Up @@ -73,6 +77,30 @@ def get_url(self, filename, dirs):
return os.path.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'),
Expand All @@ -87,6 +115,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


Expand Down
Loading

0 comments on commit 0b2b2b8

Please sign in to comment.