1use crate::config::PostgresConfig;
10use crate::error::{Error, Result};
11use lmrc_ssh::SshClient;
12use tracing::{debug, info, warn};
13
14#[derive(Debug, Clone)]
16pub struct ConfigBackup {
17 pub timestamp: String,
19 pub version: String,
21 pub backup_dir: String,
23 pub files: Vec<String>,
25}
26
27#[derive(Debug, Clone)]
29pub struct ConfigHistoryEntry {
30 pub timestamp: String,
32 pub changes: Vec<String>,
34 pub backup_id: String,
36}
37
38pub async fn backup_config(ssh: &mut SshClient, config: &PostgresConfig) -> Result<ConfigBackup> {
47 info!("Creating configuration backup");
48
49 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
50 let backup_dir = format!("/var/backups/postgresql/{}", timestamp);
51 let config_dir = config.config_dir();
52
53 debug!("Creating backup directory: {}", backup_dir);
55 ssh.execute(&format!("mkdir -p {}", backup_dir))
56 .map_err(|e| Error::Configuration(format!("Failed to create backup directory: {}", e)))?;
57
58 let mut backed_up_files = Vec::new();
59
60 let postgresql_conf = format!("{}/postgresql.conf", config_dir);
62 if ssh.execute(&format!("test -f {}", postgresql_conf)).is_ok() {
63 ssh.execute(&format!(
64 "cp {} {}/postgresql.conf",
65 postgresql_conf, backup_dir
66 ))
67 .map_err(|e| Error::Configuration(format!("Failed to backup postgresql.conf: {}", e)))?;
68 backed_up_files.push("postgresql.conf".to_string());
69 debug!("✓ Backed up postgresql.conf");
70 }
71
72 let pg_hba_conf = format!("{}/pg_hba.conf", config_dir);
74 if ssh.execute(&format!("test -f {}", pg_hba_conf)).is_ok() {
75 ssh.execute(&format!("cp {} {}/pg_hba.conf", pg_hba_conf, backup_dir))
76 .map_err(|e| Error::Configuration(format!("Failed to backup pg_hba.conf: {}", e)))?;
77 backed_up_files.push("pg_hba.conf".to_string());
78 debug!("✓ Backed up pg_hba.conf");
79 }
80
81 let pg_ident_conf = format!("{}/pg_ident.conf", config_dir);
83 if ssh.execute(&format!("test -f {}", pg_ident_conf)).is_ok() {
84 ssh.execute(&format!(
85 "cp {} {}/pg_ident.conf",
86 pg_ident_conf, backup_dir
87 ))
88 .ok(); backed_up_files.push("pg_ident.conf".to_string());
90 debug!("✓ Backed up pg_ident.conf");
91 }
92
93 let metadata = format!(
95 "timestamp={}\nversion={}\nfiles={}\n",
96 timestamp,
97 config.version,
98 backed_up_files.join(",")
99 );
100 ssh.execute(&format!("echo '{}' > {}/backup.meta", metadata, backup_dir))
101 .map_err(|e| Error::Configuration(format!("Failed to save backup metadata: {}", e)))?;
102
103 info!(
104 "✓ Configuration backup created: {} ({} files)",
105 backup_dir,
106 backed_up_files.len()
107 );
108
109 Ok(ConfigBackup {
110 timestamp,
111 version: config.version.clone(),
112 backup_dir,
113 files: backed_up_files,
114 })
115}
116
117pub async fn list_backups(ssh: &mut SshClient) -> Result<Vec<ConfigBackup>> {
119 debug!("Listing configuration backups");
120
121 let backup_base = "/var/backups/postgresql";
122
123 if ssh.execute(&format!("test -d {}", backup_base)).is_err() {
125 return Ok(Vec::new());
126 }
127
128 let output = ssh
130 .execute(&format!("ls -1 {}", backup_base))
131 .map_err(|e| Error::Configuration(format!("Failed to list backups: {}", e)))?;
132
133 let mut backups = Vec::new();
134
135 for line in output.stdout.lines() {
136 let timestamp = line.trim();
137 if timestamp.is_empty() {
138 continue;
139 }
140
141 let backup_dir = format!("{}/{}", backup_base, timestamp);
142
143 let meta_result = ssh.execute(&format!("cat {}/backup.meta 2>/dev/null", backup_dir));
145
146 let (version, files) = if let Ok(meta_output) = meta_result {
147 let mut ver = String::new();
148 let mut file_list = Vec::new();
149
150 for meta_line in meta_output.stdout.lines() {
151 if let Some(val) = meta_line.strip_prefix("version=") {
152 ver = val.to_string();
153 } else if let Some(val) = meta_line.strip_prefix("files=") {
154 file_list = val.split(',').map(|s| s.to_string()).collect();
155 }
156 }
157
158 (ver, file_list)
159 } else {
160 (String::new(), Vec::new())
161 };
162
163 backups.push(ConfigBackup {
164 timestamp: timestamp.to_string(),
165 version,
166 backup_dir,
167 files,
168 });
169 }
170
171 debug!("Found {} backup(s)", backups.len());
172 Ok(backups)
173}
174
175pub async fn restore_backup(
177 ssh: &mut SshClient,
178 config: &PostgresConfig,
179 backup: &ConfigBackup,
180) -> Result<()> {
181 info!("Restoring configuration from backup: {}", backup.timestamp);
182
183 let config_dir = config.config_dir();
184
185 if backup.files.contains(&"postgresql.conf".to_string()) {
187 ssh.execute(&format!(
188 "cp {}/postgresql.conf {}/postgresql.conf",
189 backup.backup_dir, config_dir
190 ))
191 .map_err(|e| Error::Configuration(format!("Failed to restore postgresql.conf: {}", e)))?;
192 debug!("✓ Restored postgresql.conf");
193 }
194
195 if backup.files.contains(&"pg_hba.conf".to_string()) {
197 ssh.execute(&format!(
198 "cp {}/pg_hba.conf {}/pg_hba.conf",
199 backup.backup_dir, config_dir
200 ))
201 .map_err(|e| Error::Configuration(format!("Failed to restore pg_hba.conf: {}", e)))?;
202 debug!("✓ Restored pg_hba.conf");
203 }
204
205 if backup.files.contains(&"pg_ident.conf".to_string()) {
207 ssh.execute(&format!(
208 "cp {}/pg_ident.conf {}/pg_ident.conf",
209 backup.backup_dir, config_dir
210 ))
211 .ok(); debug!("✓ Restored pg_ident.conf");
213 }
214
215 info!("✓ Configuration restored successfully");
216
217 warn!("Configuration restored. Run 'systemctl reload postgresql' to apply changes.");
219
220 Ok(())
221}
222
223pub async fn rollback_config(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
225 info!("Rolling back to most recent configuration backup");
226
227 let backups = list_backups(ssh).await?;
228
229 if backups.is_empty() {
230 return Err(Error::Configuration(
231 "No configuration backups found".to_string(),
232 ));
233 }
234
235 let latest_backup = &backups[backups.len() - 1];
237
238 restore_backup(ssh, config, latest_backup).await?;
239
240 info!("✓ Rolled back to backup: {}", latest_backup.timestamp);
241
242 Ok(())
243}
244
245pub async fn cleanup_old_backups(ssh: &mut SshClient, keep_count: usize) -> Result<usize> {
247 debug!("Cleaning up old backups, keeping {}", keep_count);
248
249 let backups = list_backups(ssh).await?;
250
251 if backups.len() <= keep_count {
252 debug!("No backups to clean up");
253 return Ok(0);
254 }
255
256 let to_delete = backups.len() - keep_count;
257 let mut deleted = 0;
258
259 for backup in backups.iter().take(to_delete) {
261 ssh.execute(&format!("rm -rf {}", backup.backup_dir))
262 .map_err(|e| Error::Configuration(format!("Failed to delete backup: {}", e)))?;
263 deleted += 1;
264 debug!("Deleted old backup: {}", backup.timestamp);
265 }
266
267 info!("✓ Cleaned up {} old backup(s)", deleted);
268 Ok(deleted)
269}
270
271pub async fn read_pg_hba(ssh: &mut SshClient, config: &PostgresConfig) -> Result<String> {
273 let pg_hba_path = config.pg_hba_conf_path();
274
275 let output = ssh
276 .execute(&format!("cat {}", pg_hba_path))
277 .map_err(|e| Error::Configuration(format!("Failed to read pg_hba.conf: {}", e)))?;
278
279 Ok(output.stdout)
280}
281
282pub fn parse_pg_hba_rules(content: &str) -> Vec<PgHbaRule> {
284 let mut rules = Vec::new();
285
286 for line in content.lines() {
287 let trimmed = line.trim();
288
289 if trimmed.is_empty() || trimmed.starts_with('#') {
291 continue;
292 }
293
294 let parts: Vec<&str> = trimmed.split_whitespace().collect();
296 if parts.len() >= 4 {
297 rules.push(PgHbaRule {
298 rule_type: parts[0].to_string(),
299 database: parts[1].to_string(),
300 user: parts[2].to_string(),
301 address: if parts.len() >= 5 {
302 Some(parts[3].to_string())
303 } else {
304 None
305 },
306 method: parts[parts.len() - 1].to_string(),
307 raw_line: line.to_string(),
308 });
309 }
310 }
311
312 rules
313}
314
315#[derive(Debug, Clone, PartialEq)]
317pub struct PgHbaRule {
318 pub rule_type: String,
320 pub database: String,
322 pub user: String,
324 pub address: Option<String>,
326 pub method: String,
328 pub raw_line: String,
330}
331
332impl std::fmt::Display for PgHbaRule {
333 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334 if let Some(ref addr) = self.address {
335 write!(
336 f,
337 "{:<10} {:<15} {:<15} {:<18} {}",
338 self.rule_type, self.database, self.user, addr, self.method
339 )
340 } else {
341 write!(
342 f,
343 "{:<10} {:<15} {:<15} {}",
344 self.rule_type, self.database, self.user, self.method
345 )
346 }
347 }
348}
349
350pub fn diff_pg_hba_rules(current: &[PgHbaRule], desired: &[PgHbaRule]) -> Vec<PgHbaDiff> {
352 let mut diffs = Vec::new();
353
354 for (idx, current_rule) in current.iter().enumerate() {
356 if let Some(desired_rule) = desired.get(idx) {
357 if current_rule != desired_rule {
358 diffs.push(PgHbaDiff::Modified {
359 line_number: idx + 1,
360 old: current_rule.clone(),
361 new: desired_rule.clone(),
362 });
363 }
364 } else {
365 diffs.push(PgHbaDiff::Removed {
366 line_number: idx + 1,
367 rule: current_rule.clone(),
368 });
369 }
370 }
371
372 for (idx, desired_rule) in desired.iter().enumerate() {
374 if idx >= current.len() {
375 diffs.push(PgHbaDiff::Added {
376 line_number: idx + 1,
377 rule: desired_rule.clone(),
378 });
379 }
380 }
381
382 diffs
383}
384
385#[derive(Debug, Clone)]
387pub enum PgHbaDiff {
388 Added { line_number: usize, rule: PgHbaRule },
390 Removed { line_number: usize, rule: PgHbaRule },
392 Modified {
394 line_number: usize,
395 old: PgHbaRule,
396 new: PgHbaRule,
397 },
398}
399
400impl std::fmt::Display for PgHbaDiff {
401 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402 match self {
403 PgHbaDiff::Added { line_number, rule } => {
404 write!(f, "[+] Line {}: {}", line_number, rule)
405 }
406 PgHbaDiff::Removed { line_number, rule } => {
407 write!(f, "[-] Line {}: {}", line_number, rule)
408 }
409 PgHbaDiff::Modified {
410 line_number,
411 old,
412 new,
413 } => {
414 write!(f, "[~] Line {}: {} → {}", line_number, old, new)
415 }
416 }
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn test_parse_pg_hba_rules() {
426 let content = r#"
427# Comment line
428local all postgres peer
429
430# IPv4 local connections:
431host all all 127.0.0.1/32 md5
432host all all 0.0.0.0/0 md5
433"#;
434
435 let rules = parse_pg_hba_rules(content);
436 assert_eq!(rules.len(), 3);
437
438 assert_eq!(rules[0].rule_type, "local");
439 assert_eq!(rules[0].database, "all");
440 assert_eq!(rules[0].user, "postgres");
441 assert_eq!(rules[0].method, "peer");
442 assert_eq!(rules[0].address, None);
443
444 assert_eq!(rules[1].rule_type, "host");
445 assert_eq!(rules[1].database, "all");
446 assert_eq!(rules[1].user, "all");
447 assert_eq!(rules[1].address, Some("127.0.0.1/32".to_string()));
448 assert_eq!(rules[1].method, "md5");
449 }
450
451 #[test]
452 fn test_diff_pg_hba_rules() {
453 let current = vec![
454 PgHbaRule {
455 rule_type: "local".to_string(),
456 database: "all".to_string(),
457 user: "postgres".to_string(),
458 address: None,
459 method: "peer".to_string(),
460 raw_line: "local all postgres peer"
461 .to_string(),
462 },
463 PgHbaRule {
464 rule_type: "host".to_string(),
465 database: "all".to_string(),
466 user: "all".to_string(),
467 address: Some("127.0.0.1/32".to_string()),
468 method: "md5".to_string(),
469 raw_line: "host all all 127.0.0.1/32 md5"
470 .to_string(),
471 },
472 ];
473
474 let desired = vec![
475 PgHbaRule {
476 rule_type: "local".to_string(),
477 database: "all".to_string(),
478 user: "postgres".to_string(),
479 address: None,
480 method: "trust".to_string(), raw_line: "local all postgres trust"
482 .to_string(),
483 },
484 PgHbaRule {
485 rule_type: "host".to_string(),
486 database: "all".to_string(),
487 user: "all".to_string(),
488 address: Some("127.0.0.1/32".to_string()),
489 method: "md5".to_string(),
490 raw_line: "host all all 127.0.0.1/32 md5"
491 .to_string(),
492 },
493 PgHbaRule {
494 rule_type: "host".to_string(),
495 database: "all".to_string(),
496 user: "all".to_string(),
497 address: Some("0.0.0.0/0".to_string()),
498 method: "md5".to_string(),
499 raw_line: "host all all 0.0.0.0/0 md5"
500 .to_string(),
501 },
502 ];
503
504 let diffs = diff_pg_hba_rules(¤t, &desired);
505 assert_eq!(diffs.len(), 2);
506
507 assert!(matches!(diffs[0], PgHbaDiff::Modified { .. }));
509
510 assert!(matches!(diffs[1], PgHbaDiff::Added { .. }));
512 }
513
514 #[test]
515 fn test_pg_hba_rule_display() {
516 let rule = PgHbaRule {
517 rule_type: "host".to_string(),
518 database: "mydb".to_string(),
519 user: "myuser".to_string(),
520 address: Some("192.168.1.0/24".to_string()),
521 method: "md5".to_string(),
522 raw_line: "host mydb myuser 192.168.1.0/24 md5"
523 .to_string(),
524 };
525
526 let display = format!("{}", rule);
527 assert!(display.contains("host"));
528 assert!(display.contains("mydb"));
529 assert!(display.contains("192.168.1.0/24"));
530 }
531}