Skip to main content

pgsync_postgres/
url.rs

1//! Connection URL handling and TLS policy derivation.
2
3use pgsync_core::config::SafetyConfig;
4use pgsync_core::{Error, Result};
5use std::str::FromStr;
6use tokio_postgres::config::{Host, SslMode};
7
8/// How TLS is negotiated for a connection.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum TlsPolicy {
11    /// Plain TCP (loopback or Unix socket only).
12    Disabled,
13    /// Try TLS, fall back to plain if the server refuses (loopback only).
14    Preferred { verify: bool },
15    /// TLS is mandatory.
16    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/// Parsed connection parameters plus the derived TLS policy. Never printed with the
32/// password; use [`ConnectionInfo::redacted`] for logs.
33#[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    /// `sslrootcert=<path>` from the URL, trusted in addition to `safety.tls_ca_file`.
43    pub tls_ca_file: Option<String>,
44}
45
46/// libpq parameters that tokio-postgres does not know. Verification modes collapse to
47/// `require` because pgsync always verifies the chain and host name for non-loopback hosts;
48/// `sslrootcert` is honoured; parameters that would silently weaken or change the
49/// connection are refused; the rest are ignored.
50struct 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        // key=value form, or something tokio-postgres will reject with its own message.
58        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            // Harmless libpq knobs with no equivalent here.
107            "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    /// Parse a `postgres://` URL (or key=value string) read from the environment variable
141    /// `env_name`, and derive the TLS policy from `safety`.
142    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        // verify-full / verify-ca collapse to require (pgsync always verifies remote hosts).
287        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        // Parameters that would change the security model are refused, not ignored.
303        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        // Unknown parameters still fail loudly.
317        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}