Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support profile (just like .ssh/config) configuration #297

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ language: python

python:
- "2.7"
- "3.4"
- "3.5"
- "3.6"
- "3.7"
Expand Down
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.3.1

#The following package used for testing
#pytest
#pytest-cov
#codecov
#flake8
#mock
1 change: 0 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
'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',
Expand Down
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
36 changes: 36 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 All @@ -12,6 +16,11 @@
)
from webssh._version import __version__

try:
FileNotFoundError
except NameError:
FileNotFoundError = IOError


def print_version(flag):
if flag:
Expand Down Expand Up @@ -73,6 +82,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 +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


Expand Down
Loading