1
0
mirror of synced 2026-10-23 07:08:55 +03:00

Require SSL for pgEdge Starfleet hosts and warn when libpq cannot connect to them. (#10569)

Starfleet endpoints drop SSL connections that arrive without SNI, which
libpq only sends from version 14, and never answer a non-SSL connection.
The cloud wizard now warns when pgAdmin's libpq is older than 14, and
the Register Server dialog defaults sslmode to require for *.pgedge.cloud
hosts, since with prefer any failed SSL attempt is retried without SSL
and reported only as a timeout.
This commit is contained in:
Dave Page
2026-10-14 12:57:08 +01:00
parent 5515b54d53
commit 50209794d7
5 changed files with 111 additions and 7 deletions

View File

@@ -185,6 +185,26 @@ export function getConnectionParameters() {
return conParams;
};
// pgEdge Starfleet endpoints never answer a connection without SSL, so with
// sslmode=prefer any failed SSL attempt (a wrong password, say) is retried
// without SSL and reported only as a timeout, hiding the real error.
const STARFLEET_HOST = /\.pgedge\.cloud\.?$/i;
export function starfleetConnectionParams(host, params) {
if(!STARFLEET_HOST.test((host || '').trim())) {
return;
}
params = params || [];
const sslmode = _.find(params, (p)=>p.name == 'sslmode');
if(sslmode && sslmode.value != 'prefer') {
return;
}
const require = {'name': 'sslmode', 'value': 'require', 'keyword': 'sslmode'};
return sslmode ?
params.map((p)=>(p.name == 'sslmode' ? {...p, value: 'require'} : p)) :
[require, ...params];
}
export default class ServerSchema extends BaseUISchema {
constructor(serverGroupOptions=[], userId=0, initValues={}) {
super({
@@ -354,6 +374,13 @@ export default class ServerSchema extends BaseUISchema {
} else {
obj.informText = undefined;
}
if(obj.isNew(state)) {
const params = starfleetConnectionParams(
state.host, state.connection_params);
if(params) {
return {connection_params: params};
}
}
}
},
{

View File

@@ -11,6 +11,7 @@
import time
from psycopg import pq
from flask import session, current_app, request
from flask_babel import gettext as _
from flask_security import current_user
@@ -39,6 +40,9 @@ JOBS_KEY = 'starfleet_jobs'
USER_TYPES = {'admin': 'admin', 'app': 'application'}
CONNECTION_PARAMS = {'sslmode': 'require', 'gssencmode': 'disable',
'connect_timeout': 30}
# Starfleet endpoints drop any SSL connection that arrives without SNI,
# which libpq only sends from version 14.
MIN_LIBPQ_VERSION = 140000
class StarfleetModule(PgAdminModule):
@@ -158,8 +162,23 @@ def verify_credentials():
'expires_at': client.expires_at}
# The wizard's next request may be served by another worker.
session.force_write = True
return make_json_response(success=1, data={
'tenant_name': tenant_name})
data = {'tenant_name': tenant_name}
warning = _libpq_warning()
if warning:
data['libpq_warning'] = warning
return make_json_response(success=1, data=data)
def _libpq_warning():
"""Explain why connections will fail if libpq is too old for SNI."""
version = pq.version()
if version >= MIN_LIBPQ_VERSION:
return None
return _('pgAdmin is using libpq {0}, which cannot connect to pgEdge '
'Starfleet because it does not send the server name (SNI) '
'during the SSL handshake. The database can be deployed, but '
'pgAdmin will not be able to connect to it until it uses '
'libpq 14 or later.').format(pq.version_pretty(version))
def _cpu(limit):

View File

@@ -64,6 +64,7 @@ class TestStarfleetBlueprint(_SkipServerSetUpMixin, BaseTestGenerator):
self.app.secret_key = 'test'
Babel(self.app) # gettext needs it in a bare app
self._test_verify()
self._test_verify_old_libpq()
self._test_verify_api_error()
self._test_verify_bad_credentials_escaped()
self._test_choices()
@@ -89,9 +90,11 @@ class TestStarfleetBlueprint(_SkipServerSetUpMixin, BaseTestGenerator):
return resp, dict(session.get('starfleet') or {}), fake
def _test_verify(self):
from pgadmin.misc.cloud.starfleet import TENANTS
resp, sess, fake = self._verify({
TENANTS: [{'name': 'acme', 'plan': 'managed'}]})
from pgadmin.misc.cloud import starfleet as sf
# Pin a libpq new enough that no warning is added to the payload.
with patch.object(sf.pq, 'version', return_value=140000):
resp, sess, fake = self._verify({
sf.TENANTS: [{'name': 'acme', 'plan': 'managed'}]})
body = json.loads(resp.data)
self.assertEqual(body['data'], {'tenant_name': 'acme'})
self.assertEqual(sess, {'access_token': 'tok-1',
@@ -99,6 +102,16 @@ class TestStarfleetBlueprint(_SkipServerSetUpMixin, BaseTestGenerator):
self.assertNotIn('test-secret', json.dumps(sess))
self.assertTrue(self.forced)
def _test_verify_old_libpq(self):
from pgadmin.misc.cloud import starfleet as sf
for version, warned in ((130020, True), (140000, False)):
with patch.object(sf.pq, 'version', return_value=version):
resp, _, _ = self._verify({sf.TENANTS: []})
data = json.loads(resp.data)['data']
self.assertEqual('libpq_warning' in data, warned)
if warned:
self.assertIn('libpq 13.20', data['libpq_warning'])
def _test_verify_api_error(self):
from pgacloud.utils.starfleet_api import StarfleetError
from pgadmin.misc.cloud.starfleet import TENANTS

View File

@@ -271,7 +271,8 @@ export default function CloudWizard({ nodeInfo, nodeData, onClose, cloudPanelId}
setErrMsg([MESSAGE_TYPE.ERROR, msg]);
reject(new Error(msg));
} else {
setErrMsg(['', '']);
const warning = res.data.data?.libpq_warning;
setErrMsg(warning ? [MESSAGE_TYPE.WARNING, warning] : ['', '']);
resolve();
}
})

View File

@@ -11,7 +11,7 @@
import _ from 'lodash';
import pgAdmin from 'sources/pgadmin';
import current_user from 'pgadmin.user_management.current_user';
import ServerSchema, { passexecForServerCopy } from '../../../pgadmin/browser/server_groups/servers/static/js/server.ui';
import ServerSchema, { passexecForServerCopy, starfleetConnectionParams } from '../../../pgadmin/browser/server_groups/servers/static/js/server.ui';
import {genericBeforeEach, getCreateView, getEditView, getPropertiesView} from '../genericFunctions';
describe('ServerSchema', ()=>{
@@ -193,4 +193,48 @@ describe('ServerSchema', ()=>{
.toBe('__none__');
});
});
describe('pgEdge Starfleet hosts', ()=>{
const defaults = [
{'name': 'sslmode', 'value': 'prefer', 'keyword': 'sslmode'},
{'name': 'connect_timeout', 'value': 10, 'keyword': 'connect_timeout'}];
const hostField = (schema)=>_.find(schema.fields, (f)=>f.id=='host');
it('requires SSL in place of the default prefer', ()=>{
expect(starfleetConnectionParams('db-1.use2.pgedge.cloud', defaults))
.toEqual([
{'name': 'sslmode', 'value': 'require', 'keyword': 'sslmode'},
defaults[1]]);
});
it('adds sslmode when it has been removed', ()=>{
expect(starfleetConnectionParams('DB-1.USE2.PGEDGE.CLOUD.', [defaults[1]]))
.toEqual([
{'name': 'sslmode', 'value': 'require', 'keyword': 'sslmode'},
defaults[1]]);
});
it('keeps an sslmode the user chose', ()=>{
expect(starfleetConnectionParams('db-1.use2.pgedge.cloud', [
{'name': 'sslmode', 'value': 'verify-full', 'keyword': 'sslmode'},
])).toBeUndefined();
});
it('leaves other hosts alone', ()=>{
expect(starfleetConnectionParams('db.example.com', defaults)).toBeUndefined();
expect(starfleetConnectionParams('pgedge.cloud.example.com', defaults)).toBeUndefined();
expect(starfleetConnectionParams('', defaults)).toBeUndefined();
});
it('applies only to a new server', ()=>{
let schema = createSchemaObject();
let f = hostField(schema);
expect(f.depChange({host: 'db-1.use2.pgedge.cloud', connection_params: defaults}))
.toEqual({connection_params: [
{'name': 'sslmode', 'value': 'require', 'keyword': 'sslmode'},
defaults[1]]});
expect(f.depChange({id: 1, host: 'db-1.use2.pgedge.cloud', connection_params: defaults}))
.toBeUndefined();
});
});
});