1use pgsync_core::config::SafetyConfig;
4use pgsync_core::{Error, Result};
5use std::str::FromStr;
6use tokio_postgres::config::{Host, SslMode};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum TlsPolicy {
11 Disabled,
13 Preferred { verify: bool },
15 Required { verify: bool },
17}
18
19impl TlsPolicy {
20 pub fn uses_tls(self) -> bool {
21 !matches!(self, TlsPolicy::Disabled)
22 }
23 pub fn verify(self) -> bool {
24 match self {
25 TlsPolicy::Disabled => false,
26 TlsPolicy::Preferred { verify } | TlsPolicy::Required { verify } => verify,
27 }
28 }
29}
30
31#[derive(Clone)]
34pub struct ConnectionInfo {
35 pub config: tokio_postgres::Config,
36 pub host: String,
37 pub port: u16,
38 pub user: String,
39 pub dbname: String,
40 pub tls: TlsPolicy,
41 pub is_loopback: bool,
42 pub tls_ca_file: Option<String>,
44}
45
46struct Normalised {
51 url: String,
52 tls_ca_file: Option<String>,
53}
54
55fn normalise_url(url: &str) -> Result<Normalised> {
56 let Ok(mut parsed) = url::Url::parse(url) else {
57 return Ok(Normalised {
59 url: url.to_owned(),
60 tls_ca_file: None,
61 });
62 };
63 if !matches!(parsed.scheme(), "postgres" | "postgresql") {
64 return Ok(Normalised {
65 url: url.to_owned(),
66 tls_ca_file: None,
67 });
68 }
69 let mut tls_ca_file = None;
70 let mut kept: Vec<(String, String)> = Vec::new();
71 for (k, v) in parsed.query_pairs() {
72 let (k, v) = (k.into_owned(), v.into_owned());
73 match k.as_str() {
74 "sslmode" => {
75 let mapped = match v.as_str() {
76 "verify-full" | "verify-ca" => "require",
77 "allow" => "prefer",
78 other => other,
79 };
80 kept.push((k, mapped.to_owned()));
81 }
82 "sslrootcert" => {
83 if v != "system" {
84 tls_ca_file = Some(v);
85 }
86 }
87 "sslcert" | "sslkey" | "sslpassword" => {
88 return Err(Error::config(format!(
89 "connection URL parameter {k} is not supported: client certificate \
90 authentication is not available in this version of pgsync"
91 )));
92 }
93 "sslcrl" | "sslcrldir" => {
94 return Err(Error::config(format!(
95 "connection URL parameter {k} is not supported (certificate revocation \
96 lists are not checked); remove it or use safety.tls_ca_file with a CA \
97 that does not need it"
98 )));
99 }
100 "passfile" | "service" | "requirepeer" => {
101 return Err(Error::config(format!(
102 "connection URL parameter {k} is not supported; put the password in the URL \
103 held by the environment variable"
104 )));
105 }
106 "sslcompression"
108 | "sslsni"
109 | "sslcertmode"
110 | "gssencmode"
111 | "krbsrvname"
112 | "gsslib"
113 | "ssl_min_protocol_version"
114 | "ssl_max_protocol_version"
115 | "client_encoding"
116 | "fallback_application_name" => {}
117 _ => kept.push((k, v)),
118 }
119 }
120 parsed.set_query(None);
121 if !kept.is_empty() {
122 let mut q = parsed.query_pairs_mut();
123 for (k, v) in &kept {
124 q.append_pair(k, v);
125 }
126 }
127 Ok(Normalised {
128 url: parsed.to_string(),
129 tls_ca_file,
130 })
131}
132
133impl std::fmt::Debug for ConnectionInfo {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 f.write_str(&self.redacted())
136 }
137}
138
139impl ConnectionInfo {
140 pub fn from_env(env_name: &str, safety: &SafetyConfig) -> Result<Self> {
143 let url = std::env::var(env_name).map_err(|_| {
144 Error::config(format!(
145 "environment variable {env_name} is not set (connection URL)"
146 ))
147 })?;
148 Self::parse(&url, safety).map_err(|e| e.context(env_name))
149 }
150
151 pub fn parse(url: &str, safety: &SafetyConfig) -> Result<Self> {
152 let normalised = normalise_url(url)?;
153 let config = tokio_postgres::Config::from_str(&normalised.url)
154 .map_err(|e| Error::config(format!("invalid connection URL: {e}")))?;
155 let hosts = config.get_hosts();
156 if hosts.len() != 1 {
157 return Err(Error::config(format!(
158 "connection URL must name exactly one host (found {})",
159 hosts.len()
160 )));
161 }
162 let (host, is_loopback) = match &hosts[0] {
163 Host::Tcp(h) => (h.clone(), is_loopback_host(h)),
164 #[cfg(unix)]
165 Host::Unix(p) => (p.display().to_string(), true),
166 };
167 let port = config.get_ports().first().copied().unwrap_or(5432);
168 let user = config
169 .get_user()
170 .ok_or_else(|| Error::config("connection URL must include a user"))?
171 .to_owned();
172 let dbname = config
173 .get_dbname()
174 .ok_or_else(|| Error::config("connection URL must include a database name"))?
175 .to_owned();
176
177 let verify = !safety.insecure_skip_tls_verify;
178 let tls = match (config.get_ssl_mode(), is_loopback, safety.require_tls) {
179 (SslMode::Disable, false, true) => {
180 return Err(Error::config(
181 "sslmode=disable is not allowed for a non-loopback host while safety.require_tls is true",
182 ));
183 }
184 (SslMode::Disable, _, _) => TlsPolicy::Disabled,
185 (_, false, true) => TlsPolicy::Required { verify: true },
186 (SslMode::Require, _, _) => TlsPolicy::Required { verify },
187 (_, true, _) => TlsPolicy::Preferred { verify },
188 (_, false, false) => TlsPolicy::Preferred { verify },
189 };
190 if safety.insecure_skip_tls_verify && !is_loopback {
191 return Err(Error::config(
192 "safety.insecure_skip_tls_verify is only honoured for loopback hosts",
193 ));
194 }
195 Ok(ConnectionInfo {
196 config,
197 host,
198 port,
199 user,
200 dbname,
201 tls,
202 is_loopback,
203 tls_ca_file: normalised.tls_ca_file,
204 })
205 }
206
207 pub fn redacted(&self) -> String {
208 format!(
209 "postgres://{}@{}:{}/{}",
210 self.user, self.host, self.port, self.dbname
211 )
212 }
213
214 pub fn password(&self) -> Option<&[u8]> {
215 self.config.get_password()
216 }
217}
218
219pub fn is_loopback_host(h: &str) -> bool {
220 if h.eq_ignore_ascii_case("localhost") {
221 return true;
222 }
223 match h.parse::<std::net::IpAddr>() {
224 Ok(ip) => ip.is_loopback(),
225 Err(_) => false,
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 fn safety(require_tls: bool, insecure: bool) -> SafetyConfig {
234 SafetyConfig {
235 require_tls,
236 insecure_skip_tls_verify: insecure,
237 ..Default::default()
238 }
239 }
240
241 #[test]
242 fn loopback_hosts_may_skip_tls() {
243 let i = ConnectionInfo::parse("postgres://u:p@localhost:5433/db", &safety(true, false))
244 .unwrap();
245 assert!(i.is_loopback);
246 assert_eq!(i.tls, TlsPolicy::Preferred { verify: true });
247 assert_eq!(i.redacted(), "postgres://u@localhost:5433/db");
248 assert_eq!(i.password(), Some(&b"p"[..]));
249 let i = ConnectionInfo::parse(
250 "postgres://u@127.0.0.1/db?sslmode=disable",
251 &safety(true, false),
252 )
253 .unwrap();
254 assert_eq!(i.tls, TlsPolicy::Disabled);
255 }
256
257 #[test]
258 fn remote_hosts_require_verified_tls() {
259 let i = ConnectionInfo::parse("postgres://u:p@db.example.com/db", &safety(true, false))
260 .unwrap();
261 assert!(!i.is_loopback);
262 assert_eq!(i.tls, TlsPolicy::Required { verify: true });
263 let err = ConnectionInfo::parse(
264 "postgres://u:p@db.example.com/db?sslmode=disable",
265 &safety(true, false),
266 )
267 .unwrap_err();
268 assert!(err.message().contains("sslmode=disable"));
269 let err = ConnectionInfo::parse("postgres://u:p@db.example.com/db", &safety(true, true))
270 .unwrap_err();
271 assert!(err.message().contains("loopback"));
272 }
273
274 #[test]
275 fn insecure_override_only_on_loopback() {
276 let i = ConnectionInfo::parse(
277 "postgres://u:p@localhost/db?sslmode=require",
278 &safety(true, true),
279 )
280 .unwrap();
281 assert_eq!(i.tls, TlsPolicy::Required { verify: false });
282 }
283
284 #[test]
285 fn libpq_tls_parameters_are_normalised() {
286 let i = ConnectionInfo::parse(
288 "postgres://u:p@db.example.com:5432/db?sslmode=verify-full&sslrootcert=/etc/ca.pem&sslcompression=0",
289 &safety(true, false),
290 )
291 .unwrap();
292 assert_eq!(i.tls, TlsPolicy::Required { verify: true });
293 assert_eq!(i.tls_ca_file.as_deref(), Some("/etc/ca.pem"));
294 let i = ConnectionInfo::parse(
295 "postgresql://u:p@db.example.com/db?sslmode=verify-ca&sslrootcert=system&application_name=x",
296 &safety(true, false),
297 )
298 .unwrap();
299 assert_eq!(i.tls, TlsPolicy::Required { verify: true });
300 assert_eq!(i.tls_ca_file, None);
301 assert_eq!(i.config.get_application_name(), Some("x"));
302 for p in [
304 "sslcert=/c.pem",
305 "sslkey=/k.pem",
306 "sslcrl=/crl.pem",
307 "passfile=/p",
308 ] {
309 let err = ConnectionInfo::parse(
310 &format!("postgres://u:p@db.example.com/db?{p}"),
311 &safety(true, false),
312 )
313 .unwrap_err();
314 assert!(err.message().contains("not supported"), "{p}: {err}");
315 }
316 assert!(
318 ConnectionInfo::parse("postgres://u:p@localhost/db?bogus=1", &safety(true, false))
319 .is_err()
320 );
321 }
322
323 #[test]
324 fn rejects_incomplete_urls() {
325 assert!(ConnectionInfo::parse("postgres://localhost/db", &safety(true, false)).is_err());
326 assert!(ConnectionInfo::parse("postgres://u@localhost", &safety(true, false)).is_err());
327 assert!(ConnectionInfo::parse("not a url", &safety(true, false)).is_err());
328 }
329}