Files
pgadmin4/web/pgadmin/evaluate_config.py
T
Ashesh Vashi 3f99454199 fix: unbound keyring probe at config-import time can hang desktop startup
evaluate_and_patch_config() called keyring.get_password() synchronously
at `import config` time to detect a selected-but-unusable OS keyring
backend. On Debian 13 (and similar headless/RDP sessions with no live
D-Bus/GNOME-Keyring session), that call - and even the keyring import
itself - can block forever, freezing the whole desktop app before it
ever starts.

Revert evaluate_config.py to the pre-9.17 synchronous check (backend
name only, no get_password call). Move the usability probe into
pgadmin.utils.keyring_probe, run from create_app() in a background
daemon thread so it never delays startup, isolated in its own process
via subprocess.Popen(sys.executable, '-c', ...) so a hang can actually
be killed - a thread-only timeout can't do this, it would leave
CPython's per-module import lock held forever and wedge any later
`import keyring` in the parent process too.

subprocess.Popen (plain fork+exec), not multiprocessing.Process, is
required here: create_app() runs at the top level of pgAdmin4.py while
it's still being imported, and multiprocessing's spawn start method
refuses to start a child before the current process finishes
bootstrapping its __main__ module. An earlier version of this fix used
multiprocessing and crashed the probe thread with RuntimeError on every
single test run, which corrupted the SQLAlchemy/sqlite session for the
rest of app init and surfaced as an unrelated-looking "attempt to write
a readonly database" error on module_preference inserts.

config.USE_OS_SECRET_STORAGE is only ever read from request handlers
requiring an authenticated session, never at import time or inside
create_app() itself, so the async resolution race is safe in practice.

Tests mock subprocess.Popen for the timeout/kill/config-fallback
orchestration (deterministic, no real 3s wait or backend dependency),
plus one test class that runs the real probe script in a real
subprocess against a fake keyring module injected via PYTHONPATH, so
the script body itself has real coverage.

Verified with the full regression suite (--exclude feature_tests) in
both desktop mode (2134 passed, 0 failed) and server mode (2251
passed, 0 failed); remaining skips are pre-existing pgAgent-dependent
job tests.
2026-07-30 00:00:33 +05:30

168 lines
6.4 KiB
Python

##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
import os
import sys
import importlib.util
from pgadmin.utils.db_utils import normalize_database_uri
# User configs loaded from config_local, config_distro etc.
custom_config_settings = {}
# Function to Extract settings from config_local, config_distro etc.
def get_variables_from_module(module_name):
module = globals().get(module_name, None)
variables = {}
if module:
variables = {key: value for key, value in module.__dict__.items()
if not (key.startswith('__') or key.startswith('_')) and
validate_config_variable(key, value)}
return variables
# Function to load config_distro at custom path
def import_module_from_path(module_name, file_path):
# Create a module spec
spec = importlib.util.spec_from_file_location(module_name, file_path)
# Create the module based on the spec
module = importlib.util.module_from_spec(spec)
# Execute the module (this loads it)
spec.loader.exec_module(module)
return module
def validate_config_variable(key, value):
boolean_keys = ['SERVER_MODE', 'ENHANCED_COOKIE_PROTECTION',
'SUPPORT_SSH_TUNNEL', 'ALLOW_SAVE_TUNNEL_PASSWORD',
'MASTER_PASSWORD_REQUIRED']
integer_keys = ['DEFAULT_SERVER_PORT', 'SERVER_HEARTBEAT_TIMEOUT',
'LOG_ROTATION_SIZE', 'LOG_ROTATION_AGE',
'LOG_ROTATION_MAX_LOG_FILES', 'MAX_SESSION_IDLE_TIME']
if key in boolean_keys and not isinstance(value, bool):
exception_msg = 'Expected boolean value for %s; got %r' % (key, value)
raise ValueError(exception_msg)
elif key in integer_keys and not isinstance(value, int):
exception_msg = 'Expected integer value for %s; got %r' % (key, value)
raise ValueError(exception_msg)
else:
# Do not validate
return True
# Load distribution-specific config overrides
try:
if 'CONFIG_DISTRO_FILE_PATH' in os.environ:
config_distro_path = os.environ['CONFIG_DISTRO_FILE_PATH']
config_distro = import_module_from_path('config_distro',
config_distro_path)
else:
import config_distro
config_distro_settings = get_variables_from_module('config_distro')
custom_config_settings.update(config_distro_settings)
except ImportError:
pass
# Load local config overrides
try:
import config_local
config_local_settings = get_variables_from_module('config_local')
custom_config_settings.update(config_local_settings)
except ImportError:
pass
# Load system config overrides. We do this last, so that the sysadmin can
# override anything they want from a config file that's in a protected system
# directory and away from pgAdmin to avoid invalidating signatures.
system_config_dir = '/etc/pgadmin'
if sys.platform.startswith('win32'):
system_config_dir = os.environ['CommonProgramFiles'] + '/pgadmin'
elif sys.platform.startswith('darwin'):
system_config_dir = '/Library/Preferences/pgadmin'
if os.path.exists(system_config_dir + '/config_system.py'):
try:
sys.path.insert(0, system_config_dir)
import config_system
config_system_settings = get_variables_from_module('config_system')
custom_config_settings.update(config_system_settings)
except ImportError:
pass
def evaluate_and_patch_config(config: dict) -> dict:
# Update settings for 'LOG_FILE', 'SQLITE_PATH', 'SESSION_DB_PATH',
# 'AZURE_CREDENTIAL_CACHE_DIR', 'KERBEROS_CCACHE_DIR', 'STORAGE_DIR'
# of DATA_DIR is user defined
data_dir_dependent_settings = \
['LOG_FILE', 'SQLITE_PATH', 'SESSION_DB_PATH',
'AZURE_CREDENTIAL_CACHE_DIR', 'KERBEROS_CCACHE_DIR', 'STORAGE_DIR']
if 'DATA_DIR' in custom_config_settings:
for setting in data_dir_dependent_settings:
if setting not in custom_config_settings:
data_dir = custom_config_settings['DATA_DIR']
file_dir_name = os.path.basename(config.get(setting))
config.update(
{setting: os.path.join(data_dir, file_dir_name)})
# To use psycopg3 driver, need to specify +psycopg in conn URI
if 'CONFIG_DATABASE_URI' in custom_config_settings:
custom_config_settings['CONFIG_DATABASE_URI'] = \
normalize_database_uri(
custom_config_settings['CONFIG_DATABASE_URI'])
# Finally update config user configs
config.update(custom_config_settings)
# Override DEFAULT_SERVER value from environment variable.
if 'PGADMIN_CONFIG_DEFAULT_SERVER' in os.environ:
config['DEFAULT_SERVER'] = os.environ['PGADMIN_CONFIG_DEFAULT_SERVER']
# Disable USER_INACTIVITY_TIMEOUT when SERVER_MODE=False
if not config.get('SERVER_MODE'):
config['USER_INACTIVITY_TIMEOUT'] = 0
# Enable PSQL in Desktop Mode.
config['ENABLE_PSQL'] = True
if config.get('SERVER_MODE'):
config.setdefault('USE_OS_SECRET_STORAGE', False)
config.setdefault('KEYRING_NAME', '')
else:
# NOTE: whether the selected keyring backend is actually *usable*
# (e.g. SecretService with no live D-Bus/GNOME-Keyring session) is
# probed separately, asynchronously, from create_app() via
# keyring_probe.start_async_probe() - see that module for why this
# can't be done safely here at config-import time.
config.setdefault('USE_OS_SECRET_STORAGE', True)
k_name = ''
try:
import keyring
k_name = keyring.get_keyring().name
except Exception:
k_name = 'fail Keyring'
if k_name == 'fail Keyring':
config['USE_OS_SECRET_STORAGE'] = False
config['KEYRING_NAME'] = ''
else:
config.setdefault('KEYRING_NAME', k_name)
config.setdefault('SESSION_COOKIE_PATH', config.get('COOKIE_DEFAULT_PATH'))
# if a script name is preset, session cookies should go to sub path
if 'SCRIPT_NAME' in os.environ and os.environ["SCRIPT_NAME"]:
config.update(dict({
'APPLICATION_ROOT': os.environ["SCRIPT_NAME"],
'SESSION_COOKIE_PATH': os.environ["SCRIPT_NAME"],
}))
return config