use crate::config::PostgresConfig;
use crate::error::{Error, Result};
use lmrc_ssh::SshClient;
use tracing::{debug, info, warn};
#[derive(Debug, Clone)]
pub struct ConfigBackup {
pub timestamp: String,
pub version: String,
pub backup_dir: String,
pub files: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ConfigHistoryEntry {
pub timestamp: String,
pub changes: Vec<String>,
pub backup_id: String,
}
pub async fn backup_config(ssh: &mut SshClient, config: &PostgresConfig) -> Result<ConfigBackup> {
info!("Creating configuration backup");
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
let backup_dir = format!("/var/backups/postgresql/{}", timestamp);
let config_dir = config.config_dir();
debug!("Creating backup directory: {}", backup_dir);
ssh.execute(&format!("mkdir -p {}", backup_dir))
.map_err(|e| Error::Configuration(format!("Failed to create backup directory: {}", e)))?;
let mut backed_up_files = Vec::new();
let postgresql_conf = format!("{}/postgresql.conf", config_dir);
if ssh.execute(&format!("test -f {}", postgresql_conf)).is_ok() {
ssh.execute(&format!(
"cp {} {}/postgresql.conf",
postgresql_conf, backup_dir
))
.map_err(|e| Error::Configuration(format!("Failed to backup postgresql.conf: {}", e)))?;
backed_up_files.push("postgresql.conf".to_string());
debug!("✓ Backed up postgresql.conf");
}
let pg_hba_conf = format!("{}/pg_hba.conf", config_dir);
if ssh.execute(&format!("test -f {}", pg_hba_conf)).is_ok() {
ssh.execute(&format!("cp {} {}/pg_hba.conf", pg_hba_conf, backup_dir))
.map_err(|e| Error::Configuration(format!("Failed to backup pg_hba.conf: {}", e)))?;
backed_up_files.push("pg_hba.conf".to_string());
debug!("✓ Backed up pg_hba.conf");
}
let pg_ident_conf = format!("{}/pg_ident.conf", config_dir);
if ssh.execute(&format!("test -f {}", pg_ident_conf)).is_ok() {
ssh.execute(&format!(
"cp {} {}/pg_ident.conf",
pg_ident_conf, backup_dir
))
.ok(); backed_up_files.push("pg_ident.conf".to_string());
debug!("✓ Backed up pg_ident.conf");
}
let metadata = format!(
"timestamp={}\nversion={}\nfiles={}\n",
timestamp,
config.version,
backed_up_files.join(",")
);
ssh.execute(&format!("echo '{}' > {}/backup.meta", metadata, backup_dir))
.map_err(|e| Error::Configuration(format!("Failed to save backup metadata: {}", e)))?;
info!(
"✓ Configuration backup created: {} ({} files)",
backup_dir,
backed_up_files.len()
);
Ok(ConfigBackup {
timestamp,
version: config.version.clone(),
backup_dir,
files: backed_up_files,
})
}
pub async fn list_backups(ssh: &mut SshClient) -> Result<Vec<ConfigBackup>> {
debug!("Listing configuration backups");
let backup_base = "/var/backups/postgresql";
if ssh.execute(&format!("test -d {}", backup_base)).is_err() {
return Ok(Vec::new());
}
let output = ssh
.execute(&format!("ls -1 {}", backup_base))
.map_err(|e| Error::Configuration(format!("Failed to list backups: {}", e)))?;
let mut backups = Vec::new();
for line in output.stdout.lines() {
let timestamp = line.trim();
if timestamp.is_empty() {
continue;
}
let backup_dir = format!("{}/{}", backup_base, timestamp);
let meta_result = ssh.execute(&format!("cat {}/backup.meta 2>/dev/null", backup_dir));
let (version, files) = if let Ok(meta_output) = meta_result {
let mut ver = String::new();
let mut file_list = Vec::new();
for meta_line in meta_output.stdout.lines() {
if let Some(val) = meta_line.strip_prefix("version=") {
ver = val.to_string();
} else if let Some(val) = meta_line.strip_prefix("files=") {
file_list = val.split(',').map(|s| s.to_string()).collect();
}
}
(ver, file_list)
} else {
(String::new(), Vec::new())
};
backups.push(ConfigBackup {
timestamp: timestamp.to_string(),
version,
backup_dir,
files,
});
}
debug!("Found {} backup(s)", backups.len());
Ok(backups)
}
pub async fn restore_backup(
ssh: &mut SshClient,
config: &PostgresConfig,
backup: &ConfigBackup,
) -> Result<()> {
info!("Restoring configuration from backup: {}", backup.timestamp);
let config_dir = config.config_dir();
if backup.files.contains(&"postgresql.conf".to_string()) {
ssh.execute(&format!(
"cp {}/postgresql.conf {}/postgresql.conf",
backup.backup_dir, config_dir
))
.map_err(|e| Error::Configuration(format!("Failed to restore postgresql.conf: {}", e)))?;
debug!("✓ Restored postgresql.conf");
}
if backup.files.contains(&"pg_hba.conf".to_string()) {
ssh.execute(&format!(
"cp {}/pg_hba.conf {}/pg_hba.conf",
backup.backup_dir, config_dir
))
.map_err(|e| Error::Configuration(format!("Failed to restore pg_hba.conf: {}", e)))?;
debug!("✓ Restored pg_hba.conf");
}
if backup.files.contains(&"pg_ident.conf".to_string()) {
ssh.execute(&format!(
"cp {}/pg_ident.conf {}/pg_ident.conf",
backup.backup_dir, config_dir
))
.ok(); debug!("✓ Restored pg_ident.conf");
}
info!("✓ Configuration restored successfully");
warn!("Configuration restored. Run 'systemctl reload postgresql' to apply changes.");
Ok(())
}
pub async fn rollback_config(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
info!("Rolling back to most recent configuration backup");
let backups = list_backups(ssh).await?;
if backups.is_empty() {
return Err(Error::Configuration(
"No configuration backups found".to_string(),
));
}
let latest_backup = &backups[backups.len() - 1];
restore_backup(ssh, config, latest_backup).await?;
info!("✓ Rolled back to backup: {}", latest_backup.timestamp);
Ok(())
}
pub async fn cleanup_old_backups(ssh: &mut SshClient, keep_count: usize) -> Result<usize> {
debug!("Cleaning up old backups, keeping {}", keep_count);
let backups = list_backups(ssh).await?;
if backups.len() <= keep_count {
debug!("No backups to clean up");
return Ok(0);
}
let to_delete = backups.len() - keep_count;
let mut deleted = 0;
for backup in backups.iter().take(to_delete) {
ssh.execute(&format!("rm -rf {}", backup.backup_dir))
.map_err(|e| Error::Configuration(format!("Failed to delete backup: {}", e)))?;
deleted += 1;
debug!("Deleted old backup: {}", backup.timestamp);
}
info!("✓ Cleaned up {} old backup(s)", deleted);
Ok(deleted)
}
pub async fn read_pg_hba(ssh: &mut SshClient, config: &PostgresConfig) -> Result<String> {
let pg_hba_path = config.pg_hba_conf_path();
let output = ssh
.execute(&format!("cat {}", pg_hba_path))
.map_err(|e| Error::Configuration(format!("Failed to read pg_hba.conf: {}", e)))?;
Ok(output.stdout)
}
pub fn parse_pg_hba_rules(content: &str) -> Vec<PgHbaRule> {
let mut rules = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 4 {
rules.push(PgHbaRule {
rule_type: parts[0].to_string(),
database: parts[1].to_string(),
user: parts[2].to_string(),
address: if parts.len() >= 5 {
Some(parts[3].to_string())
} else {
None
},
method: parts[parts.len() - 1].to_string(),
raw_line: line.to_string(),
});
}
}
rules
}
#[derive(Debug, Clone, PartialEq)]
pub struct PgHbaRule {
pub rule_type: String,
pub database: String,
pub user: String,
pub address: Option<String>,
pub method: String,
pub raw_line: String,
}
impl std::fmt::Display for PgHbaRule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(ref addr) = self.address {
write!(
f,
"{:<10} {:<15} {:<15} {:<18} {}",
self.rule_type, self.database, self.user, addr, self.method
)
} else {
write!(
f,
"{:<10} {:<15} {:<15} {}",
self.rule_type, self.database, self.user, self.method
)
}
}
}
pub fn diff_pg_hba_rules(current: &[PgHbaRule], desired: &[PgHbaRule]) -> Vec<PgHbaDiff> {
let mut diffs = Vec::new();
for (idx, current_rule) in current.iter().enumerate() {
if let Some(desired_rule) = desired.get(idx) {
if current_rule != desired_rule {
diffs.push(PgHbaDiff::Modified {
line_number: idx + 1,
old: current_rule.clone(),
new: desired_rule.clone(),
});
}
} else {
diffs.push(PgHbaDiff::Removed {
line_number: idx + 1,
rule: current_rule.clone(),
});
}
}
for (idx, desired_rule) in desired.iter().enumerate() {
if idx >= current.len() {
diffs.push(PgHbaDiff::Added {
line_number: idx + 1,
rule: desired_rule.clone(),
});
}
}
diffs
}
#[derive(Debug, Clone)]
pub enum PgHbaDiff {
Added { line_number: usize, rule: PgHbaRule },
Removed { line_number: usize, rule: PgHbaRule },
Modified {
line_number: usize,
old: PgHbaRule,
new: PgHbaRule,
},
}
impl std::fmt::Display for PgHbaDiff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PgHbaDiff::Added { line_number, rule } => {
write!(f, "[+] Line {}: {}", line_number, rule)
}
PgHbaDiff::Removed { line_number, rule } => {
write!(f, "[-] Line {}: {}", line_number, rule)
}
PgHbaDiff::Modified {
line_number,
old,
new,
} => {
write!(f, "[~] Line {}: {} → {}", line_number, old, new)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pg_hba_rules() {
let content = r#"
# Comment line
local all postgres peer
# IPv4 local connections:
host all all 127.0.0.1/32 md5
host all all 0.0.0.0/0 md5
"#;
let rules = parse_pg_hba_rules(content);
assert_eq!(rules.len(), 3);
assert_eq!(rules[0].rule_type, "local");
assert_eq!(rules[0].database, "all");
assert_eq!(rules[0].user, "postgres");
assert_eq!(rules[0].method, "peer");
assert_eq!(rules[0].address, None);
assert_eq!(rules[1].rule_type, "host");
assert_eq!(rules[1].database, "all");
assert_eq!(rules[1].user, "all");
assert_eq!(rules[1].address, Some("127.0.0.1/32".to_string()));
assert_eq!(rules[1].method, "md5");
}
#[test]
fn test_diff_pg_hba_rules() {
let current = vec![
PgHbaRule {
rule_type: "local".to_string(),
database: "all".to_string(),
user: "postgres".to_string(),
address: None,
method: "peer".to_string(),
raw_line: "local all postgres peer"
.to_string(),
},
PgHbaRule {
rule_type: "host".to_string(),
database: "all".to_string(),
user: "all".to_string(),
address: Some("127.0.0.1/32".to_string()),
method: "md5".to_string(),
raw_line: "host all all 127.0.0.1/32 md5"
.to_string(),
},
];
let desired = vec![
PgHbaRule {
rule_type: "local".to_string(),
database: "all".to_string(),
user: "postgres".to_string(),
address: None,
method: "trust".to_string(), raw_line: "local all postgres trust"
.to_string(),
},
PgHbaRule {
rule_type: "host".to_string(),
database: "all".to_string(),
user: "all".to_string(),
address: Some("127.0.0.1/32".to_string()),
method: "md5".to_string(),
raw_line: "host all all 127.0.0.1/32 md5"
.to_string(),
},
PgHbaRule {
rule_type: "host".to_string(),
database: "all".to_string(),
user: "all".to_string(),
address: Some("0.0.0.0/0".to_string()),
method: "md5".to_string(),
raw_line: "host all all 0.0.0.0/0 md5"
.to_string(),
},
];
let diffs = diff_pg_hba_rules(¤t, &desired);
assert_eq!(diffs.len(), 2);
assert!(matches!(diffs[0], PgHbaDiff::Modified { .. }));
assert!(matches!(diffs[1], PgHbaDiff::Added { .. }));
}
#[test]
fn test_pg_hba_rule_display() {
let rule = PgHbaRule {
rule_type: "host".to_string(),
database: "mydb".to_string(),
user: "myuser".to_string(),
address: Some("192.168.1.0/24".to_string()),
method: "md5".to_string(),
raw_line: "host mydb myuser 192.168.1.0/24 md5"
.to_string(),
};
let display = format!("{}", rule);
assert!(display.contains("host"));
assert!(display.contains("mydb"));
assert!(display.contains("192.168.1.0/24"));
}
}