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}