Skip to main content

ssh_commander_core/postgres/
config.rs

1//! PostgreSQL connection configuration.
2//!
3//! Mirrors the shape of `SshConfig` / `SftpConfig` so the macOS bridge can
4//! map a `ConnectionProfile` onto a `PgConfig` with the same conventions
5//! used for every other protocol.
6
7use serde::{Deserialize, Serialize};
8
9/// How to authenticate to the Postgres server.
10///
11/// `Keychain` defers credential lookup to the macOS keychain at connect time;
12/// this matches the SSH/SFTP pattern and keeps secrets out of memory until
13/// they are actually required.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub enum PgAuthMethod {
16    /// Plaintext password supplied directly. Use only for ephemeral test
17    /// connections; production callers should prefer `Keychain`.
18    Password { password: String },
19    /// Resolve the password from the macOS keychain at connect time, using
20    /// the supplied `account` identifier (e.g. `"postgres:profile-id"`).
21    Keychain { account: String },
22}
23
24/// TLS posture for the connection.
25///
26/// Modeled after libpq's `sslmode`. The MVP supports the four most useful
27/// values; `allow` is omitted because it negotiates plaintext on failure
28/// and silently weakens security in a way no UI affordance can clarify.
29#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
30pub enum PgTlsMode {
31    /// Never use TLS.
32    Disable,
33    /// Try TLS, fall back to plaintext on negotiation failure.
34    #[default]
35    Prefer,
36    /// Require TLS, but do not verify the server certificate.
37    Require,
38    /// Require TLS and validate the server certificate against system roots.
39    VerifyFull,
40}
41
42/// Reference to an existing `ConnectionManager`-managed SSH connection that
43/// should be used as a `direct-tcpip` tunnel for this Postgres connection.
44///
45/// Holding only the `connection_id` (rather than an `Arc<RwLock<SshClient>>`)
46/// keeps the config purely data — the actual `SshClient` is resolved at
47/// connect time from the manager, so a tunnel can be re-established after
48/// SSH reconnect without rewriting the Postgres profile.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SshTunnelRef {
51    /// `ConnectionManager` ID of the SSH connection to tunnel through.
52    pub ssh_connection_id: String,
53    /// Host to forward to, as seen from the SSH server.
54    pub remote_host: String,
55    /// Port to forward to, as seen from the SSH server.
56    pub remote_port: u16,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct PgConfig {
61    pub host: String,
62    pub port: u16,
63    pub database: String,
64    pub user: String,
65    pub auth: PgAuthMethod,
66    #[serde(default)]
67    pub tls: PgTlsMode,
68    /// Optional application_name reported to the server. Surfaces nicely in
69    /// `pg_stat_activity` so DBAs can identify connections from r-shell.
70    #[serde(default)]
71    pub application_name: Option<String>,
72    /// Optional SSH tunnel. When `Some`, the connection is established to
73    /// `127.0.0.1:<ephemeral>` after the tunnel is spliced.
74    #[serde(default)]
75    pub ssh_tunnel: Option<SshTunnelRef>,
76    /// Connection timeout, seconds. `None` falls back to the driver default.
77    #[serde(default)]
78    pub connect_timeout_secs: Option<u64>,
79    /// Maximum number of connections this profile's pool may open.
80    /// `None` keeps the built-in default. Tighten on managed-DB
81    /// providers with strict `max_connections` quotas.
82    #[serde(default)]
83    pub max_pool_size: Option<u32>,
84    /// How long an idle connection lingers before the eviction loop
85    /// closes it, in seconds. `None` keeps the built-in default.
86    /// Lower values are politer to providers that bill on connection
87    /// hours (RDS, Neon).
88    #[serde(default)]
89    pub idle_timeout_secs: Option<u64>,
90    /// Minimum idle connections to keep alive even past
91    /// `idle_timeout_secs`. `Some(0)` lets a profile fully evacuate
92    /// during inactivity at the cost of a reconnect on next use.
93    #[serde(default)]
94    pub min_idle_connections: Option<u32>,
95}
96
97impl PgConfig {
98    /// Sensible local-development default — useful in tests and the bridge's
99    /// "new connection" flow.
100    pub fn local(database: impl Into<String>, user: impl Into<String>) -> Self {
101        Self {
102            host: "127.0.0.1".to_string(),
103            port: 5432,
104            database: database.into(),
105            user: user.into(),
106            auth: PgAuthMethod::Password {
107                password: String::new(),
108            },
109            tls: PgTlsMode::Disable,
110            application_name: Some("r-shell".to_string()),
111            ssh_tunnel: None,
112            connect_timeout_secs: Some(10),
113            max_pool_size: None,
114            idle_timeout_secs: None,
115            min_idle_connections: None,
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn local_defaults_disable_tls_and_set_app_name() {
126        let cfg = PgConfig::local("mydb", "alice");
127        assert_eq!(cfg.host, "127.0.0.1");
128        assert_eq!(cfg.port, 5432);
129        assert_eq!(cfg.database, "mydb");
130        assert_eq!(cfg.user, "alice");
131        assert_eq!(cfg.tls, PgTlsMode::Disable);
132        assert_eq!(cfg.application_name.as_deref(), Some("r-shell"));
133        assert!(cfg.ssh_tunnel.is_none());
134    }
135
136    #[test]
137    fn tls_mode_default_is_prefer() {
138        assert_eq!(PgTlsMode::default(), PgTlsMode::Prefer);
139    }
140
141    #[test]
142    fn config_round_trips_through_serde() {
143        let cfg = PgConfig {
144            host: "db.example.com".to_string(),
145            port: 5433,
146            database: "app".to_string(),
147            user: "svc".to_string(),
148            auth: PgAuthMethod::Keychain {
149                account: "postgres:profile-1".to_string(),
150            },
151            tls: PgTlsMode::VerifyFull,
152            application_name: Some("r-shell".to_string()),
153            ssh_tunnel: Some(SshTunnelRef {
154                ssh_connection_id: "ssh-1".to_string(),
155                remote_host: "db.internal".to_string(),
156                remote_port: 5432,
157            }),
158            connect_timeout_secs: Some(15),
159            max_pool_size: Some(10),
160            idle_timeout_secs: Some(120),
161            min_idle_connections: Some(0),
162        };
163        let json = serde_json::to_string(&cfg).expect("serialize");
164        let back: PgConfig = serde_json::from_str(&json).expect("deserialize");
165        assert_eq!(back.host, cfg.host);
166        assert_eq!(back.tls, cfg.tls);
167        assert!(back.ssh_tunnel.is_some());
168        assert_eq!(back.max_pool_size, Some(10));
169        assert_eq!(back.idle_timeout_secs, Some(120));
170        assert_eq!(back.min_idle_connections, Some(0));
171    }
172
173    #[test]
174    fn local_defaults_pool_settings_to_none() {
175        // None means "use built-in default". The pool reads these
176        // and substitutes its constants when absent.
177        let cfg = PgConfig::local("db", "u");
178        assert_eq!(cfg.max_pool_size, None);
179        assert_eq!(cfg.idle_timeout_secs, None);
180        assert_eq!(cfg.min_idle_connections, None);
181    }
182}