diff --git a/docs/en_US/images/server_advanced.png b/docs/en_US/images/server_advanced.png old mode 100644 new mode 100755 index 10f1809a3..bb35d7b22 Binary files a/docs/en_US/images/server_advanced.png and b/docs/en_US/images/server_advanced.png differ diff --git a/docs/en_US/images/server_connection.png b/docs/en_US/images/server_connection.png old mode 100644 new mode 100755 index dd18376e5..813bbbfb5 Binary files a/docs/en_US/images/server_connection.png and b/docs/en_US/images/server_connection.png differ diff --git a/docs/en_US/images/server_general.png b/docs/en_US/images/server_general.png old mode 100644 new mode 100755 index 6e26bb31f..f4847c550 Binary files a/docs/en_US/images/server_general.png and b/docs/en_US/images/server_general.png differ diff --git a/docs/en_US/images/server_ssh_tunnel.png b/docs/en_US/images/server_ssh_tunnel.png new file mode 100644 index 000000000..059bfd4d8 Binary files /dev/null and b/docs/en_US/images/server_ssh_tunnel.png differ diff --git a/docs/en_US/images/server_ssl.png b/docs/en_US/images/server_ssl.png old mode 100644 new mode 100755 index 0b9cbcb6b..c0bcb95c5 Binary files a/docs/en_US/images/server_ssl.png and b/docs/en_US/images/server_ssl.png differ diff --git a/docs/en_US/release_notes_3_1.rst b/docs/en_US/release_notes_3_1.rst index 527277aba..57cbe4970 100644 --- a/docs/en_US/release_notes_3_1.rst +++ b/docs/en_US/release_notes_3_1.rst @@ -10,6 +10,7 @@ This release contains a number of features and fixes reported since the release Features ******** +| `Bug #1447 `_ - Add support for SSH tunneled connections Bug fixes ********* diff --git a/docs/en_US/server_dialog.rst b/docs/en_US/server_dialog.rst index b1159c598..ad95497e3 100644 --- a/docs/en_US/server_dialog.rst +++ b/docs/en_US/server_dialog.rst @@ -53,6 +53,28 @@ If pgAdmin is installed in Server mode (the default mode), you can use the platf *WARNING:* In Server mode, certificates, private keys, and the revocation list are stored in the per-user file storage area on the server, which is owned by the user account under which the pgAdmin server process is run. This means that administrators of the server may be able to access those files; appropriate caution should be taken before choosing to use this feature. +Click the *SSH Tunnel* tab to continue. + +.. image:: images/server_ssh_tunnel.png + :alt: Server dialog ssh tunnel tab + +Use the fields in the *SSH Tunnel* tab to configure SSH Tunneling: + +You can use the "SSH Tunnel" tab to connect pgAdmin (through an intermediary +proxy host) to a server that resides on a network to which the client may +not be able to connect directly. + +* Set "Use SSH tunneling" to *Yes* to specify that pgAdmin should use an SSH tunnel when connecting to the specified server. +* Specify the name or IP address of the SSH host (through which client connections will be forwarded) in the *Tunnel host* field. +* Specify the port of the SSH host (through which client connections will be forwarded) in the *Tunnel port* field. +* Specify the name of a user with login privileges for the SSH host in the *Username* field. +* Specify the type of authentication that will be used when connecting to the SSH host in the *Authentication* field. + + * Select the *Password* option to specify that pgAdmin will use a password for authentication to the SSH host. This is the default. + * Select the *Identity file* to specify that pgAdmin will use a private key file when connecting. +* If the SSH host is expecting a private key file for authentication, use the *Identity file* field to specify the location of the key file. +* If the SSH host is expecting a password, use the *Password/Passphrase* field to specify the password, or if an identity file is being used, the passphrase. + Click the *Advanced* tab to continue. .. image:: images/server_advanced.png diff --git a/requirements.txt b/requirements.txt index 9b6b22866..38646fbb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,6 +38,7 @@ htmlmin==0.1.12; 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 3d8f6f7df..614314699 100644 --- a/web/config.py +++ b/web/config.py @@ -378,3 +378,13 @@ 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 000000000..5e94bb1e2 --- /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 9df9dc98b..3741d9d32 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 01c72e5c8..625d8dabd 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,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) 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 375b0b633..6ee037b55 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, @@ -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); }, },{ 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 000000000..ed0b68bae --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/templates/servers/tunnel_password.html @@ -0,0 +1,28 @@ +
+
{% if errmsg %} +
+
{{ errmsg }}
+
+ {% endif %} + {% if tunnel_identity_file %} +
{{ _('SSH Tunnel password for the identity file \'{0}\' to connect the server "{1}"').format(tunnel_identity_file, tunnel_host) }}
+ {% else %} +
{{ _('SSH Tunnel password for the user \'{0}\' to connect the server "{1}"').format(tunnel_username, tunnel_host) }}
+ {% endif %} +
+
+ + + +
+
+
{{ _('Database server password for the user \'{0}\' to connect the server "{1}"').format(username, server_label) }}
+
+
+ + + +
+
+
+
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 000000000..cca9322b1 --- /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 be3b7122a..79cf69aa8 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 11bc9f05e..c3171ce5b 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -145,6 +145,24 @@ 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 f1627aacd..c790eeb22 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 9b188042a..d3c3bf9c1 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 fae527c7d..c72505a40 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 6c090674d..8c598ffd4 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/tools/sqleditor/utils/tests/test_start_running_query.py b/web/pgadmin/tools/sqleditor/utils/tests/test_start_running_query.py index 4ad0891af..c8391353d 100644 --- a/web/pgadmin/tools/sqleditor/utils/tests/test_start_running_query.py +++ b/web/pgadmin/tools/sqleditor/utils/tests/test_start_running_query.py @@ -12,7 +12,7 @@ from flask import Response import simplejson as json from pgadmin.tools.sqleditor.utils.start_running_query import StartRunningQuery -from pgadmin.utils.exception import ConnectionLost +from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost from pgadmin.utils.route import BaseTestGenerator if sys.version_info < (3, 3): @@ -176,6 +176,35 @@ class StartRunningQueryTest(BaseTestGenerator): is_rollback_required=False, apply_explain_plan_wrapper_if_needed_return_value='some sql', + expect_make_json_response_to_have_been_called_with=None, + expect_internal_server_error_called_with=None, + expected_logger_error=None, + expect_execute_void_called_with='some sql', + )), + ('When SSHTunnelConnectionLost happens while retrieving the ' + 'database connection, ' + 'it returns an error', + dict( + function_parameters=dict( + sql=dict(sql='some sql', explain_plan=None), + trans_id=123, + http_session=dict(gridData={'123': dict(command_obj='')}) + ), + pickle_load_return=MagicMock( + conn_id=1, + update_fetched_row_cnt=MagicMock() + ), + get_driver_exception=False, + get_connection_lost_exception=False, + manager_connection_exception=SSHTunnelConnectionLost('1.1.1.1'), + + is_connected_to_server=False, + connection_connect_return=None, + execute_async_return_value=None, + is_begin_required=False, + is_rollback_required=False, + apply_explain_plan_wrapper_if_needed_return_value='some sql', + expect_make_json_response_to_have_been_called_with=None, expect_internal_server_error_called_with=None, expected_logger_error=None, diff --git a/web/pgadmin/utils/driver/psycopg2/connection.py b/web/pgadmin/utils/driver/psycopg2/connection.py index 8a05ba844..315631c00 100644 --- a/web/pgadmin/utils/driver/psycopg2/connection.py +++ b/web/pgadmin/utils/driver/psycopg2/connection.py @@ -211,13 +211,25 @@ class Connection(BaseConnection): pg_conn = None password = None passfile = None - mgr = self.manager + manager = self.manager 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 manager.use_ssh_tunnel == 1 and tunnel_password is not None: + status, error = manager.create_ssh_tunnel(tunnel_password) + if not status: + return False, error + + # Check SSH Tunnel is alive or not. + if manager.use_ssh_tunnel == 1: + manager.check_ssh_tunnel_alive() if encpass is None: - encpass = self.password or getattr(mgr, 'password', None) + encpass = self.password or getattr(manager, 'password', None) # Reset the existing connection password if self.reconnecting is not False: @@ -240,6 +252,7 @@ class Connection(BaseConnection): password = password.decode() except Exception as e: + manager.stop_ssh_tunnel() current_app.logger.exception(e) return False, \ _( @@ -251,16 +264,16 @@ class Connection(BaseConnection): # we will check for pgpass file availability from connection manager # if it's present then we will use it if not password and not encpass and not passfile: - passfile = mgr.passfile if mgr.passfile else None + passfile = manager.passfile if manager.passfile else None try: if hasattr(str, 'decode'): database = self.db.encode('utf-8') - user = mgr.user.encode('utf-8') + user = manager.user.encode('utf-8') conn_id = self.conn_id.encode('utf-8') else: database = self.db - user = mgr.user + user = manager.user conn_id = self.conn_id import os @@ -268,21 +281,24 @@ class Connection(BaseConnection): config.APP_NAME, conn_id) pg_conn = psycopg2.connect( - host=mgr.host, - hostaddr=mgr.hostaddr, - port=mgr.port, + host=manager.local_bind_host if manager.use_ssh_tunnel + else manager.host, + hostaddr=manager.local_bind_host if manager.use_ssh_tunnel + else manager.hostaddr, + port=manager.local_bind_port if manager.use_ssh_tunnel + else manager.port, database=database, user=user, password=password, async=self.async, passfile=get_complete_file_path(passfile), - sslmode=mgr.ssl_mode, - sslcert=get_complete_file_path(mgr.sslcert), - sslkey=get_complete_file_path(mgr.sslkey), - sslrootcert=get_complete_file_path(mgr.sslrootcert), - sslcrl=get_complete_file_path(mgr.sslcrl), - sslcompression=True if mgr.sslcompression else False, - service=mgr.service + sslmode=manager.ssl_mode, + sslcert=get_complete_file_path(manager.sslcert), + sslkey=get_complete_file_path(manager.sslkey), + sslrootcert=get_complete_file_path(manager.sslrootcert), + sslcrl=get_complete_file_path(manager.sslcrl), + sslcompression=True if manager.sslcompression else False, + service=manager.service ) # If connection is asynchronous then we will have to wait @@ -291,6 +307,7 @@ class Connection(BaseConnection): self._wait(pg_conn) except psycopg2.Error as e: + manager.stop_ssh_tunnel() if e.pgerror: msg = e.pgerror elif e.diag.message_detail: @@ -317,6 +334,7 @@ class Connection(BaseConnection): try: status, msg = self._initialize(conn_id, **kwargs) except Exception as e: + manager.stop_ssh_tunnel() current_app.logger.exception(e) self.conn = None if not self.reconnecting: @@ -324,7 +342,7 @@ class Connection(BaseConnection): raise e if status: - mgr._update_password(encpass) + manager._update_password(encpass) else: if not self.reconnecting: self.wasConnected = False @@ -342,7 +360,7 @@ class Connection(BaseConnection): status, cur = self.__cursor() formatted_exception_msg = self._formatted_exception_msg - mgr = self.manager + manager = self.manager def _execute(cur, query, params=None): try: @@ -381,8 +399,8 @@ class Connection(BaseConnection): return False, status - if mgr.role: - status = _execute(cur, u"SET ROLE TO %s", [mgr.role]) + if manager.role: + status = _execute(cur, u"SET ROLE TO %s", [manager.role]) if status is not None: self.conn.close() @@ -401,7 +419,7 @@ class Connection(BaseConnection): "Failed to setup the role with error message:\n{0}" ).format(status) - if mgr.ver is None: + if manager.ver is None: status = _execute(cur, "SELECT version()") if status is not None: @@ -421,8 +439,8 @@ class Connection(BaseConnection): if cur.rowcount > 0: row = cur.fetchmany(1)[0] - mgr.ver = row['version'] - mgr.sversion = self.conn.server_version + manager.ver = row['version'] + manager.sversion = self.conn.server_version status = _execute(cur, """ SELECT @@ -434,14 +452,14 @@ FROM WHERE db.datname = current_database()""") if status is None: - mgr.db_info = mgr.db_info or dict() + manager.db_info = manager.db_info or dict() if cur.rowcount > 0: res = cur.fetchmany(1)[0] - mgr.db_info[res['did']] = res.copy() + manager.db_info[res['did']] = res.copy() # We do not have database oid for the maintenance database. - if len(mgr.db_info) == 1: - mgr.did = res['did'] + if len(manager.db_info) == 1: + manager.did = res['did'] status = _execute(cur, """ SELECT @@ -453,33 +471,39 @@ WHERE rolname = current_user""") if status is None: - mgr.user_info = dict() + manager.user_info = dict() if cur.rowcount > 0: - mgr.user_info = cur.fetchmany(1)[0] + manager.user_info = cur.fetchmany(1)[0] if 'password' in kwargs: - mgr.password = kwargs['password'] + manager.password = kwargs['password'] server_types = None if 'server_types' in kwargs and isinstance( kwargs['server_types'], list): - server_types = mgr.server_types = kwargs['server_types'] + server_types = manager.server_types = kwargs['server_types'] if server_types is None: from pgadmin.browser.server_groups.servers.types import ServerType server_types = ServerType.types() for st in server_types: - if st.instanceOf(mgr.ver): - mgr.server_type = st.stype - mgr.server_cls = st + if st.instanceOf(manager.ver): + manager.server_type = st.stype + manager.server_cls = st break - mgr.update_session() + manager.update_session() 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, @@ -1166,9 +1190,9 @@ WHERE if self.conn.closed: self.conn = None pg_conn = None - mgr = self.manager + manager = self.manager - password = getattr(mgr, 'password', None) + password = getattr(manager, 'password', None) if password: # Fetch Logged in User Details. @@ -1181,20 +1205,23 @@ WHERE try: pg_conn = psycopg2.connect( - host=mgr.host, - hostaddr=mgr.hostaddr, - port=mgr.port, + host=manager.local_bind_host if manager.use_ssh_tunnel + else manager.host, + hostaddr=manager.local_bind_host if manager.use_ssh_tunnel + else manager.hostaddr, + port=manager.local_bind_port if manager.use_ssh_tunnel + else manager.port, database=self.db, - user=mgr.user, + user=manager.user, password=password, - passfile=get_complete_file_path(mgr.passfile), - sslmode=mgr.ssl_mode, - sslcert=get_complete_file_path(mgr.sslcert), - sslkey=get_complete_file_path(mgr.sslkey), - sslrootcert=get_complete_file_path(mgr.sslrootcert), - sslcrl=get_complete_file_path(mgr.sslcrl), - sslcompression=True if mgr.sslcompression else False, - service=mgr.service + passfile=get_complete_file_path(manager.passfile), + sslmode=manager.ssl_mode, + sslcert=get_complete_file_path(manager.sslcert), + sslkey=get_complete_file_path(manager.sslkey), + sslrootcert=get_complete_file_path(manager.sslrootcert), + sslcrl=get_complete_file_path(manager.sslcrl), + sslcompression=True if manager.sslcompression else False, + service=manager.service ) except psycopg2.Error as e: @@ -1456,9 +1483,13 @@ 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 3066b1af4..30a7a7542 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,73 @@ 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 5fe2e9297..62b7068ea 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 990507427..ff6440455 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', () => {