Add support for SSH tunneled connections. Fixes #1447

This commit is contained in:
Akshay Joshi
2018-05-04 11:27:27 +01:00
committed by Dave Page
parent 455c45ea85
commit b7fb01ab04
26 changed files with 697 additions and 129 deletions

View File

@@ -479,7 +479,13 @@ class ServerNode(PGChildNodeView):
'sslcompression': 'sslcompression',
'bgcolor': 'bgcolor',
'fgcolor': 'fgcolor',
'service': 'service'
'service': 'service',
'use_ssh_tunnel': 'use_ssh_tunnel',
'tunnel_host': 'tunnel_host',
'tunnel_port': 'tunnel_port',
'tunnel_username': 'tunnel_username',
'tunnel_authentication': 'tunnel_authentication',
'tunnel_identity_file': 'tunnel_identity_file',
}
disp_lbl = {
@@ -665,7 +671,19 @@ class ServerNode(PGChildNodeView):
'sslcrl': server.sslcrl if is_ssl else None,
'sslcompression': True if is_ssl and server.sslcompression
else False,
'service': server.service if server.service else None
'service': server.service if server.service else None,
'use_ssh_tunnel': server.use_ssh_tunnel
if server.use_ssh_tunnel else 0,
'tunnel_host': server.tunnel_host if server.tunnel_host
else None,
'tunnel_port': server.tunnel_port if server.tunnel_port
else 22,
'tunnel_username': server.tunnel_username
if server.tunnel_username else None,
'tunnel_identity_file': server.tunnel_identity_file
if server.tunnel_identity_file else None,
'tunnel_authentication': server.tunnel_authentication
if server.tunnel_authentication else 0
}
)
@@ -736,7 +754,13 @@ class ServerNode(PGChildNodeView):
sslcompression=1 if is_ssl and data['sslcompression'] else 0,
bgcolor=data.get('bgcolor', None),
fgcolor=data.get('fgcolor', None),
service=data.get('service', None)
service=data.get('service', None),
use_ssh_tunnel=data.get('use_ssh_tunnel', 0),
tunnel_host=data.get('tunnel_host', None),
tunnel_port=data.get('tunnel_port', 22),
tunnel_username=data.get('tunnel_username', None),
tunnel_authentication=data.get('tunnel_authentication', 0),
tunnel_identity_file=data.get('tunnel_identity_file', None)
)
db.session.add(server)
db.session.commit()
@@ -754,6 +778,7 @@ class ServerNode(PGChildNodeView):
have_password = False
password = None
passfile = None
tunnel_password = None
if 'password' in data and data["password"] != '':
# login with password
have_password = True
@@ -764,9 +789,15 @@ class ServerNode(PGChildNodeView):
setattr(server, 'passfile', passfile)
db.session.commit()
if 'tunnel_password' in data and data["tunnel_password"] != '':
tunnel_password = data['tunnel_password']
tunnel_password = \
encrypt(tunnel_password, current_user.password)
status, errmsg = conn.connect(
password=password,
passfile=passfile,
tunnel_password=tunnel_password,
server_types=ServerType.types()
)
if hasattr(str, 'decode') and errmsg is not None:
@@ -877,10 +908,11 @@ class ServerNode(PGChildNodeView):
res = conn.connected()
if res:
from pgadmin.utils.exception import ConnectionLost
from pgadmin.utils.exception import ConnectionLost, \
SSHTunnelConnectionLost
try:
conn.execute_scalar('SELECT 1')
except ConnectionLost:
except (ConnectionLost, SSHTunnelConnectionLost):
res = False
return make_json_response(data={'connected': res})
@@ -924,28 +956,37 @@ class ServerNode(PGChildNodeView):
password = None
passfile = None
tunnel_password = None
save_password = False
# Connect the Server
manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(sid)
conn = manager.connection()
# If server using SSH Tunnel
if server.use_ssh_tunnel:
if 'tunnel_password' not in data:
return self.get_response_for_password(server, 428)
else:
tunnel_password = data['tunnel_password'] if 'tunnel_password'\
in data else None
# Encrypt the password before saving with user's login
# password key.
try:
tunnel_password = encrypt(tunnel_password, user.password) \
if tunnel_password is not None else \
server.tunnel_password
except Exception as e:
current_app.logger.exception(e)
return internal_server_error(errormsg=e.message)
if 'password' not in data:
conn_passwd = getattr(conn, 'password', None)
if conn_passwd is None and server.password is None and \
server.passfile is None and server.service is None:
# Return the password template in case password is not
# provided, or password has not been saved earlier.
return make_json_response(
success=0,
status=428,
result=render_template(
'servers/password.html',
server_label=server.name,
username=server.username,
_=gettext
)
)
return self.get_response_for_password(server, 428)
elif server.passfile and server.passfile != '':
passfile = server.passfile
else:
@@ -969,22 +1010,13 @@ class ServerNode(PGChildNodeView):
status, errmsg = conn.connect(
password=password,
passfile=passfile,
tunnel_password=tunnel_password,
server_types=ServerType.types()
)
except Exception as e:
current_app.logger.exception(e)
return make_json_response(
success=0,
status=401,
result=render_template(
'servers/password.html',
server_label=server.name,
username=server.username,
errmsg=getattr(e, 'message', str(e)),
_=gettext
)
)
return self.get_response_for_password(
server, 401, getattr(e, 'message', str(e)))
if not status:
if hasattr(str, 'decode'):
@@ -995,17 +1027,7 @@ class ServerNode(PGChildNodeView):
.format(server.id, server.name, errmsg)
)
return make_json_response(
success=0,
status=401,
result=render_template(
'servers/password.html',
server_label=server.name,
username=server.username,
errmsg=errmsg,
_=gettext
)
)
return self.get_response_for_password(server, 401, errmsg)
else:
if save_password and config.ALLOW_SAVE_PASSWORD:
try:
@@ -1376,5 +1398,34 @@ class ServerNode(PGChildNodeView):
)
return internal_server_error(errormsg=str(e))
def get_response_for_password(self, server, status, errmsg=None):
if server.use_ssh_tunnel:
return make_json_response(
success=0,
status=status,
result=render_template(
'servers/tunnel_password.html',
server_label=server.name,
username=server.username,
tunnel_username=server.tunnel_username,
tunnel_host=server.tunnel_host,
tunnel_identity_file=server.tunnel_identity_file,
errmsg=errmsg,
_=gettext
)
)
else:
return make_json_response(
success=0,
status=status,
result=render_template(
'servers/password.html',
server_label=server.name,
username=server.username,
errmsg=errmsg,
_=gettext
)
)
ServerNode.register_node_view(blueprint)

View File

@@ -669,6 +669,13 @@ define('pgadmin.node.server', [
sslrootcert: undefined,
sslcrl: undefined,
service: undefined,
use_ssh_tunnel: 0,
tunnel_host: undefined,
tunnel_port: 22,
tunnel_username: undefined,
tunnel_identity_file: undefined,
tunnel_password: undefined,
tunnel_authentication: 0,
},
// Default values!
initialize: function(attrs, args) {
@@ -695,8 +702,7 @@ define('pgadmin.node.server', [
},{
id: 'connected', label: gettext('Connected?'), type: 'switch',
mode: ['properties'], group: gettext('Connection'), 'options': {
'onText': gettext('True'), 'offText': gettext('False'), 'onColor': 'success',
'offColor': 'danger', 'size': 'small',
'onText': gettext('True'), 'offText': gettext('False'), 'size': 'small',
},
},{
id: 'version', label: gettext('Version'), type: 'text', group: null,
@@ -729,17 +735,35 @@ define('pgadmin.node.server', [
},{
id: 'password', label: gettext('Password'), type: 'password',
group: gettext('Connection'), control: 'input', mode: ['create'], deps: ['connect_now'],
visible: function(m) {
return m.get('connect_now') && m.isNew();
visible: function(model) {
return model.get('connect_now') && model.isNew();
},
},{
id: 'save_password', controlLabel: gettext('Save password?'),
type: 'checkbox', group: gettext('Connection'), mode: ['create'],
deps: ['connect_now'], visible: function(m) {
return m.get('connect_now') && m.isNew();
deps: ['connect_now', 'use_ssh_tunnel'], visible: function(model) {
return model.get('connect_now') && model.isNew();
},
disabled: function() {
return !current_user.allow_save_password;
disabled: function(model) {
if (!current_user.allow_save_password)
return true;
if (model.get('use_ssh_tunnel')) {
if (model.get('save_password')) {
Alertify.alert(
gettext('Stored Password'),
gettext('Database passwords cannot be stored when using SSH tunnelling. The \'Save password\' option has been turned off.')
);
}
setTimeout(function() {
model.set('save_password', false);
}, 10);
return true;
}
return false;
},
},{
id: 'role', label: gettext('Role'), type: 'text', group: gettext('Connection'),
@@ -782,51 +806,114 @@ define('pgadmin.node.server', [
},{
id: 'sslcompression', label: gettext('SSL compression?'), type: 'switch',
mode: ['edit', 'create'], group: gettext('SSL'),
'options': { 'onText': gettext('True'), 'offText': gettext('False'),
'onColor': 'success', 'offColor': 'danger', 'size': 'small'},
'options': {'size': 'small'},
deps: ['sslmode'], disabled: 'isSSL',
},{
id: 'sslcert', label: gettext('Client certificate'), type: 'text',
group: gettext('SSL'), mode: ['properties'],
deps: ['sslmode'],
visible: function(m) {
var sslcert = m.get('sslcert');
visible: function(model) {
var sslcert = model.get('sslcert');
return !_.isUndefined(sslcert) && !_.isNull(sslcert);
},
},{
id: 'sslkey', label: gettext('Client certificate key'), type: 'text',
group: gettext('SSL'), mode: ['properties'],
deps: ['sslmode'],
visible: function(m) {
var sslkey = m.get('sslkey');
visible: function(model) {
var sslkey = model.get('sslkey');
return !_.isUndefined(sslkey) && !_.isNull(sslkey);
},
},{
id: 'sslrootcert', label: gettext('Root certificate'), type: 'text',
group: gettext('SSL'), mode: ['properties'],
deps: ['sslmode'],
visible: function(m) {
var sslrootcert = m.get('sslrootcert');
visible: function(model) {
var sslrootcert = model.get('sslrootcert');
return !_.isUndefined(sslrootcert) && !_.isNull(sslrootcert);
},
},{
id: 'sslcrl', label: gettext('Certificate revocation list'), type: 'text',
group: gettext('SSL'), mode: ['properties'],
deps: ['sslmode'],
visible: function(m) {
var sslcrl = m.get('sslcrl');
visible: function(model) {
var sslcrl = model.get('sslcrl');
return !_.isUndefined(sslcrl) && !_.isNull(sslcrl);
},
},{
id: 'sslcompression', label: gettext('SSL compression?'), type: 'switch',
mode: ['properties'], group: gettext('SSL'),
'options': { 'onText': gettext('True'), 'offText': gettext('False'),
'onColor': 'success', 'offColor': 'danger', 'size': 'small'},
deps: ['sslmode'], visible: function(m) {
var sslmode = m.get('sslmode');
'options': {'size': 'small'},
deps: ['sslmode'], visible: function(model) {
var sslmode = model.get('sslmode');
return _.indexOf(SSL_MODES, sslmode) != -1;
},
},{
id: 'use_ssh_tunnel', label: gettext('Use SSH tunneling'), type: 'switch',
mode: ['properties', 'edit', 'create'], group: gettext('SSH Tunnel'),
'options': {'size': 'small'},
disabled: function(model) {
if (!pgAdmin.Browser.utils.support_ssh_tunnel) {
setTimeout(function() {
model.set('use_ssh_tunnel', 0);
}, 10);
return true;
}
return model.get('connected');
},
},{
id: 'tunnel_host', label: gettext('Tunnel host'), type: 'text', group: gettext('SSH Tunnel'),
mode: ['properties', 'edit', 'create'], deps: ['use_ssh_tunnel'],
disabled: function(model) {
return !model.get('use_ssh_tunnel');
},
},{
id: 'tunnel_port', label: gettext('Tunnel port'), type: 'int', group: gettext('SSH Tunnel'),
mode: ['properties', 'edit', 'create'], deps: ['use_ssh_tunnel'], max: 65535,
disabled: function(model) {
return !model.get('use_ssh_tunnel');
},
},{
id: 'tunnel_username', label: gettext('Username'), type: 'text', group: gettext('SSH Tunnel'),
mode: ['properties', 'edit', 'create'], deps: ['use_ssh_tunnel'],
disabled: function(model) {
return !model.get('use_ssh_tunnel');
},
},{
id: 'tunnel_authentication', label: gettext('Authentication'), type: 'switch',
mode: ['properties', 'edit', 'create'], group: gettext('SSH Tunnel'),
'options': {'onText': gettext('Identity file'),
'offText': gettext('Password'), 'size': 'small'},
deps: ['use_ssh_tunnel'],
disabled: function(model) {
return !model.get('use_ssh_tunnel');
},
}, {
id: 'tunnel_identity_file', label: gettext('Identity file'), type: 'text',
group: gettext('SSH Tunnel'), mode: ['edit', 'create'],
control: Backform.FileControl, dialog_type: 'select_file', supp_types: ['*'],
deps: ['tunnel_authentication', 'use_ssh_tunnel'],
disabled: function(model) {
if (!model.get('tunnel_authentication') || !model.get('use_ssh_tunnel')) {
setTimeout(function() {
model.set('tunnel_identity_file', '');
}, 10);
}
return !model.get('tunnel_authentication');
},
},{
id: 'tunnel_identity_file', label: gettext('Identity file'), type: 'text',
group: gettext('SSH Tunnel'), mode: ['properties'],
},{
id: 'tunnel_password', label: gettext('Password'), type: 'password',
group: gettext('SSH Tunnel'), control: 'input', mode: ['create'],
deps: ['use_ssh_tunnel'],
disabled: function(model) {
return !model.get('use_ssh_tunnel');
},
}, {
id: 'hostaddr', label: gettext('Host address'), type: 'text', group: gettext('Advanced'),
mode: ['properties', 'edit', 'create'], disabled: 'isConnected',
},{
@@ -841,8 +928,8 @@ define('pgadmin.node.server', [
},{
id: 'passfile', label: gettext('Password file'), type: 'text',
group: gettext('Advanced'), mode: ['properties'],
visible: function(m) {
var passfile = m.get('passfile');
visible: function(model) {
var passfile = model.get('passfile');
return !_.isUndefined(passfile) && !_.isNull(passfile);
},
},{

View File

@@ -0,0 +1,28 @@
<form name="frmPassword" id="frmPassword" style="height: 100%; width: 100%" onsubmit="return false;">
<div>{% if errmsg %}
<div class="highlight has-error">
<div class='control-label'>{{ errmsg }}</div>
</div>
{% endif %}
{% if tunnel_identity_file %}
<div><b>{{ _('SSH Tunnel password for the identity file \'{0}\' to connect the server "{1}"').format(tunnel_identity_file, tunnel_host) }}</b></div>
{% else %}
<div><b>{{ _('SSH Tunnel password for the user \'{0}\' to connect the server "{1}"').format(tunnel_username, tunnel_host) }}</b></div>
{% endif %}
<div style="padding: 5px; height: 1px;"></div>
<div style="width: 100%">
<span style="width: 97%;display: inline-block;">
<input style="width:100%" id="tunnel_password" class="form-control" name="tunnel_password" type="password">
</span>
</div>
<div style="padding: 5px; height: 1px;"></div>
<div><b>{{ _('Database server password for the user \'{0}\' to connect the server "{1}"').format(username, server_label) }}</b></div>
<div style="padding: 5px; height: 1px;"></div>
<div style="width: 100%">
<span style="width: 97%;display: inline-block;">
<input style="width:100%" id="password" class="form-control" name="password" type="password">
</span>
</div>
<div style="padding: 5px; height: 1px;"></div>
</div>
</form>

View File

@@ -0,0 +1,62 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2018, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
import json
from pgadmin.utils.route import BaseTestGenerator
from regression.python_test_utils import test_utils as utils
class ServersWithSSHTunnelAddTestCase(BaseTestGenerator):
""" This class will add the servers under default server group. """
scenarios = [
(
'Add server using SSH tunnel with password', dict(
url='/browser/server/obj/',
with_password=True
)
),
(
'Add server using SSH tunnel with identity file', dict(
url='/browser/server/obj/',
with_password=False
)
),
]
def setUp(self):
pass
def runTest(self):
""" This function will add the server under default server group."""
url = "{0}{1}/".format(self.url, utils.SERVER_GROUP)
# Add service name in the config
self.server['use_ssh_tunnel'] = 1
self.server['tunnel_host'] = '127.0.0.1'
self.server['tunnel_port'] = 22
self.server['tunnel_username'] = 'user'
if self.with_password:
self.server['tunnel_authentication'] = 0
else:
self.server['tunnel_authentication'] = 1
self.server['tunnel_identity_file'] = 'pkey_rsa'
response = self.tester.post(
url,
data=json.dumps(self.server),
content_type='html/json'
)
self.assertEquals(response.status_code, 200)
response_data = json.loads(response.data.decode('utf-8'))
self.server_id = response_data['node']['_id']
def tearDown(self):
"""This function delete the server from SQLite """
utils.delete_server_with_api(self.tester, self.server_id)