mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2025-02-25 18:55:31 -06:00
Add support for SSH tunneled connections. Fixes #1447
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},{
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user