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	connect_one_with(url, application_name, Prompt::Allowed).await
256}
257
258/// [`connect_one`] for a caller that says whether a password may be asked for.
259pub async fn connect_one_with(
260	url: &str,
261	application_name: &str,
262	prompt: Prompt,
263) -> Result<tokio_postgres::Client> {
264	use mobc::Manager as _;
265
266	let pool = create_pool_sized(url, application_name, PoolSize::default(), prompt).await?;
267	pool.manager.connect().await.into_diagnostic()
268}
269
270/// Check if we can actually establish a connection
271async fn check_pool(pool: &PgPool) -> Result<()> {
272	let conn = match pool.get().await {
273		Err(mobc::Error::Inner(db_err)) => {
274			return Err(match db_err.as_db_error() {
275				Some(db_err) => miette!(
276					"E{code} at {func} in {file}:{line}",
277					code = db_err.code().code(),
278					func = db_err.routine().unwrap_or("{unknown}"),
279					file = db_err.file().unwrap_or("unknown.c"),
280					line = db_err.line().unwrap_or(0)
281				),
282				_ => miette!("{db_err}"),
283			})
284			.wrap_err(
285				db_err
286					.as_db_error()
287					.map(|e| e.to_string())
288					.unwrap_or_default(),
289			)?;
290		}
291		res @ Err(_) => {
292			let res = res.map(drop).into_diagnostic();
293			return if let Err(ref err) = res
294				&& is_auth_error(err)
295			{
296				res.wrap_err("hint: check the password")
297			} else {
298				res
299			};
300		}
301		Ok(conn) => conn,
302	};
303	conn.simple_query("SELECT 1")
304		.await
305		.into_diagnostic()
306		.wrap_err("checking connection")?;
307	Ok(())
308}
309
310#[cfg(test)]
311mod tests {
312	use super::*;
313
314	#[tokio::test]
315	async fn test_create_pool_valid_connection_string() {
316		let connection_string = "postgresql://localhost/test";
317		let result = create_pool(connection_string, "test").await;
318		// May fail if database doesn't exist, but should not be a parsing error
319		if let Err(e) = result {
320			let error_msg = format!("{:?}", e);
321			assert!(
322				!error_msg.contains("parsing connection string"),
323				"Should not be a parsing error: {}",
324				error_msg
325			);
326		}
327	}
328
329	#[tokio::test]
330	async fn test_create_pool_with_full_url() {
331		let connection_string = "postgresql://user:pass@localhost:5432/testdb";
332		let result = create_pool(connection_string, "test").await;
333		// May fail if database doesn't exist or auth fails, but should not be a parsing error
334		if let Err(e) = result {
335			let error_msg = format!("{:?}", e);
336			assert!(
337				!error_msg.contains("parsing connection string"),
338				"Should not be a parsing error: {}",
339				error_msg
340			);
341		}
342	}
343
344	#[tokio::test]
345	async fn test_create_pool_with_unix_socket_path() {
346		// Test connecting via Unix socket path
347		let url = "postgresql:///postgres?host=/var/run/postgresql";
348		let result = create_pool(url, "test").await;
349		// This may fail if PostgreSQL isn't running or isn't accessible via Unix socket
350		// but we can at least verify the parsing works
351		match result {
352			Ok(_) => {
353				// Connection succeeded
354			}
355			Err(e) => {
356				let error_msg = format!("{:?}", e);
357				// Verify it's not a parsing error but a connection error
358				assert!(
359					!error_msg.contains("parsing connection string"),
360					"Should not be a parsing error: {}",
361					error_msg
362				);
363			}
364		}
365	}
366
367	#[tokio::test]
368	async fn test_create_pool_with_encoded_unix_socket() {
369		// Test connecting via percent-encoded Unix socket path in host
370		let url = "postgresql://%2Fvar%2Frun%2Fpostgresql/postgres";
371		let result = create_pool(url, "test").await;
372		// This may fail if PostgreSQL isn't running, but parsing should work
373		match result {
374			Ok(_) => {
375				// Connection succeeded
376			}
377			Err(e) => {
378				let error_msg = format!("{:?}", e);
379				// Verify it's not a parsing error
380				assert!(
381					!error_msg.contains("parsing connection string"),
382					"Should not be a parsing error: {}",
383					error_msg
384				);
385			}
386		}
387	}
388
389	#[tokio::test]
390	async fn test_create_pool_with_no_host() {
391		// Test connection with no host specified (should try Unix socket or fallback to localhost)
392		let url = "postgresql:///postgres";
393		let result = create_pool(url, "test").await;
394		// This should either succeed or fail with a connection error, not a parsing error
395		match result {
396			Ok(_) => {
397				// Connection succeeded
398			}
399			Err(e) => {
400				let error_msg = format!("{:?}", e);
401				// Verify it's not a parsing error
402				assert!(
403					!error_msg.contains("parsing connection string"),
404					"Should not be a parsing error: {}",
405					error_msg
406				);
407			}
408		}
409	}
410
411	#[tokio::test]
412	async fn test_unix_socket_connection_end_to_end() {
413		// Test that we can actually connect and query via Unix socket
414		let url = "postgresql:///postgres?host=/var/run/postgresql";
415		let result = create_pool(url, "test").await;
416
417		match result {
418			Ok(pool) => {
419				// If connection succeeded, try a simple query
420				let conn = pool.get().await;
421				if let Ok(conn) = conn {
422					let result = conn.simple_query("SELECT 1 as test").await;
423					assert!(result.is_ok(), "Query should succeed");
424				}
425			}
426			Err(e) => {
427				let error_msg = format!("{:?}", e);
428				// If it failed, make sure it's not a parsing or TLS error
429				assert!(
430					!error_msg.contains("parsing connection string"),
431					"Should not be a parsing error: {}",
432					error_msg
433				);
434				assert!(
435					!error_msg.contains("TLS handshake"),
436					"Should not be a TLS error for Unix socket: {}",
437					error_msg
438				);
439			}
440		}
441	}
442}