Skip to main content

pgwire_replication/
config.rs

1//! Configuration types for PostgreSQL replication connections.
2//!
3//! This module provides configuration structures for establishing replication
4//! connections to PostgreSQL, including TLS settings and replication parameters.
5
6use std::path::PathBuf;
7use std::time::Duration;
8
9use crate::lsn::Lsn;
10
11/// SSL/TLS connection mode.
12///
13/// These modes match PostgreSQL's `sslmode` connection parameter.
14/// See [PostgreSQL SSL Support](https://www.postgresql.org/docs/current/libpq-ssl.html)
15/// for detailed documentation.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum SslMode {
18    /// Never use TLS. Connection will fail if server requires TLS.
19    #[default]
20    Disable,
21
22    /// Try TLS first, fall back to unencrypted if server doesn't support it.
23    ///
24    /// **Warning**: Vulnerable to downgrade attacks. Not recommended for production.
25    Prefer,
26
27    /// Require TLS but don't verify the server certificate.
28    ///
29    /// Protects against passive eavesdropping but not active MITM attacks.
30    Require,
31
32    /// Require TLS and verify the server certificate chain against trusted CAs.
33    ///
34    /// Does NOT verify that the certificate hostname matches the connection target.
35    VerifyCa,
36
37    /// Require TLS, verify certificate chain, AND verify hostname matches.
38    ///
39    /// **Recommended for production**. Provides full protection against MITM attacks.
40    VerifyFull,
41}
42
43impl SslMode {
44    /// Returns `true` if this mode requires TLS (won't fall back to plain).
45    #[inline]
46    pub fn requires_tls(&self) -> bool {
47        !matches!(self, SslMode::Disable | SslMode::Prefer)
48    }
49
50    /// Returns `true` if this mode verifies the certificate chain.
51    #[inline]
52    pub fn verifies_certificate(&self) -> bool {
53        matches!(self, SslMode::VerifyCa | SslMode::VerifyFull)
54    }
55
56    /// Returns `true` if this mode verifies the server hostname.
57    #[inline]
58    pub fn verifies_hostname(&self) -> bool {
59        matches!(self, SslMode::VerifyFull)
60    }
61}
62
63/// TLS/SSL configuration for PostgreSQL connections.
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
65pub struct TlsConfig {
66    /// SSL mode controlling connection security level.
67    pub mode: SslMode,
68
69    /// Path to PEM file containing trusted CA certificates.
70    ///
71    /// If `None` and verification is enabled (`VerifyCa`/`VerifyFull`),
72    /// the Mozilla root certificates (webpki-roots) are used.
73    pub ca_pem_path: Option<PathBuf>,
74
75    /// Override SNI hostname sent during TLS handshake.
76    ///
77    /// Useful when:
78    /// - Connecting via IP address but certificate has a DNS name
79    /// - Using a load balancer with different internal/external names
80    ///
81    /// If `None`, the connection `host` is used for SNI.
82    pub sni_hostname: Option<String>,
83
84    /// Path to PEM file containing client certificate chain.
85    ///
86    /// Required for mutual TLS (mTLS) authentication.
87    /// Must be paired with `client_key_pem_path`.
88    pub client_cert_pem_path: Option<PathBuf>,
89
90    /// Path to PEM file containing client private key.
91    ///
92    /// Required for mutual TLS (mTLS) authentication.
93    /// Must be paired with `client_cert_pem_path`.
94    /// Supports PKCS#8, PKCS#1 (RSA), and SEC1 (EC) formats.
95    pub client_key_pem_path: Option<PathBuf>,
96}
97
98impl TlsConfig {
99    /// Create a configuration with TLS disabled.
100    ///
101    /// # Example
102    /// ```
103    /// use pgwire_replication::config::TlsConfig;
104    ///
105    /// let tls = TlsConfig::disabled();
106    /// assert!(!tls.mode.requires_tls());
107    /// ```
108    pub fn disabled() -> Self {
109        Self::default()
110    }
111
112    /// Create a configuration requiring TLS without certificate verification.
113    ///
114    /// **Warning**: This mode is vulnerable to MITM attacks.
115    /// Use `verify_ca()` or `verify_full()` for production.
116    ///
117    /// # Example
118    /// ```
119    /// use pgwire_replication::config::TlsConfig;
120    ///
121    /// let tls = TlsConfig::require();
122    /// assert!(tls.mode.requires_tls());
123    /// assert!(!tls.mode.verifies_certificate());
124    /// ```
125    pub fn require() -> Self {
126        Self {
127            mode: SslMode::Require,
128            ..Default::default()
129        }
130    }
131
132    /// Create a configuration with certificate chain verification.
133    ///
134    /// # Arguments
135    /// * `ca_path` - Path to CA certificate PEM file, or `None` for system roots
136    ///
137    /// # Example
138    /// ```
139    /// use pgwire_replication::config::TlsConfig;
140    ///
141    /// // Using system/Mozilla roots
142    /// let tls = TlsConfig::verify_ca(None);
143    ///
144    /// // Using custom CA
145    /// let tls = TlsConfig::verify_ca(Some("/path/to/ca.pem".into()));
146    /// ```
147    pub fn verify_ca(ca_path: Option<PathBuf>) -> Self {
148        Self {
149            mode: SslMode::VerifyCa,
150            ca_pem_path: ca_path,
151            ..Default::default()
152        }
153    }
154
155    /// Create a configuration with full verification (chain + hostname).
156    ///
157    /// **Recommended for production**.
158    ///
159    /// # Arguments
160    /// * `ca_path` - Path to CA certificate PEM file, or `None` for system roots
161    ///
162    /// # Example
163    /// ```
164    /// use pgwire_replication::config::TlsConfig;
165    ///
166    /// let tls = TlsConfig::verify_full(Some("/etc/ssl/certs/ca.pem".into()));
167    /// assert!(tls.mode.verifies_hostname());
168    /// ```
169    pub fn verify_full(ca_path: Option<PathBuf>) -> Self {
170        Self {
171            mode: SslMode::VerifyFull,
172            ca_pem_path: ca_path,
173            ..Default::default()
174        }
175    }
176
177    /// Set SNI hostname override.
178    ///
179    /// # Example
180    /// ```
181    /// use pgwire_replication::config::TlsConfig;
182    ///
183    /// let tls = TlsConfig::verify_full(None)
184    ///     .with_sni_hostname("db.example.com");
185    /// ```
186    pub fn with_sni_hostname(mut self, hostname: impl Into<String>) -> Self {
187        self.sni_hostname = Some(hostname.into());
188        self
189    }
190
191    /// Configure client certificate for mutual TLS.
192    ///
193    /// # Example
194    /// ```
195    /// use pgwire_replication::config::TlsConfig;
196    ///
197    /// let tls = TlsConfig::verify_full(Some("/ca.pem".into()))
198    ///     .with_client_cert("/client.pem", "/client.key");
199    /// ```
200    pub fn with_client_cert(
201        mut self,
202        cert_path: impl Into<PathBuf>,
203        key_path: impl Into<PathBuf>,
204    ) -> Self {
205        self.client_cert_pem_path = Some(cert_path.into());
206        self.client_key_pem_path = Some(key_path.into());
207        self
208    }
209
210    /// Returns `true` if mutual TLS (client certificate) is configured.
211    #[inline]
212    pub fn is_mtls(&self) -> bool {
213        self.client_cert_pem_path.is_some() && self.client_key_pem_path.is_some()
214    }
215}
216
217/// One or more publication names a replication slot subscribes to.
218///
219/// A single slot can stream changes from several publications at once.
220/// Construct from a single name or from a collection:
221///
222/// ```
223/// use pgwire_replication::config::Publication;
224///
225/// let one: Publication = "orders".into();
226/// let many: Publication = ["orders", "customers"].into();
227/// let dynamic: Publication = vec![String::from("a"), String::from("b")].into();
228/// ```
229///
230/// `Publication` is an immutable value fixed for the connection's lifetime: the
231/// `publication_names` set is bound once at `START_REPLICATION` and cannot be
232/// changed on a live stream. To replicate a different set, reconnect with a new
233/// [`ReplicationConfig`].
234///
235/// # Limitations
236///
237/// Names are joined into PostgreSQL's comma-separated `publication_names` list,
238/// so a name containing a comma or whitespace cannot be represented (a
239/// pre-existing PostgreSQL constraint). Single quotes in names are escaped. An
240/// empty `Publication` is invalid and the server will reject the stream.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct Publication(Vec<String>);
243
244impl Publication {
245    /// The publication names, in order.
246    #[inline]
247    pub fn names(&self) -> &[String] {
248        &self.0
249    }
250
251    /// Render the value for the `publication_names` `START_REPLICATION` option:
252    /// single quotes escaped, names comma-separated.
253    pub(crate) fn to_option_value(&self) -> String {
254        self.0
255            .iter()
256            .map(|name| name.replace('\'', "''"))
257            .collect::<Vec<_>>()
258            .join(",")
259    }
260}
261
262impl From<&str> for Publication {
263    fn from(value: &str) -> Self {
264        Self(vec![value.to_string()])
265    }
266}
267
268impl From<String> for Publication {
269    fn from(value: String) -> Self {
270        Self(vec![value])
271    }
272}
273
274impl<A: Into<String>, const N: usize> From<[A; N]> for Publication {
275    fn from(value: [A; N]) -> Self {
276        Self(value.into_iter().map(Into::into).collect())
277    }
278}
279
280impl<A: Into<String>> From<Vec<A>> for Publication {
281    fn from(value: Vec<A>) -> Self {
282        Self(value.into_iter().map(Into::into).collect())
283    }
284}
285
286impl<A: Into<String>> FromIterator<A> for Publication {
287    fn from_iter<T: IntoIterator<Item = A>>(iter: T) -> Self {
288        Self(iter.into_iter().map(Into::into).collect())
289    }
290}
291
292impl std::fmt::Display for Publication {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        write!(f, "{}", self.0.join(","))
295    }
296}
297
298/// Configuration for PostgreSQL logical replication connections.
299///
300/// # Example
301///
302/// ```
303/// use pgwire_replication::config::{ReplicationConfig, TlsConfig};
304///
305/// let config = ReplicationConfig::new(
306///     "db.example.com",
307///     "replicator",
308///     "secret",
309///     "mydb",
310///     "my_slot",
311///     "my_publication",
312/// )
313/// .with_tls(TlsConfig::verify_full(Some("/path/to/ca.pem".into())));
314/// ```
315///
316/// This struct is `#[non_exhaustive]`: construct it with [`ReplicationConfig::new`]
317/// (or [`unix`](ReplicationConfig::unix)) plus the `with_*` builder methods
318/// rather than a struct literal, so new fields can be added without breaking you.
319#[derive(Debug, Clone, PartialEq, Eq)]
320#[non_exhaustive]
321pub struct ReplicationConfig {
322    /// PostgreSQL server hostname or IP address.
323    pub host: String,
324
325    /// PostgreSQL server port (default: 5432).
326    pub port: u16,
327
328    /// PostgreSQL username with replication privileges.
329    ///
330    /// The user must have the `REPLICATION` attribute or be a superuser.
331    pub user: String,
332
333    /// Password for authentication.
334    pub password: String,
335
336    /// Database name to connect to.
337    pub database: String,
338
339    /// TLS/SSL configuration.
340    pub tls: TlsConfig,
341
342    /// Name of the replication slot to use.
343    ///
344    /// The slot must already exist and be a logical replication slot
345    /// using the `pgoutput` plugin.
346    pub slot: String,
347
348    /// Publication(s) to subscribe to.
349    ///
350    /// Each publication must exist and include the tables you want to replicate.
351    /// Accepts a single name or a collection — see [`Publication`].
352    pub publication: Publication,
353
354    /// LSN position to start replication from.
355    ///
356    /// - `Lsn(0)`: Start from slot's `confirmed_flush_lsn`
357    /// - Specific LSN: Resume from that position (must be >= slot's restart_lsn)
358    pub start_lsn: Lsn,
359
360    /// Optional LSN to stop replication at.
361    ///
362    /// When set, replication will stop once a commit with `end_lsn >= stop_at_lsn`
363    /// is received. Useful for:
364    /// - Bounded replay (e.g., point-in-time recovery)
365    /// - Testing with known data ranges
366    ///
367    /// If `None`, replication continues indefinitely (normal CDC mode).
368    pub stop_at_lsn: Option<Lsn>,
369
370    /// Interval for sending standby status updates to the server.
371    ///
372    /// Status updates inform PostgreSQL of the client's replay position,
373    /// allowing the server to release WAL segments. Too infrequent updates
374    /// may cause WAL accumulation; too frequent updates add overhead.
375    ///
376    /// Default: 1 second (matches pg_recvlogical)
377    pub status_interval: Duration,
378
379    /// Maximum time to wait for server messages before waking up.
380    ///
381    /// Silence is normal during logical replication. When this interval elapses
382    /// with no incoming messages, the client will send a standby status update
383    /// (feedback) and continue waiting.
384    ///
385    /// This effectively bounds how long the worker can stay blocked in a read
386    /// while idle.
387    ///
388    /// Default: 10 seconds
389    pub idle_wakeup_interval: Duration,
390
391    /// Size of the bounded event buffer between replication worker and consumer.
392    ///
393    /// Larger buffers can smooth out processing latency spikes but use more memory.
394    /// Each event is typically 100-1000 bytes depending on row size.
395    ///
396    /// Default: 8192 events
397    pub buffer_events: usize,
398
399    /// Request column values in PostgreSQL binary wire format.
400    ///
401    /// When `true`, the `binary 'true'` option is added to `START_REPLICATION`
402    /// and `pgoutput` encodes column values using each type's binary send
403    /// function instead of text output.
404    ///
405    /// This does not change anything the library parses — the worker decodes
406    /// only Begin/Commit/Message boundaries and forwards `XLogData` tuple bytes
407    /// raw — so it is the **consumer's** decoder that must handle binary values.
408    ///
409    /// Leave as `false` (the default) unless you control both ends and have
410    /// measured a real win: any column whose type lacks a binary send function
411    /// makes the walsender error and close the replication stream.
412    ///
413    /// Requires PostgreSQL 14 or newer.
414    ///
415    /// Default: `false`
416    pub binary: bool,
417}
418
419impl Default for ReplicationConfig {
420    fn default() -> Self {
421        Self {
422            host: "127.0.0.1".into(),
423            port: 5432,
424            user: "postgres".into(),
425            password: "postgres".into(),
426            database: "postgres".into(),
427            tls: TlsConfig::default(),
428            slot: "slot".into(),
429            publication: "pub".into(),
430            start_lsn: Lsn(0),
431            stop_at_lsn: None,
432            status_interval: Duration::from_secs(10),
433            idle_wakeup_interval: Duration::from_secs(10),
434            buffer_events: 8192,
435            binary: false,
436        }
437    }
438}
439
440impl ReplicationConfig {
441    /// Create a new configuration with required fields.
442    ///
443    /// Other fields use defaults and can be customized with builder methods.
444    ///
445    /// # Example
446    /// ```
447    /// use pgwire_replication::config::ReplicationConfig;
448    ///
449    /// let config = ReplicationConfig::new(
450    ///     "db.example.com",
451    ///     "replicator",
452    ///     "secret",
453    ///     "mydb",
454    ///     "my_slot",
455    ///     "my_pub",
456    /// );
457    /// ```
458    pub fn new(
459        host: impl Into<String>,
460        user: impl Into<String>,
461        password: impl Into<String>,
462        database: impl Into<String>,
463        slot: impl Into<String>,
464        publication: impl Into<Publication>,
465    ) -> Self {
466        Self {
467            host: host.into(),
468            user: user.into(),
469            password: password.into(),
470            database: database.into(),
471            slot: slot.into(),
472            publication: publication.into(),
473            ..Default::default()
474        }
475    }
476
477    /// Returns `true` if `host` refers to a Unix domain socket directory.
478    ///
479    /// Following libpq convention, a host starting with `/` is treated as
480    /// the directory containing the PostgreSQL Unix socket file.
481    #[inline]
482    pub fn is_unix_socket(&self) -> bool {
483        self.host.starts_with('/')
484    }
485
486    /// Returns the full Unix socket path: `{host}/.s.PGSQL.{port}`.
487    ///
488    /// # Panics
489    ///
490    /// Panics if `host` does not start with `/` (i.e. `is_unix_socket()` is false).
491    pub fn unix_socket_path(&self) -> std::path::PathBuf {
492        assert!(
493            self.is_unix_socket(),
494            "unix_socket_path() called but host is not a socket directory: {:?}",
495            self.host
496        );
497        std::path::Path::new(&self.host).join(format!(".s.PGSQL.{}", self.port))
498    }
499
500    /// Create a configuration for connecting via Unix domain socket.
501    ///
502    /// `socket_dir` is the directory containing the PostgreSQL socket file
503    /// (e.g. `/var/run/postgresql`). The actual socket path will be
504    /// `{socket_dir}/.s.PGSQL.{port}`.
505    ///
506    /// TLS is automatically disabled for Unix socket connections.
507    ///
508    /// # Example
509    /// ```
510    /// use pgwire_replication::config::ReplicationConfig;
511    ///
512    /// let config = ReplicationConfig::unix(
513    ///     "/var/run/postgresql",
514    ///     5432,
515    ///     "replicator",
516    ///     "secret",
517    ///     "mydb",
518    ///     "my_slot",
519    ///     "my_pub",
520    /// );
521    /// assert!(config.is_unix_socket());
522    /// ```
523    pub fn unix(
524        socket_dir: impl Into<String>,
525        port: u16,
526        user: impl Into<String>,
527        password: impl Into<String>,
528        database: impl Into<String>,
529        slot: impl Into<String>,
530        publication: impl Into<Publication>,
531    ) -> Self {
532        Self {
533            host: socket_dir.into(),
534            port,
535            user: user.into(),
536            password: password.into(),
537            database: database.into(),
538            tls: TlsConfig::disabled(),
539            slot: slot.into(),
540            publication: publication.into(),
541            ..Default::default()
542        }
543    }
544
545    /// Set the server port.
546    pub fn with_port(mut self, port: u16) -> Self {
547        self.port = port;
548        self
549    }
550
551    /// Set TLS configuration.
552    pub fn with_tls(mut self, tls: TlsConfig) -> Self {
553        self.tls = tls;
554        self
555    }
556
557    /// Set the starting LSN.
558    pub fn with_start_lsn(mut self, lsn: Lsn) -> Self {
559        self.start_lsn = lsn;
560        self
561    }
562
563    /// Set an optional stop LSN for bounded replay.
564    pub fn with_stop_lsn(mut self, lsn: Lsn) -> Self {
565        self.stop_at_lsn = Some(lsn);
566        self
567    }
568
569    /// Set the status update interval.
570    pub fn with_status_interval(mut self, interval: Duration) -> Self {
571        self.status_interval = interval;
572        self
573    }
574
575    /// Set the idle wakeup interval.
576    pub fn with_wakeup_interval(mut self, timeout: Duration) -> Self {
577        self.idle_wakeup_interval = timeout;
578        self
579    }
580
581    /// Set the event buffer size.
582    pub fn with_buffer_size(mut self, size: usize) -> Self {
583        self.buffer_events = size;
584        self
585    }
586
587    /// Request binary-format column values from `pgoutput` (requires PG 14+).
588    ///
589    /// See [`binary`](Self::binary) for caveats — off by default.
590    pub fn with_binary(mut self, binary: bool) -> Self {
591        self.binary = binary;
592        self
593    }
594
595    /// Returns the connection string for display (password masked).
596    ///
597    /// Useful for logging without exposing credentials.
598    pub fn display_connection(&self) -> String {
599        if self.is_unix_socket() {
600            format!(
601                "postgresql://{}:***@[{}]:{}/{}",
602                self.user,
603                self.unix_socket_path().display(),
604                self.port,
605                self.database
606            )
607        } else {
608            format!(
609                "postgresql://{}:***@{}:{}/{}",
610                self.user, self.host, self.port, self.database
611            )
612        }
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn publication_from_single_str() {
622        let p: Publication = "orders".into();
623        assert_eq!(p.names(), ["orders"]);
624        assert_eq!(p.to_option_value(), "orders");
625    }
626
627    #[test]
628    fn publication_from_array_is_comma_joined() {
629        let p: Publication = ["orders", "customers"].into();
630        assert_eq!(p.names(), ["orders", "customers"]);
631        assert_eq!(p.to_option_value(), "orders,customers");
632    }
633
634    #[test]
635    fn publication_from_vec_and_from_iter() {
636        let from_vec: Publication = vec![String::from("a"), String::from("b")].into();
637        assert_eq!(from_vec.to_option_value(), "a,b");
638
639        let collected: Publication = ["x", "y", "z"].into_iter().collect();
640        assert_eq!(collected.to_option_value(), "x,y,z");
641    }
642
643    #[test]
644    fn publication_escapes_single_quotes_per_name() {
645        let p: Publication = ["a'b", "c"].into();
646        // Each single quote is doubled; names remain comma-separated.
647        assert_eq!(p.to_option_value(), "a''b,c");
648    }
649
650    #[test]
651    fn publication_display_is_plain_join_without_escaping() {
652        let p: Publication = ["a'b", "c"].into();
653        assert_eq!(p.to_string(), "a'b,c");
654    }
655
656    #[test]
657    fn sslmode_verification_predicates() {
658        assert!(SslMode::VerifyCa.verifies_certificate());
659        assert!(SslMode::VerifyFull.verifies_certificate());
660        assert!(!SslMode::Require.verifies_certificate());
661
662        assert!(SslMode::VerifyFull.verifies_hostname());
663        assert!(!SslMode::VerifyCa.verifies_hostname());
664        assert!(!SslMode::Require.verifies_hostname());
665    }
666
667    #[test]
668    fn verify_ca_sets_mode_and_ca_path() {
669        let tls = TlsConfig::verify_ca(Some("/ca.pem".into()));
670        assert_eq!(tls.mode, SslMode::VerifyCa);
671        assert_eq!(tls.ca_pem_path, Some("/ca.pem".into()));
672    }
673
674    #[test]
675    fn is_mtls_requires_both_cert_and_key() {
676        let both = TlsConfig::verify_full(None).with_client_cert("/c.pem", "/k.pem");
677        assert!(both.is_mtls());
678
679        let neither = TlsConfig::verify_full(None);
680        assert!(!neither.is_mtls());
681
682        let cert_only = TlsConfig {
683            client_key_pem_path: None,
684            ..TlsConfig::verify_full(None).with_client_cert("/c.pem", "/k.pem")
685        };
686        assert!(!cert_only.is_mtls());
687    }
688
689    #[test]
690    fn is_unix_socket_and_path() {
691        let tcp = ReplicationConfig::new("127.0.0.1", "u", "p", "db", "s", "pub");
692        assert!(!tcp.is_unix_socket());
693
694        let unix = ReplicationConfig::unix("/var/run/postgresql", 5432, "u", "p", "db", "s", "pub");
695        assert!(unix.is_unix_socket());
696        assert_eq!(
697            unix.unix_socket_path(),
698            std::path::PathBuf::from("/var/run/postgresql/.s.PGSQL.5432")
699        );
700    }
701
702    #[test]
703    fn display_connection_masks_password_and_shows_target() {
704        let cfg = ReplicationConfig::new("db.example.com", "alice", "secret", "mydb", "s", "pub")
705            .with_port(6432);
706        let shown = cfg.display_connection();
707        assert!(shown.contains("alice"));
708        assert!(shown.contains("db.example.com"));
709        assert!(shown.contains("6432"));
710        assert!(shown.contains("mydb"));
711        assert!(shown.contains("***"));
712        assert!(!shown.contains("secret"));
713    }
714
715    #[test]
716    fn binary_defaults_off_and_builder_sets_it() {
717        assert!(!ReplicationConfig::default().binary);
718        let cfg = ReplicationConfig::new("h", "u", "p", "db", "slot", "pub").with_binary(true);
719        assert!(cfg.binary);
720    }
721
722    #[test]
723    fn new_accepts_single_and_multiple_publications() {
724        let single = ReplicationConfig::new("h", "u", "p", "db", "slot", "solo");
725        assert_eq!(single.publication.to_option_value(), "solo");
726
727        let multi = ReplicationConfig::new("h", "u", "p", "db", "slot", ["one", "two"]);
728        assert_eq!(multi.publication.to_option_value(), "one,two");
729    }
730}