diff --git a/docs/en_US/images/server_ssh_tunnel.png b/docs/en_US/images/server_ssh_tunnel.png index 71e9e806e..b5964e8a8 100644 Binary files a/docs/en_US/images/server_ssh_tunnel.png and b/docs/en_US/images/server_ssh_tunnel.png differ diff --git a/docs/en_US/release_notes_9_9.rst b/docs/en_US/release_notes_9_9.rst index 175c10ec1..cfc32ef24 100644 --- a/docs/en_US/release_notes_9_9.rst +++ b/docs/en_US/release_notes_9_9.rst @@ -8,7 +8,7 @@ This release contains a number of bug fixes and new features since the release o Supported Database Servers ************************** -**PostgreSQL**: 13, 14, 15, 16 and 17 +**PostgreSQL**: 13, 14, 15, 16, 17 and 18 **EDB Advanced Server**: 13, 14, 15, 16 and 17 @@ -20,8 +20,10 @@ Bundled PostgreSQL Utilities New features ************ + | `Issue #6385 `_ - Add support of DEPENDS/NO DEPENDS ON EXTENSION for ALTER FUNCTION. | `Issue #6394 `_ - Added "MULTIRANGE_TYPE_NAME" option while creating a Range Type. | `Issue #6395 `_ - Added "SUBSCRIPT" option while creating a External Type. + | `Issue #6996 `_ - Added option to skip the password dialog when using an identity file. | `Issue #8932 `_ - Added 'failover' and 'two_phase' parameter support in CREATE/ALTER SUBSCRIPTION for PostgreSQL v17+. Housekeeping diff --git a/docs/en_US/server_dialog.rst b/docs/en_US/server_dialog.rst index c9db6df7c..233b438d0 100644 --- a/docs/en_US/server_dialog.rst +++ b/docs/en_US/server_dialog.rst @@ -177,6 +177,9 @@ not be able to connect directly. *Identity file* field to specify the location of the key file. * If the SSH host is expecting a password of the user name or an identity file if being used, use the *Password* field to specify the password. +* Check the box next to *Prompt for password?* to to have pgAdmin prompt for + a password if the identity file includes one. This setting applies only when + using an identity file, which may or may not require a password. * Check the box next to *Save password?* to instruct pgAdmin to save the password for future use. Use :ref:`Clear SSH Tunnel Password ` to remove the saved diff --git a/web/migrations/versions/efbbe5d5862f_.py b/web/migrations/versions/efbbe5d5862f_.py new file mode 100644 index 000000000..a4f078303 --- /dev/null +++ b/web/migrations/versions/efbbe5d5862f_.py @@ -0,0 +1,42 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +""" +Revision ID: efbbe5d5862f +Revises: e6ed5dac37c2 +Create Date: 2025-09-29 18:40:30.248908 + +""" +from alembic import op, context +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'efbbe5d5862f' +down_revision = 'e6ed5dac37c2' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table( + "server", + table_kwargs={'sqlite_autoincrement': True}) as batch_op: + batch_op.add_column(sa.Column('tunnel_prompt_password', + sa.Integer(), server_default='0')) + with op.batch_alter_table( + "sharedserver", + table_kwargs={'sqlite_autoincrement': True}) as batch_op: + batch_op.add_column(sa.Column('tunnel_prompt_password', + sa.Integer(), server_default='0')) + + +def downgrade(): + # pgAdmin only upgrades, downgrade not implemented. + pass diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index 1f00620cd..369e3e248 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -167,6 +167,7 @@ class ServerModule(sg.ServerGroupPluginModule): server.tunnel_password = sharedserver.tunnel_password server.save_password = sharedserver.save_password server.tunnel_identity_file = sharedserver.tunnel_identity_file + server.tunnel_prompt_password = sharedserver.tunnel_prompt_password if hasattr(server, 'connection_params') and \ hasattr(sharedserver, 'connection_params') and \ 'passfile' in server.connection_params and \ @@ -413,6 +414,7 @@ class ServerModule(sg.ServerGroupPluginModule): tunnel_authentication=0, tunnel_identity_file=None, tunnel_keep_alive=0, + tunnel_prompt_password=0, shared=True, connection_params=data.connection_params, prepare_threshold=data.prepare_threshold @@ -790,6 +792,7 @@ class ServerNode(PGChildNodeView): 'tunnel_username': 'tunnel_username', 'tunnel_authentication': 'tunnel_authentication', 'tunnel_identity_file': 'tunnel_identity_file', + 'tunnel_prompt_password': 'tunnel_prompt_password', 'tunnel_keep_alive': 'tunnel_keep_alive', 'shared': 'shared', 'shared_username': 'shared_username', @@ -1086,6 +1089,8 @@ class ServerNode(PGChildNodeView): 'tunnel_username': tunnel_username, 'tunnel_identity_file': server.tunnel_identity_file if server.tunnel_identity_file else None, + 'tunnel_prompt_password': server.tunnel_prompt_password + if server.tunnel_identity_file else 0, 'tunnel_authentication': tunnel_authentication, 'tunnel_keep_alive': tunnel_keep_alive, 'kerberos_conn': bool(server.kerberos_conn), @@ -1212,6 +1217,8 @@ class ServerNode(PGChildNodeView): tunnel_authentication=1 if data.get('tunnel_authentication', False) else 0, tunnel_identity_file=data.get('tunnel_identity_file', None), + tunnel_prompt_password=1 if data.get('tunnel_prompt_password', + True) else 0, tunnel_keep_alive=data.get('tunnel_keep_alive', 0), shared=data.get('shared', None), shared_username=data.get('shared_username', None), @@ -1419,6 +1426,19 @@ class ServerNode(PGChildNodeView): } ) + def is_prompt_tunnel_password(self, server): + """ + This function will check whether to prompt tunnel password or not. + """ + prompt_tunnel_password = True + # In case of identity file check the value of tunnel_prompt_password. + if server.tunnel_password is not None or \ + (server.tunnel_identity_file is not None and + server.tunnel_prompt_password != 1): + prompt_tunnel_password = False + + return prompt_tunnel_password + def connect(self, gid, sid, is_qt=False, server=None): """ Connect the Server and return the connection object. @@ -1502,11 +1522,12 @@ class ServerNode(PGChildNodeView): # If server using SSH Tunnel if server.use_ssh_tunnel: - if 'tunnel_password' not in data: - if server.tunnel_password is None: - prompt_tunnel_password = True - else: - tunnel_password = server.tunnel_password + if 'tunnel_password' not in data and \ + server.tunnel_password is None: + prompt_tunnel_password = self.is_prompt_tunnel_password(server) + elif 'tunnel_password' not in data and \ + server.tunnel_password is not None: + tunnel_password = server.tunnel_password else: tunnel_password = data['tunnel_password'] \ if 'tunnel_password' in data else '' @@ -1562,6 +1583,10 @@ class ServerNode(PGChildNodeView): return self.get_response_for_password( server, 428, prompt_password, prompt_tunnel_password) + # Check whether to prompt for the tunnel password in case if + # password is saved in server object or in data. + prompt_tunnel_password = self.is_prompt_tunnel_password(server) + try: status, errmsg = conn.connect( password=password, @@ -1571,7 +1596,7 @@ class ServerNode(PGChildNodeView): ) except Exception as e: return self.get_response_for_password( - server, 401, True, True, + server, 401, not server.save_password, prompt_tunnel_password, getattr(e, 'message', str(e))) if not status: @@ -1583,7 +1608,8 @@ class ServerNode(PGChildNodeView): return internal_server_error(errmsg) return self.get_response_for_password( - server, 401, True, True, errmsg) + server, 401, not server.save_password, + prompt_tunnel_password, errmsg) else: if save_password and config.ALLOW_SAVE_PASSWORD: try: diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js index d192c8037..97a7311af 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js @@ -211,6 +211,7 @@ export default class ServerSchema extends BaseUISchema { tunnel_port: 22, tunnel_username: undefined, tunnel_identity_file: undefined, + tunnel_prompt_password: false, tunnel_password: undefined, tunnel_authentication: false, tunnel_keep_alive: 0, @@ -496,7 +497,22 @@ export default class ServerSchema extends BaseUISchema { maxLength: null }, readonly: obj.isConnected, - }, { + }, + { + id: 'tunnel_prompt_password', label: gettext('Prompt for password?'), + type: 'switch', group: gettext('SSH Tunnel'), mode: ['properties', 'edit', 'create'], + deps: ['tunnel_authentication', 'use_ssh_tunnel'], + depChange: (state)=>{ + if (!state.tunnel_authentication) { + return {tunnel_prompt_password: false}; + } + }, + disabled: function(state) { + return !state.tunnel_authentication || !state.use_ssh_tunnel; + }, + helpMessage: gettext('This setting applies only when using an identity file. An identity file may or may not have a password. If set to true the system will prompt for the password.') + }, + { id: 'save_tunnel_password', label: gettext('Save password?'), type: 'switch', group: gettext('SSH Tunnel'), mode: ['create'], deps: ['connect_now', 'use_ssh_tunnel'], diff --git a/web/pgadmin/browser/server_groups/servers/tests/servers_test_data.json b/web/pgadmin/browser/server_groups/servers/tests/servers_test_data.json index b1a47a309..a5a432c7f 100644 --- a/web/pgadmin/browser/server_groups/servers/tests/servers_test_data.json +++ b/web/pgadmin/browser/server_groups/servers/tests/servers_test_data.json @@ -633,6 +633,7 @@ "tunnel_password": "user123", "tunnel_identity_file": "pkey_rsa", "tunnel_keep_alive": 0, + "tunnel_prompt_password": 0, "service": null, "server_info": { "id": 1, diff --git a/web/pgadmin/browser/server_groups/servers/tests/test_check_ssh_mock_connect.py b/web/pgadmin/browser/server_groups/servers/tests/test_check_ssh_mock_connect.py index f6e376838..8e19f9758 100644 --- a/web/pgadmin/browser/server_groups/servers/tests/test_check_ssh_mock_connect.py +++ b/web/pgadmin/browser/server_groups/servers/tests/test_check_ssh_mock_connect.py @@ -77,6 +77,7 @@ class ServersSSHConnectTestCase(BaseTestGenerator): self.service = service self.save_password = 0 self.shared = None + self.tunnel_prompt_password = 0 mock_server_obj = TestMockServer( self.mock_data['name'], diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index 323412fdd..fcc55268c 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -33,7 +33,7 @@ import config # ########################################################################## -SCHEMA_VERSION = 47 +SCHEMA_VERSION = 48 ########################################################################## # @@ -246,6 +246,11 @@ class Server(db.Model): nullable=False ) tunnel_identity_file = db.Column(db.String(64), nullable=True) + tunnel_prompt_password = db.Column( + db.Integer(), db.CheckConstraint( + 'tunnel_prompt_password >= 0 AND tunnel_prompt_password <= 1'), + nullable=False + ) tunnel_password = db.Column(PgAdminDbBinaryString()) tunnel_keep_alive = db.Column(db.Integer(), nullable=True, default=0) shared = db.Column(db.Boolean(), nullable=False) @@ -483,6 +488,11 @@ class SharedServer(db.Model): nullable=False ) tunnel_identity_file = db.Column(db.String(64), nullable=True) + tunnel_prompt_password = db.Column( + db.Integer(), db.CheckConstraint( + 'tunnel_prompt_password >= 0 AND tunnel_prompt_password <= 1'), + nullable=False + ) tunnel_password = db.Column(PgAdminDbBinaryString()) tunnel_keep_alive = db.Column(db.Integer(), nullable=True) shared = db.Column(db.Boolean(), nullable=False) diff --git a/web/pgadmin/setup/tests/servers.json b/web/pgadmin/setup/tests/servers.json index a6b715e8c..07ac58449 100644 --- a/web/pgadmin/setup/tests/servers.json +++ b/web/pgadmin/setup/tests/servers.json @@ -36,6 +36,7 @@ "TunnelPort": "22", "TunnelUsername": "username", "TunnelAuthentication": 0, + "TunnelPromptPassword": 0, "PasswordExecCommand": "echo 'test'", "PasswordExecExpiration": 100 } diff --git a/web/pgadmin/static/js/Dialogs/ConnectServerContent.jsx b/web/pgadmin/static/js/Dialogs/ConnectServerContent.jsx index 9696959cf..cb17144eb 100644 --- a/web/pgadmin/static/js/Dialogs/ConnectServerContent.jsx +++ b/web/pgadmin/static/js/Dialogs/ConnectServerContent.jsx @@ -109,21 +109,23 @@ export default function ConnectServerContent({closeModal, data, onOK, setHeight, } onClick={()=>{ closeModal(); }} >{gettext('Cancel')} - } onClick={()=>{ - let postFormData = new FormData(); - if(data.prompt_tunnel_password) { - postFormData.append('tunnel_password', formData.tunnel_password); - formData.save_tunnel_password && - postFormData.append('save_tunnel_password', formData.save_tunnel_password); - } - if(data.prompt_password) { - postFormData.append('password', formData.password); - formData.save_password && - postFormData.append('save_password', formData.save_password); - } - onOK?.(postFormData); - closeModal(); - }} >{gettext('OK')} + {(data.prompt_password || data.prompt_tunnel_password) && <> + } onClick={()=>{ + let postFormData = new FormData(); + if(data.prompt_tunnel_password) { + postFormData.append('tunnel_password', formData.tunnel_password); + formData.save_tunnel_password && + postFormData.append('save_tunnel_password', formData.save_tunnel_password); + } + if(data.prompt_password) { + postFormData.append('password', formData.password); + formData.save_password && + postFormData.append('save_password', formData.save_password); + } + onOK?.(postFormData); + closeModal(); + }} >{gettext('OK')} + } ); diff --git a/web/pgadmin/utils/__init__.py b/web/pgadmin/utils/__init__.py index a66055e3f..b2b17d4c5 100644 --- a/web/pgadmin/utils/__init__.py +++ b/web/pgadmin/utils/__init__.py @@ -525,6 +525,8 @@ def dump_database_servers(output_file, selected_servers, server.tunnel_authentication) add_value(attr_dict, "TunnelIdentityFile", server.tunnel_identity_file) + add_value(attr_dict, "TunnelPromptPassword", + server.tunnel_prompt_password) add_value(attr_dict, "TunnelKeepAlive", server.tunnel_keep_alive) add_value(attr_dict, "KerberosAuthentication", @@ -773,6 +775,9 @@ def load_database_servers(input_file, selected_servers, new_server.tunnel_identity_file = \ obj.get("TunnelIdentityFile", None) + new_server.tunnel_prompt_password = \ + obj.get("TunnelPromptPassword", 0) + new_server.tunnel_keep_alive = \ obj.get("TunnelKeepAlive", None) diff --git a/web/pgadmin/utils/driver/psycopg3/server_manager.py b/web/pgadmin/utils/driver/psycopg3/server_manager.py index 9505a6b76..561141b5d 100644 --- a/web/pgadmin/utils/driver/psycopg3/server_manager.py +++ b/web/pgadmin/utils/driver/psycopg3/server_manager.py @@ -99,6 +99,7 @@ class ServerManager(object): if server.tunnel_authentication is None \ else server.tunnel_authentication self.tunnel_identity_file = server.tunnel_identity_file + self.tunnel_prompt_password = server.tunnel_prompt_password self.tunnel_password = server.tunnel_password self.tunnel_keep_alive = server.tunnel_keep_alive else: @@ -108,6 +109,7 @@ class ServerManager(object): self.tunnel_username = None self.tunnel_authentication = None self.tunnel_identity_file = None + self.tunnel_prompt_password = 0 self.tunnel_password = None self.tunnel_keep_alive = 0 @@ -610,8 +612,12 @@ WHERE db.oid = {0}""".format(did)) self.tunnel_created = True except BaseSSHTunnelForwarderError as e: current_app.logger.exception(e) - return False, gettext("Failed to create the SSH tunnel.\n" - "Error: {0}").format(str(e)) + return False, gettext( + "Failed to create the SSH tunnel. Possible causes:\n" + "1. Enter the correct tunnel password (Clear saved password " + "if it has changed).\n 2. If using an identity file that " + "requires a password, enable “Prompt for Password?” in the " + "server dialog. \n 3. Verify the host address.") # Update the port to communicate locally self.local_bind_port = self.tunnel_object.local_bind_port