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:
@@ -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};
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user