diff --git a/requirements.txt b/requirements.txt index 3cf6196..9dd9dd4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,6 +37,7 @@ python-dateutil>=2.7.1; python_version >= '2.7' Flask-HTMLmin==1.3.2; python_version >= '2.7' SQLAlchemy>=1.2.5; python_version >= '2.7' Flask-Security>=3.0.0; python_version >= '2.7' +sshtunnel>=0.1.3; python_version >= '2.7' ############################################################### # Modules specifically required for Python2.7 or lesser version diff --git a/web/config.py b/web/config.py index 3d8f6f7..93a1d2d 100644 --- a/web/config.py +++ b/web/config.py @@ -378,3 +378,11 @@ try: from config_local import * except ImportError: pass + +########################################################################## +# SSH Tunneling supports only for Python 2.7 and 3.4+ +########################################################################## +SUPPORT_SSH_TUNNEL = False +if (sys.version_info[0] == 2 and sys.version_info[1] >= 7) or \ + (sys.version_info[0] == 3 and sys.version_info[1] >= 4): + SUPPORT_SSH_TUNNEL = True diff --git a/web/migrations/versions/a68b374fe373_.py b/web/migrations/versions/a68b374fe373_.py new file mode 100644 index 0000000..5e94bb1 --- /dev/null +++ b/web/migrations/versions/a68b374fe373_.py @@ -0,0 +1,41 @@ + +"""Added columns for SSH tunneling + +Revision ID: a68b374fe373 +Revises: 50aad68f99c2 +Create Date: 2018-04-05 13:59:57.588355 + +""" +from alembic import op +import sqlalchemy as sa +from pgadmin.model import db + +# revision identifiers, used by Alembic. +revision = 'a68b374fe373' +down_revision = '50aad68f99c2' +branch_labels = None +depends_on = None + + +def upgrade(): + db.engine.execute( + 'ALTER TABLE server ADD COLUMN use_ssh_tunnel INTEGER DEFAULT 0' + ) + db.engine.execute( + 'ALTER TABLE server ADD COLUMN tunnel_host TEXT' + ) + db.engine.execute( + 'ALTER TABLE server ADD COLUMN tunnel_port TEXT' + ) + db.engine.execute( + 'ALTER TABLE server ADD COLUMN tunnel_username TEXT' + ) + db.engine.execute( + 'ALTER TABLE server ADD COLUMN tunnel_authentication INTEGER DEFAULT 0' + ) + db.engine.execute( + 'ALTER TABLE server ADD COLUMN tunnel_identity_file TEXT' + ) + +def downgrade(): + pass diff --git a/web/pgadmin/browser/__init__.py b/web/pgadmin/browser/__init__.py index 9df9dc9..3741d9d 100644 --- a/web/pgadmin/browser/__init__.py +++ b/web/pgadmin/browser/__init__.py @@ -617,7 +617,8 @@ def utils(): editor_insert_pair_brackets=insert_pair_brackets, editor_indent_with_tabs=editor_indent_with_tabs, app_name=config.APP_NAME, - pg_libpq_version=pg_libpq_version + pg_libpq_version=pg_libpq_version, + support_ssh_tunnel=config.SUPPORT_SSH_TUNNEL ), 200, {'Content-Type': 'application/x-javascript'}) diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index 01c72e5..e615f9e 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -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,18 @@ 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 +753,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 +777,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 +788,14 @@ 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 +906,10 @@ 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 +953,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 +1007,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 +1024,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 +1395,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) diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.js b/web/pgadmin/browser/server_groups/servers/static/js/server.js index 302fe45..8da71a9 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.js @@ -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, @@ -735,11 +741,29 @@ define('pgadmin.node.server', [ },{ id: 'save_password', controlLabel: gettext('Save password?'), type: 'checkbox', group: gettext('Connection'), mode: ['create'], - deps: ['connect_now'], visible: function(m) { + deps: ['connect_now', 'use_ssh_tunnel'], visible: function(m) { return m.get('connect_now') && m.isNew(); }, - disabled: function() { - return !current_user.allow_save_password; + disabled: function(m) { + if (!current_user.allow_save_password) + return true; + + if (m.get('use_ssh_tunnel')) { + if (m.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() { + m.set('save_password', false); + }, 10); + + return true; + } + + return false; }, },{ id: 'role', label: gettext('Role'), type: 'text', group: gettext('Connection'), @@ -782,8 +806,7 @@ 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', @@ -820,8 +843,7 @@ define('pgadmin.node.server', [ },{ 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'}, + 'options': {'size': 'small'}, deps: ['sslmode'], visible: function(m) { var sslmode = m.get('sslmode'); return _.indexOf(SSL_MODES, sslmode) != -1; @@ -849,6 +871,71 @@ define('pgadmin.node.server', [ id: 'service', label: gettext('Service'), type: 'text', mode: ['properties', 'edit', 'create'], disabled: 'isConnected', group: gettext('Connection'), + },{ + id: 'use_ssh_tunnel', label: gettext('Use SSH tunneling'), type: 'switch', + mode: ['properties', 'edit', 'create'], group: gettext('SSH Tunnel'), + 'options': {'size': 'small'}, + disabled: function(m) { + if (!pgAdmin.Browser.utils.support_ssh_tunnel) { + setTimeout(function() { + m.set('use_ssh_tunnel', 0); + }, 10); + + return true; + } + + return m.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(m) { + return !m.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(m) { + return !m.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(m) { + return !m.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(m) { + return !m.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(m) { + if (!m.get('tunnel_authentication') || !m.get('use_ssh_tunnel')) { + setTimeout(function() { + m.set('tunnel_identity_file', ''); + }, 10); + } + return !m.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/Passphrase'), type: 'password', + group: gettext('SSH Tunnel'), control: 'input', mode: ['create'], + deps: ['use_ssh_tunnel'], + disabled: function(m) { + return !m.get('use_ssh_tunnel'); + }, }], validate: function() { const validateModel = new modelValidation.ModelValidation(this); diff --git a/web/pgadmin/browser/server_groups/servers/templates/servers/tunnel_password.html b/web/pgadmin/browser/server_groups/servers/templates/servers/tunnel_password.html new file mode 100644 index 0000000..ed0b68b --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/templates/servers/tunnel_password.html @@ -0,0 +1,28 @@ +
diff --git a/web/pgadmin/browser/server_groups/servers/tests/test_add_server_with_ssh_tunnel.py b/web/pgadmin/browser/server_groups/servers/tests/test_add_server_with_ssh_tunnel.py new file mode 100644 index 0000000..cca9322 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/tests/test_add_server_with_ssh_tunnel.py @@ -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) diff --git a/web/pgadmin/browser/templates/browser/js/utils.js b/web/pgadmin/browser/templates/browser/js/utils.js index be3b712..79cf69a 100644 --- a/web/pgadmin/browser/templates/browser/js/utils.js +++ b/web/pgadmin/browser/templates/browser/js/utils.js @@ -26,6 +26,7 @@ define('pgadmin.browser.utils', is_indent_with_tabs: '{{ editor_indent_with_tabs }}' == 'True', app_name: '{{ app_name }}', pg_libpq_version: {{pg_libpq_version|e}}, + support_ssh_tunnel: '{{ support_ssh_tunnel }}' == 'True', counter: {total: 0, loaded: 0}, registerScripts: function (ctx) { diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index 11bc9f0..784bc1e 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -145,6 +145,23 @@ class Server(db.Model): bgcolor = db.Column(db.Text(10), nullable=True) fgcolor = db.Column(db.Text(10), nullable=True) service = db.Column(db.Text(), nullable=True) + use_ssh_tunnel = db.Column( + db.Integer(), + db.CheckConstraint('use_ssh_tunnel >= 0 AND use_ssh_tunnel <= 1'), + nullable=False + ) + tunnel_host = db.Column(db.String(128), nullable=True) + tunnel_port = db.Column( + db.Integer(), + db.CheckConstraint('port <= 65534'), + nullable=True) + tunnel_username = db.Column(db.String(64), nullable=True) + tunnel_authentication = db.Column( + db.Integer(), + db.CheckConstraint('tunnel_authentication >= 0 AND tunnel_authentication <= 1'), + nullable=False + ) + tunnel_identity_file = db.Column(db.String(64), nullable=True) class ModulePreference(db.Model): diff --git a/web/pgadmin/static/js/browser/server_groups/servers/model_validation.js b/web/pgadmin/static/js/browser/server_groups/servers/model_validation.js index f1627aa..c790eeb 100644 --- a/web/pgadmin/static/js/browser/server_groups/servers/model_validation.js +++ b/web/pgadmin/static/js/browser/server_groups/servers/model_validation.js @@ -43,6 +43,15 @@ export class ModelValidation { this.checkForEmpty('username', gettext('Username must be specified.')); this.checkForEmpty('port', gettext('Port must be specified.')); + if (this.model.get('use_ssh_tunnel')) { + this.checkForEmpty('tunnel_host', gettext('SSH Tunnel host must be specified.')); + this.checkForEmpty('tunnel_port', gettext('SSH Tunnel port must be specified.')); + this.checkForEmpty('tunnel_username', gettext('SSH Tunnel username must be specified.')); + if (this.model.get('tunnel_authentication')) { + this.checkForEmpty('tunnel_identity_file', gettext('SSH Tunnel identity file must be specified.')); + } + } + this.model.errorModel.set(this.err); if (_.size(this.err)) { diff --git a/web/pgadmin/tools/datagrid/__init__.py b/web/pgadmin/tools/datagrid/__init__.py index 9b18804..d3c3bf9 100644 --- a/web/pgadmin/tools/datagrid/__init__.py +++ b/web/pgadmin/tools/datagrid/__init__.py @@ -27,7 +27,7 @@ from config import PG_DEFAULT_DRIVER from pgadmin.utils.preferences import Preferences from pgadmin.model import Server from pgadmin.utils.driver import get_driver -from pgadmin.utils.exception import ConnectionLost +from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost from pgadmin.tools.sqleditor.utils.query_tool_preferences import \ get_query_tool_keyboard_shortcuts, get_text_representation_of_shortcut @@ -135,7 +135,7 @@ def initialize_datagrid(cmd_type, obj_type, sgid, sid, did, obj_id): auto_reconnect=False, use_binary_placeholder=True, array_to_string=True) - except ConnectionLost as e: + except (ConnectionLost, SSHTunnelConnectionLost) as e: raise except Exception as e: app.logger.error(e) @@ -363,7 +363,7 @@ def initialize_query_tool(sgid, sid, did=None): array_to_string=True) if connect: conn.connect() - except ConnectionLost as e: + except (ConnectionLost, SSHTunnelConnectionLost) as e: raise except Exception as e: app.logger.error(e) diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index fae527c..c72505a 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -34,7 +34,7 @@ from pgadmin.utils.ajax import make_json_response, bad_request, \ success_return, internal_server_error, unauthorized from pgadmin.utils.driver import get_driver from pgadmin.utils.menu import MenuItem -from pgadmin.utils.exception import ConnectionLost +from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost from pgadmin.utils.sqlautocomplete.autocomplete import SQLAutoComplete from pgadmin.tools.sqleditor.utils.query_tool_preferences import \ RegisterQueryToolPreferences @@ -166,7 +166,7 @@ def check_transaction_status(trans_id): use_binary_placeholder=True, array_to_string=True ) - except ConnectionLost as e: + except (ConnectionLost, SSHTunnelConnectionLost) as e: raise except Exception as e: current_app.logger.error(e) @@ -212,7 +212,7 @@ def start_view_data(trans_id): manager = get_driver(PG_DEFAULT_DRIVER).connection_manager( trans_obj.sid) default_conn = manager.connection(did=trans_obj.did) - except ConnectionLost as e: + except (ConnectionLost, SSHTunnelConnectionLost) as e: raise except Exception as e: current_app.logger.error(e) @@ -261,7 +261,7 @@ def start_view_data(trans_id): # Execute sql asynchronously try: status, result = conn.execute_async(sql) - except ConnectionLost as e: + except (ConnectionLost, SSHTunnelConnectionLost) as e: raise else: status = False diff --git a/web/pgadmin/tools/sqleditor/utils/start_running_query.py b/web/pgadmin/tools/sqleditor/utils/start_running_query.py index 6c09067..8c598ff 100644 --- a/web/pgadmin/tools/sqleditor/utils/start_running_query.py +++ b/web/pgadmin/tools/sqleditor/utils/start_running_query.py @@ -25,7 +25,7 @@ from pgadmin.tools.sqleditor.utils.update_session_grid_transaction import \ update_session_grid_transaction from pgadmin.utils.ajax import make_json_response, internal_server_error from pgadmin.utils.driver import get_driver -from pgadmin.utils.exception import ConnectionLost +from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost class StartRunningQuery: @@ -61,7 +61,7 @@ class StartRunningQuery: auto_reconnect=False, use_binary_placeholder=True, array_to_string=True) - except ConnectionLost: + except (ConnectionLost, SSHTunnelConnectionLost): raise except Exception as e: self.logger.error(e) @@ -127,7 +127,7 @@ class StartRunningQuery: # and formatted_error is True. try: status, result = conn.execute_async(sql) - except ConnectionLost: + except (ConnectionLost, SSHTunnelConnectionLost): raise # If the transaction aborted for some reason and diff --git a/web/pgadmin/utils/driver/psycopg2/connection.py b/web/pgadmin/utils/driver/psycopg2/connection.py index 8a05ba8..e86dcfe 100644 --- a/web/pgadmin/utils/driver/psycopg2/connection.py +++ b/web/pgadmin/utils/driver/psycopg2/connection.py @@ -215,6 +215,17 @@ class Connection(BaseConnection): encpass = kwargs['password'] if 'password' in kwargs else None passfile = kwargs['passfile'] if 'passfile' in kwargs else None + tunnel_password = kwargs['tunnel_password'] if 'tunnel_password' in kwargs else None + + # Check SSH Tunnel needs to be created + if mgr.use_ssh_tunnel == 1 and tunnel_password is not None: + status, error = mgr.create_ssh_tunnel(tunnel_password) + if not status: + return False, error + + # Check SSH Tunnel is alive or not. + if mgr.use_ssh_tunnel == 1: + mgr.check_ssh_tunnel_alive() if encpass is None: encpass = self.password or getattr(mgr, 'password', None) @@ -240,6 +251,7 @@ class Connection(BaseConnection): password = password.decode() except Exception as e: + mgr.stop_ssh_tunnel() current_app.logger.exception(e) return False, \ _( @@ -268,9 +280,9 @@ class Connection(BaseConnection): config.APP_NAME, conn_id) pg_conn = psycopg2.connect( - host=mgr.host, - hostaddr=mgr.hostaddr, - port=mgr.port, + host=mgr.local_bind_host if mgr.use_ssh_tunnel else mgr.host, + hostaddr=mgr.local_bind_host if mgr.use_ssh_tunnel else mgr.hostaddr, + port=mgr.local_bind_port if mgr.use_ssh_tunnel else mgr.port, database=database, user=user, password=password, @@ -291,6 +303,7 @@ class Connection(BaseConnection): self._wait(pg_conn) except psycopg2.Error as e: + mgr.stop_ssh_tunnel() if e.pgerror: msg = e.pgerror elif e.diag.message_detail: @@ -317,6 +330,7 @@ class Connection(BaseConnection): try: status, msg = self._initialize(conn_id, **kwargs) except Exception as e: + mgr.stop_ssh_tunnel() current_app.logger.exception(e) self.conn = None if not self.reconnecting: @@ -480,6 +494,12 @@ WHERE return True, None def __cursor(self, server_cursor=False): + + # Check SSH Tunnel is alive or not. If used by the database + # server for the connection. + if self.manager.use_ssh_tunnel == 1: + self.manager.check_ssh_tunnel_alive() + if self.wasConnected is False: raise ConnectionLost( self.manager.sid, @@ -1181,9 +1201,9 @@ WHERE try: pg_conn = psycopg2.connect( - host=mgr.host, - hostaddr=mgr.hostaddr, - port=mgr.port, + host=mgr.local_bind_host if mgr.use_ssh_tunnel else mgr.host, + hostaddr=mgr.local_bind_host if mgr.use_ssh_tunnel else mgr.hostaddr, + port=mgr.local_bind_port if mgr.use_ssh_tunnel else mgr.port, database=self.db, user=mgr.user, password=password, @@ -1456,9 +1476,12 @@ Failed to reset the connection to the server due to following error: try: pg_conn = psycopg2.connect( - host=self.manager.host, - hostaddr=self.manager.hostaddr, - port=self.manager.port, + host=self.manager.local_bind_host if + self.manager.use_ssh_tunnel else self.manager.host, + hostaddr=self.manager.local_bind_host if + self.manager.use_ssh_tunnel else self.manager.hostaddr, + port=self.manager.local_bind_port if + self.manager.use_ssh_tunnel else self.manager.port, database=self.db, user=self.manager.user, password=password, diff --git a/web/pgadmin/utils/driver/psycopg2/server_manager.py b/web/pgadmin/utils/driver/psycopg2/server_manager.py index 3066b1a..3e27bbb 100644 --- a/web/pgadmin/utils/driver/psycopg2/server_manager.py +++ b/web/pgadmin/utils/driver/psycopg2/server_manager.py @@ -12,14 +12,19 @@ Implementation of ServerManager """ import os import datetime +import config from flask import current_app, session from flask_security import current_user from flask_babelex import gettext +from pgadmin.utils import get_complete_file_path from pgadmin.utils.crypto import decrypt from .connection import Connection -from pgadmin.model import Server -from pgadmin.utils.exception import ConnectionLost +from pgadmin.model import Server, User +from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost + +if config.SUPPORT_SSH_TUNNEL: + from sshtunnel import SSHTunnelForwarder, BaseSSHTunnelForwarderError class ServerManager(object): @@ -32,6 +37,9 @@ class ServerManager(object): def __init__(self, server): self.connections = dict() + self.local_bind_host = '127.0.0.1' + self.local_bind_port = None + self.tunnel_object = None self.update(server) @@ -66,6 +74,20 @@ class ServerManager(object): self.sslcrl = server.sslcrl self.sslcompression = True if server.sslcompression else False self.service = server.service + if config.SUPPORT_SSH_TUNNEL: + self.use_ssh_tunnel = server.use_ssh_tunnel + self.tunnel_host = server.tunnel_host + self.tunnel_port = server.tunnel_port + self.tunnel_username = server.tunnel_username + self.tunnel_authentication = server.tunnel_authentication + self.tunnel_identity_file = server.tunnel_identity_file + else: + self.use_ssh_tunnel = 0 + self.tunnel_host = None + self.tunnel_port = 22 + self.tunnel_username = None + self.tunnel_authentication = None + self.tunnel_identity_file = None for con in self.connections: self.connections[con]._release() @@ -167,7 +189,11 @@ WHERE db.oid = {0}""".format(did)) )) if database is None: - raise ConnectionLost(self.sid, None, None) + # Check SSH Tunnel is alive or not. + if self.use_ssh_tunnel == 1: + self.check_ssh_tunnel_alive() + else: + raise ConnectionLost(self.sid, None, None) my_id = (u'CONN:{0}'.format(conn_id)) if conn_id is not None else \ (u'DB:{0}'.format(database)) @@ -247,6 +273,9 @@ WHERE db.oid = {0}""".format(did)) self.connections.pop(conn_info['conn_id']) def release(self, database=None, conn_id=None, did=None): + # Stop the SSH tunnel if created. + self.stop_ssh_tunnel() + if did is not None: if did in self.db_info and 'datname' in self.db_info[did]: database = self.db_info[did]['datname'] @@ -332,3 +361,71 @@ WHERE db.oid = {0}""".format(did)) self.password, current_user.password ).decode() os.environ[str(env)] = password + + def create_ssh_tunnel(self, tunnel_password): + """ + This method is used to create ssh tunnel and update the IP Address and + IP Address and port to localhost and the local bind port return by the + SSHTunnelForwarder class. + :return: True if tunnel is successfully created else error message. + """ + # Fetch Logged in User Details. + user = User.query.filter_by(id=current_user.id).first() + if user is None: + return False, gettext("Unauthorized request.") + + try: + tunnel_password = decrypt(tunnel_password, user.password) + # Handling of non ascii password (Python2) + if hasattr(str, 'decode'): + tunnel_password = tunnel_password.decode('utf-8').encode('utf-8') + # password is in bytes, for python3 we need it in string + elif isinstance(tunnel_password, bytes): + tunnel_password = tunnel_password.decode() + + except Exception as e: + current_app.logger.exception(e) + return False, "Failed to decrypt the SSH tunnel " \ + "password.\nError: {0}".format(str(e)) + + try: + # If authentication method is 1 then it uses identity file and password + if self.tunnel_authentication == 1: + self.tunnel_object = SSHTunnelForwarder( + self.tunnel_host, + ssh_username=self.tunnel_username, + ssh_pkey=get_complete_file_path(self.tunnel_identity_file), + ssh_private_key_password=tunnel_password, + remote_bind_address=(self.host, self.port) + ) + else: + self.tunnel_object = SSHTunnelForwarder( + self.tunnel_host, + ssh_username=self.tunnel_username, + ssh_password=tunnel_password, + remote_bind_address=(self.host, self.port) + ) + + self.tunnel_object.start() + except BaseSSHTunnelForwarderError as e: + current_app.logger.exception(e) + return False, "Failed to create the SSH tunnel." \ + "\nError: {0}".format(str(e)) + + # Update the port to communicate locally + self.local_bind_port = self.tunnel_object.local_bind_port + + return True, None + + def check_ssh_tunnel_alive(self): + # Check SSH Tunnel is alive or not. if it is not then + # raise the ConnectionLost exception. + if self.tunnel_object is None or not self.tunnel_object.is_active: + raise SSHTunnelConnectionLost(self.tunnel_host) + + def stop_ssh_tunnel(self): + # Stop the SSH tunnel if created. + if self.tunnel_object and self.tunnel_object.is_active: + self.tunnel_object.stop() + self.local_bind_port = None + self.tunnel_object = None diff --git a/web/pgadmin/utils/exception.py b/web/pgadmin/utils/exception.py index 5fe2e92..62b7068 100644 --- a/web/pgadmin/utils/exception.py +++ b/web/pgadmin/utils/exception.py @@ -48,3 +48,35 @@ class ConnectionLost(HTTPException): def __repr__(self): return "Connection (id #{2}) lost for the server (#{0}) on " \ "database ({1})".format(self.sid, self.db, self.conn_id) + + +class SSHTunnelConnectionLost(HTTPException): + """ + Exception when connection to SSH tunnel is lost + """ + + def __init__(self, _tunnel_host): + self.tunnel_host = _tunnel_host + HTTPException.__init__(self) + + @property + def name(self): + return HTTP_STATUS_CODES.get(503, 'Service Unavailable') + + def get_response(self, environ=None): + return service_unavailable( + _("Connection to the SSH Tunnel for host '{0}' has been lost. " + "Reconnect to the database server.").format(self.tunnel_host), + info="SSH_TUNNEL_CONNECTION_LOST", + data={ + 'tunnel_host': self.tunnel_host + } + ) + + def __str__(self): + return "Connection to the SSH Tunnel for host '{0}' has been lost. " \ + "Reconnect to the database server".format(self.tunnel_host) + + def __repr__(self): + return "Connection to the SSH Tunnel for host '{0}' has been lost. " \ + "Reconnect to the database server".format(self.tunnel_host) diff --git a/web/regression/javascript/browser/server_groups/servers/model_validation_spec.js b/web/regression/javascript/browser/server_groups/servers/model_validation_spec.js index 85e51eb..57b8cf1 100644 --- a/web/regression/javascript/browser/server_groups/servers/model_validation_spec.js +++ b/web/regression/javascript/browser/server_groups/servers/model_validation_spec.js @@ -67,7 +67,52 @@ describe('Server#ModelValidation', () => { }); }); - + describe('SSH Tunnel parameters', () => { + beforeEach(() => { + model.isNew.and.returnValue(true); + model.allValues['name'] = 'some name'; + model.allValues['username'] = 'some username'; + model.allValues['port'] = 12345; + model.allValues['host'] = 'some host'; + model.allValues['db'] = 'some db'; + model.allValues['hostaddr'] = '1.1.1.1'; + model.allValues['use_ssh_tunnel'] = 1; + }); + it('sets the "SSH Tunnel host must be specified." error', () => { + model.allValues['tunnel_port'] = 22; + model.allValues['tunnel_username'] = 'user1'; + expect(modelValidation.validate()).toBe('SSH Tunnel host must be specified.'); + expect(model.errorModel.set).toHaveBeenCalledWith({ + tunnel_host:'SSH Tunnel host must be specified.' + }); + }); + it('sets the "SSH Tunnel port must be specified." error', () => { + model.allValues['tunnel_host'] = 'host'; + model.allValues['tunnel_username'] = 'user1'; + expect(modelValidation.validate()).toBe('SSH Tunnel port must be specified.'); + expect(model.errorModel.set).toHaveBeenCalledWith({ + tunnel_port:'SSH Tunnel port must be specified.' + }); + }); + it('sets the "SSH Tunnel username be specified." error', () => { + model.allValues['tunnel_host'] = 'host'; + model.allValues['tunnel_port'] = 22; + expect(modelValidation.validate()).toBe('SSH Tunnel username must be specified.'); + expect(model.errorModel.set).toHaveBeenCalledWith({ + tunnel_username:'SSH Tunnel username must be specified.' + }); + }); + it('sets the "SSH Tunnel identity file be specified." error', () => { + model.allValues['tunnel_host'] = 'host'; + model.allValues['tunnel_port'] = 22; + model.allValues['tunnel_username'] = 'user1'; + model.allValues['tunnel_authentication'] = 1; + expect(modelValidation.validate()).toBe('SSH Tunnel identity file must be specified.'); + expect(model.errorModel.set).toHaveBeenCalledWith({ + tunnel_identity_file:'SSH Tunnel identity file must be specified.' + }); + }); + }); }); describe('When no parameters are valid', () => {