Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/en_US/images/server_ssh_tunnel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion docs/en_US/release_notes_9_9.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -20,8 +20,10 @@ Bundled PostgreSQL Utilities
New features
************

| `Issue #6385 <https://github.com/pgadmin-org/pgadmin4/issues/6385>`_ - Add support of DEPENDS/NO DEPENDS ON EXTENSION for ALTER FUNCTION.
| `Issue #6394 <https://github.com/pgadmin-org/pgadmin4/issues/6394>`_ - Added "MULTIRANGE_TYPE_NAME" option while creating a Range Type.
| `Issue #6395 <https://github.com/pgadmin-org/pgadmin4/issues/6395>`_ - Added "SUBSCRIPT" option while creating a External Type.
| `Issue #6996 <https://github.com/pgadmin-org/pgadmin4/issues/6996>`_ - Added option to skip the password dialog when using an identity file.
| `Issue #8932 <https://github.com/pgadmin-org/pgadmin4/issues/8932>`_ - Added 'failover' and 'two_phase' parameter support in CREATE/ALTER SUBSCRIPTION for PostgreSQL v17+.

Housekeeping
Expand Down
3 changes: 3 additions & 0 deletions docs/en_US/server_dialog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <clear_saved_passwords>` to remove the saved
Expand Down
42 changes: 42 additions & 0 deletions web/migrations/versions/efbbe5d5862f_.py
Original file line number Diff line number Diff line change
@@ -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
40 changes: 33 additions & 7 deletions web/pgadmin/browser/server_groups/servers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ def get_shared_server_properties(server, sharedserver):
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 \
Expand Down Expand Up @@ -413,6 +414,7 @@ def create_shared_server(data, gid):
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
Expand Down Expand Up @@ -790,6 +792,7 @@ def update(self, gid, sid):
'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',
Expand Down Expand Up @@ -1086,6 +1089,8 @@ def properties(self, gid, sid):
'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),
Expand Down Expand Up @@ -1212,6 +1217,8 @@ def create(self, gid):
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),
Expand Down Expand Up @@ -1419,6 +1426,19 @@ def connect_status(self, gid, sid):
}
)

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.
Expand Down Expand Up @@ -1502,11 +1522,12 @@ def connect(self, gid, sid, is_qt=False, server=None):

# 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 ''
Expand Down Expand Up @@ -1562,6 +1583,10 @@ def connect(self, gid, sid, is_qt=False, server=None):
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,
Expand All @@ -1571,7 +1596,7 @@ def connect(self, gid, sid, is_qt=False, server=None):
)
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:
Expand All @@ -1583,7 +1608,8 @@ def connect(self, gid, sid, is_qt=False, server=None):
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:
Expand Down
18 changes: 17 additions & 1 deletion web/pgadmin/browser/server_groups/servers/static/js/server.ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def __init__(self, name, id, username, use_ssh_tunnel,
self.service = service
self.save_password = 0
self.shared = None
self.tunnel_prompt_password = 0

mock_server_obj = TestMockServer(
self.mock_data['name'],
Expand Down
12 changes: 11 additions & 1 deletion web/pgadmin/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
#
##########################################################################

SCHEMA_VERSION = 47
SCHEMA_VERSION = 48

##########################################################################
#
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions web/pgadmin/setup/tests/servers.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"TunnelPort": "22",
"TunnelUsername": "username",
"TunnelAuthentication": 0,
"TunnelPromptPassword": 0,
"PasswordExecCommand": "echo 'test'",
"PasswordExecExpiration": 100
}
Expand Down
32 changes: 17 additions & 15 deletions web/pgadmin/static/js/Dialogs/ConnectServerContent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,21 +109,23 @@ export default function ConnectServerContent({closeModal, data, onOK, setHeight,
<DefaultButton data-test="close" startIcon={<CloseIcon />} onClick={()=>{
closeModal();
}} >{gettext('Cancel')}</DefaultButton>
<PrimaryButton ref={okBtnRef} data-test="save" startIcon={<CheckRoundedIcon />} 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')}</PrimaryButton>
{(data.prompt_password || data.prompt_tunnel_password) && <>
<PrimaryButton ref={okBtnRef} data-test="save" startIcon={<CheckRoundedIcon />} 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')}</PrimaryButton>
</>}
</ModalFooter>
</ModalContent>
);
Expand Down
5 changes: 5 additions & 0 deletions web/pgadmin/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)

Expand Down
10 changes: 8 additions & 2 deletions web/pgadmin/utils/driver/psycopg3/server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def update(self, server):
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:
Expand All @@ -108,6 +109,7 @@ def update(self, server):
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

Expand Down Expand Up @@ -610,8 +612,12 @@ def create_ssh_tunnel(self, tunnel_password):
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
Expand Down
Loading