Skip to main content

bestool_postgres/
pool.rs

1use std::time::Duration;
2
3use miette::{IntoDiagnostic, Report, Result, WrapErr, miette};
4use mobc::{Connection, Pool};
5use tokio_postgres::config::SslMode;
6use tracing::debug;
7
8pub use manager::{PgConnectionManager, PgError};
9
10mod manager;
11mod tls;
12mod url;
13
14/// Check if an error is a TLS/SSL error
15fn is_tls_error(error: &Report) -> bool {
16	if error.downcast_ref::<rustls::Error>().is_some() {
17		return true;
18	}
19
20	// Check the error chain for PgError::Tls
21	let mut source = error.source();
22	while let Some(err) = source {
23		if err.downcast_ref::<rustls::Error>().is_some() {
24			return true;
25		}
26		source = err.source();
27	}
28
29	let message = error.to_string();
30	message.contains("tls:")
31		|| message.contains("rustls")
32		|| message.contains("certificate")
33		|| message.contains("TLS handshake")
34		|| message.contains("invalid configuration")
35}
36
37/// Check if an error is an authentication error
38fn is_auth_error(error: &Report) -> bool {
39	if let Some(db_error) = error.downcast_ref::<tokio_postgres::Error>()
40		&& let Some(db_error) = db_error.as_db_error()
41	{
42		// PostgreSQL error codes for authentication failures:
43		// 28000 - invalid_authorization_specification
44		// 28P01 - invalid_password
45		let code = db_error.code().code();
46		return code == "28000" || code == "28P01";
47	}
48
49	// Check for other connection errors that might indicate auth issues
50	let message = error.to_string();
51	message.contains("password authentication failed")
52		|| message.contains("no password supplied")
53		|| message.contains("authentication failed")
54}
55
56pub type PgConnection = Connection<manager::PgConnectionManager>;
57
58#[derive(Debug, Clone)]
59pub struct PgPool {
60	pub manager: manager::PgConnectionManager,
61	pub inner: Pool<manager::PgConnectionManager>,
62}
63
64impl PgPool {
65	/// Returns a single connection by either opening a new connection
66	/// or returning an existing connection from the connection pool. Conn will
67	/// block until either a connection is returned or timeout.
68	pub async fn get(&self) -> Result<PgConnection, mobc::Error<PgError>> {
69		self.inner.get().await
70	}
71
72	/// Retrieves a connection from the pool, waiting for at most `timeout`
73	///
74	/// The given timeout will be used instead of the configured connection
75	/// timeout.
76	pub async fn get_timeout(
77		&self,
78		duration: Duration,
79	) -> Result<PgConnection, mobc::Error<PgError>> {
80		self.inner.get_timeout(duration).await
81	}
82}
83
84/// Create a connection pool from a connection URL
85///
86/// Supports Unix socket connections via:
87/// - Query parameter: `postgresql:///dbname?host=/var/run/postgresql`
88/// - Percent-encoded host: `postgresql://%2Fvar%2Frun%2Fpostgresql/dbname`
89/// - Empty host (auto-detects Unix socket or falls back to localhost): `postgresql:///dbname`
90///
91/// Unix socket connections automatically disable SSL/TLS.
92///
93/// # Password Prompting
94///
95/// If the connection fails with an authentication error and no password was provided
96/// in the connection URL, the function will prompt the user to enter a password
97/// interactively. The password will be read securely without echoing to the terminal.
98pub async fn create_pool(url: &str, application_name: &str) -> Result<PgPool> {
99	create_pool_sized(url, application_name, PoolSize::default(), Prompt::Allowed).await
100}
101
102/// How a pool is sized and how long it waits.
103///
104/// The default suits a caller that runs a query at a time and has a person
105/// watching. A caller that fans out sets these deliberately: `max_open` is a
106/// budget against the server's own connection limit rather than a slot per
107/// concurrent caller, `max_idle` keeps the resting footprint proportional to
108/// what the work actually leaves behind, and the two timeouts separate waiting
109/// for a free connection from waiting for one to open — a busy server and an
110/// unreachable one deserve very different patience.
111#[derive(Clone, Copy, Debug)]
112pub struct PoolSize {
113	pub max_open: u64,
114	pub max_idle: u64,
115	/// How long an idle connection is kept before being closed. `None` keeps
116	/// idle connections until `max_lifetime`; a caller that works in bursts
117	/// sets this long enough to span the gap between them and no longer, so
118	/// connections survive burst to burst without being held indefinitely.
119	pub max_idle_lifetime: Option<Duration>,
120	/// How long to wait for a free connection before giving up. `None` waits as
121	/// long as it takes, so a caller with more work than slots queues rather
122	/// than being told the database is unavailable when it is merely busy.
123	///
124	/// This covers opening a connection as well as waiting for one, so a caller
125	/// that wants to distinguish the two sets `connect_timeout` too.
126	pub get_timeout: Option<Duration>,
127	/// How long to wait for a connection to open. `None` leaves it to the
128	/// operating system, which can mean minutes of SYN retries when a host
129	/// drops packets rather than refusing.
130	///
131	/// A caller that is generous about queueing still wants this short: an
132	/// unreachable database should be reported quickly, not waited out.
133	pub connect_timeout: Option<Duration>,
134}
135
136impl Default for PoolSize {
137	fn default() -> Self {
138		// mobc's own defaults.
139		Self {
140			max_open: 10,
141			max_idle: 10,
142			max_idle_lifetime: None,
143			get_timeout: Some(Duration::from_secs(30)),
144			connect_timeout: None,
145		}
146	}
147}
148
149/// Whether a missing password may be asked for on the terminal.
150///
151/// Prompting suits a command a person is watching. A daemon has nobody to ask:
152/// under systemd there is no controlling terminal so the prompt errors, but run
153/// from a shell it blocks on stdin indefinitely — so an unattended caller asks
154/// for [`Prompt::Never`] and gets an error it can report instead.
155#[derive(Clone, Copy, Debug, PartialEq, Eq)]
156pub enum Prompt {
157	Allowed,
158	Never,
159}
160
161/// [`create_pool`] with an explicit size.
162pub async fn create_pool_sized(
163	url: &str,
164	application_name: &str,
165	size: PoolSize,
166	prompt: Prompt,
167) -> Result<PgPool> {
168	let mut config = url::parse_connection_url(url)?;
169
170	config.application_name(application_name);
171	if let Some(timeout) = size.connect_timeout {
172		config.connect_timeout(timeout);
173	}
174
175	let mut tried_ssl_fallback = false;
176
177	// Try to connect, and if it fails with auth error, prompt for password
178	let pool = loop {
179		debug!("Creating manager");
180		let tls = config.get_ssl_mode() != SslMode::Disable;
181		let manager = crate::pool::PgConnectionManager::new(config.clone(), tls);
182
183		debug!("Creating pool");
184		let pool = Pool::builder()
185			.max_lifetime(Some(Duration::from_secs(3600)))
186			.max_open(size.max_open)
187			.max_idle(size.max_idle)
188			.max_idle_lifetime(size.max_idle_lifetime)
189			.get_timeout(size.get_timeout)
190			.build(manager.clone());
191
192		let pool = PgPool {
193			manager,
194			inner: pool,
195		};
196
197		debug!("Checking pool");
198		match check_pool(&pool).await {
199			Ok(_) => {
200				if tried_ssl_fallback {
201					tracing::info!("Connected successfully with SSL disabled after TLS error");
202				}
203				break pool;
204			}
205			Err(e) => {
206				debug!("Connection error: {:#}", e);
207				debug!(
208					"is_tls_error: {}, is_auth_error: {}",
209					is_tls_error(&e),
210					is_auth_error(&e)
211				);
212
213				if is_tls_error(&e) {
214					// If SSL mode is prefer and we haven't tried fallback yet, retry with SSL disabled
215					if config.get_ssl_mode() == SslMode::Prefer && !tried_ssl_fallback {
216						debug!("TLS failed with prefer mode, retrying with SSL disabled");
217						config.ssl_mode(SslMode::Disable);
218						tried_ssl_fallback = true;
219						continue;
220					}
221
222					// TLS error - suggest disabling SSL
223					return Err(e).wrap_err(
224						"TLS/SSL connection failed. Try using --ssl disable, \
225						or use a connection URL with sslmode=disable: \
226						postgresql://user@host/db?sslmode=disable",
227					);
228				} else if is_auth_error(&e)
229					&& config.get_password().is_none()
230					&& prompt == Prompt::Allowed
231				{
232					let password = rpassword::prompt_password("Password: ").into_diagnostic()?;
233					config.password(password);
234					// Loop will retry with the new password
235				} else {
236					// Not an auth error or we already have a password, re-throw
237					return Err(e);
238				}
239			}
240		}
241	};
242
243	Ok(pool)
244}
245
246/// Open a single connection using the same SSL fallback, auth retry, and
247/// application-name path as [`create_pool`].
248///
249/// For one-shot commands that open exactly one connection and exit (doctor
250/// sweeps, the patient-portal flag lookup in lifecycle subcommands, etc.).
251/// Internally builds a pool, takes one client out, then drops the pool. The
252/// returned client owns its connection driver task — it remains usable after
253/// the pool is gone.
254pub async fn connect_one(url: &str, application_name: &str) -> Result<tokio_postgres::Client> {
255	use mobc::Manager as _;
256
257	let pool = create_pool(url, application_name).await?;
258	pool.manager.connect().await.into_diagnostic()
259}
260
261/// Check if we can actually establish a connection
262async fn check_pool(pool: &PgPool) -> Result<()> {
263	let conn = match pool.get().await {
264		Err(mobc::Error::Inner(db_err)) => {
265			return Err(match db_err.as_db_error() {
266				Some(db_err) => miette!(
267					"E{code} at {func} in {file}:{line}",
268					code = db_err.code().code(),
269					func = db_err.routine().unwrap_or("{unknown}"),
270					file = db_err.file().unwrap_or("unknown.c"),
271					line = db_err.line().unwrap_or(0)
272				),
273				_ => miette!("{db_err}"),
274			})
275			.wrap_err(
276				db_err
277					.as_db_error()
278					.map(|e| e.to_string())
279					.unwrap_or_default(),
280			)?;
281		}
282		res @ Err(_) => {
283			let res = res.map(drop).into_diagnostic();
284			return if let Err(ref err) = res
285				&& is_auth_error(err)
286			{
287				res.wrap_err("hint: check the password")
288			} else {
289				res
290			};
291		}
292		Ok(conn) => conn,
293	};
294	conn.simple_query("SELECT 1")
295		.await
296		.into_diagnostic()
297		.wrap_err("checking connection")?;
298	Ok(())
299}
300
301#[cfg(test)]
302mod tests {
303	use super::*;
304
305	#[tokio::test]
306	async fn test_create_pool_valid_connection_string() {
307		let connection_string = "postgresql://localhost/test";
308		let result = create_pool(connection_string, "test").await;
309		// May fail if database doesn't exist, but should not be a parsing error
310		if let Err(e) = result {
311			let error_msg = format!("{:?}", e);
312			assert!(
313				!error_msg.contains("parsing connection string"),
314				"Should not be a parsing error: {}",
315				error_msg
316			);
317		}
318	}
319
320	#[tokio::test]
321	async fn test_create_pool_with_full_url() {
322		let connection_string = "postgresql://user:pass@localhost:5432/testdb";
323		let result = create_pool(connection_string, "test").await;
324		// May fail if database doesn't exist or auth fails, but should not be a parsing error
325		if let Err(e) = result {
326			let error_msg = format!("{:?}", e);
327			assert!(
328				!error_msg.contains("parsing connection string"),
329				"Should not be a parsing error: {}",
330				error_msg
331			);
332		}
333	}
334
335	#[tokio::test]
336	async fn test_create_pool_with_unix_socket_path() {
337		// Test connecting via Unix socket path
338		let url = "postgresql:///postgres?host=/var/run/postgresql";
339		let result = create_pool(url, "test").await;
340		// This may fail if PostgreSQL isn't running or isn't accessible via Unix socket
341		// but we can at least verify the parsing works
342		match result {
343			Ok(_) => {
344				// Connection succeeded
345			}
346			Err(e) => {
347				let error_msg = format!("{:?}", e);
348				// Verify it's not a parsing error but a connection error
349				assert!(
350					!error_msg.contains("parsing connection string"),
351					"Should not be a parsing error: {}",
352					error_msg
353				);
354			}
355		}
356	}
357
358	#[tokio::test]
359	async fn test_create_pool_with_encoded_unix_socket() {
360		// Test connecting via percent-encoded Unix socket path in host
361		let url = "postgresql://%2Fvar%2Frun%2Fpostgresql/postgres";
362		let result = create_pool(url, "test").await;
363		// This may fail if PostgreSQL isn't running, but parsing should work
364		match result {
365			Ok(_) => {
366				// Connection succeeded
367			}
368			Err(e) => {
369				let error_msg = format!("{:?}", e);
370				// Verify it's not a parsing error
371				assert!(
372					!error_msg.contains("parsing connection string"),
373					"Should not be a parsing error: {}",
374					error_msg
375				);
376			}
377		}
378	}
379
380	#[tokio::test]
381	async fn test_create_pool_with_no_host() {
382		// Test connection with no host specified (should try Unix socket or fallback to localhost)
383		let url = "postgresql:///postgres";
384		let result = create_pool(url, "test").await;
385		// This should either succeed or fail with a connection error, not a parsing error
386		match result {
387			Ok(_) => {
388				// Connection succeeded
389			}
390			Err(e) => {
391				let error_msg = format!("{:?}", e);
392				// Verify it's not a parsing error
393				assert!(
394					!error_msg.contains("parsing connection string"),
395					"Should not be a parsing error: {}",
396					error_msg
397				);
398			}
399		}
400	}
401
402	#[tokio::test]
403	async fn test_unix_socket_connection_end_to_end() {
404		// Test that we can actually connect and query via Unix socket
405		let url = "postgresql:///postgres?host=/var/run/postgresql";
406		let result = create_pool(url, "test").await;
407
408		match result {
409			Ok(pool) => {
410				// If connection succeeded, try a simple query
411				let conn = pool.get().await;
412				if let Ok(conn) = conn {
413					let result = conn.simple_query("SELECT 1 as test").await;
414					assert!(result.is_ok(), "Query should succeed");
415				}
416			}
417			Err(e) => {
418				let error_msg = format!("{:?}", e);
419				// If it failed, make sure it's not a parsing or TLS error
420				assert!(
421					!error_msg.contains("parsing connection string"),
422					"Should not be a parsing error: {}",
423					error_msg
424				);
425				assert!(
426					!error_msg.contains("TLS handshake"),
427					"Should not be a TLS error for Unix socket: {}",
428					error_msg
429				);
430			}
431		}
432	}
433}