diff --git a/AUTHENTICATION_PLAN.md b/AUTHENTICATION_PLAN.md new file mode 100644 index 0000000..cd879bc --- /dev/null +++ b/AUTHENTICATION_PLAN.md @@ -0,0 +1,379 @@ +# Database Authentication Enhancement Plan + +## Current Situation Analysis + +### PostgreSQL Authentication Methods (by Distribution) + +#### Current System: CentOS Stream 10 +- **Local (Unix socket)**: `peer` authentication + - Requires OS username = PostgreSQL username + - Example: `sudo -u postgres ./dbbackup status --user postgres` ✅ + - Fails: `./dbbackup status --user postgres` ❌ (peer auth failed) + +- **TCP (localhost)**: `ident` authentication + - Uses identd protocol to verify OS username + - Similar to peer but over TCP + +#### Common PostgreSQL Auth Methods Across Distributions +1. **peer** - Unix socket only, OS user must match DB user +2. **ident** - TCP/IP, uses identd to verify OS user +3. **md5/scram-sha-256** - Password-based (most common for remote) +4. **trust** - No authentication (development only) +5. **cert** - SSL certificate-based +6. **ldap/pam** - Enterprise integration + +### MySQL/MariaDB Authentication +- Typically uses password-based authentication by default +- Can use unix_socket plugin (similar to peer) +- Less likely to have peer/ident issues + +## Problem Statement + +When user runs: +```bash +./dbbackup status --user postgres +``` + +The tool attempts to connect as "postgres" user, but: +1. **Root user context**: OS user is "root", PostgreSQL expects "postgres" +2. **Peer auth fails**: `FATAL: Peer authentication failed for user "postgres"` +3. **User must know**: Need `sudo -u postgres` or provide password + +## Solution Strategy: Multi-Level Authentication + +### Level 1: Smart OS User Detection (Quick Win) +**Goal**: Detect when OS user ≠ DB user and provide helpful guidance + +**Implementation**: +```go +// Check if OS user matches requested DB user +currentOSUser := getCurrentUser() +requestedDBUser := cfg.User + +if currentOSUser != requestedDBUser { + // Check authentication method + authMethod := detectPostgreSQLAuthMethod(cfg.Host, cfg.Port) + + if authMethod == "peer" || authMethod == "ident" { + // Peer/ident requires OS user = DB user + if cfg.Password == "" { + // No password provided, suggest sudo + log.Warn("Authentication mismatch detected", + "os_user", currentOSUser, + "db_user", requestedDBUser, + "auth_method", authMethod) + fmt.Printf("\n⚠️ Authentication Note:\n") + fmt.Printf(" PostgreSQL is using '%s' authentication\n", authMethod) + fmt.Printf(" OS user '%s' cannot authenticate as DB user '%s'\n", + currentOSUser, requestedDBUser) + fmt.Printf("\n💡 Solutions:\n") + fmt.Printf(" 1. Run as matching user: sudo -u %s %s\n", + requestedDBUser, os.Args[0]) + fmt.Printf(" 2. Provide password: %s --password \n", + os.Args[0]) + fmt.Printf(" 3. Set PGPASSWORD environment variable\n") + fmt.Printf(" 4. Configure ~/.pgpass file\n\n") + + return fmt.Errorf("authentication method requires matching OS user") + } + } +} +``` + +### Level 2: Auto-Sudo Wrapper (Medium Effort) +**Goal**: Automatically re-execute with sudo when needed + +**Implementation**: +```go +func autoSudoIfNeeded(cfg *Config) error { + currentUser := getCurrentUser() + + // Check if we need sudo and aren't already using it + if currentUser != cfg.User && os.Getenv("DBBACKUP_SUDO_RETRY") == "" { + authMethod := detectAuthMethod(cfg) + + if authMethod == "peer" || authMethod == "ident" { + if cfg.Password == "" { + fmt.Printf("🔄 Auto-retrying with sudo as user '%s'...\n", cfg.User) + + // Re-execute with sudo + cmd := exec.Command("sudo", "-u", cfg.User, os.Args[0], os.Args[1:]...) + cmd.Env = append(os.Environ(), "DBBACKUP_SUDO_RETRY=1") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("sudo retry failed: %w", err) + } + + os.Exit(cmd.ProcessState.ExitCode()) + } + } + } + + return nil +} +``` + +### Level 3: pgpass Support (High Value) +**Goal**: Use ~/.pgpass file for password-less authentication + +**Implementation**: +```go +// Check ~/.pgpass and /var/lib/pgsql/.pgpass +func loadPasswordFromPgpass(cfg *Config) (string, bool) { + pgpassLocations := []string{ + filepath.Join(os.Getenv("HOME"), ".pgpass"), + "/var/lib/pgsql/.pgpass", + filepath.Join("/home", cfg.User, ".pgpass"), + } + + for _, pgpassPath := range pgpassLocations { + if password := parsePgpass(pgpassPath, cfg); password != "" { + return password, true + } + } + + return "", false +} + +// Format: hostname:port:database:username:password +func parsePgpass(path string, cfg *Config) string { + file, err := os.Open(path) + if err != nil { + return "" + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.Split(line, ":") + if len(parts) != 5 { + continue + } + + host, port, db, user, pass := parts[0], parts[1], parts[2], parts[3], parts[4] + + // Match hostname (* = wildcard) + if host != "*" && host != cfg.Host { + continue + } + + // Match port (* = wildcard) + if port != "*" && port != strconv.Itoa(cfg.Port) { + continue + } + + // Match database (* = wildcard) + if db != "*" && db != cfg.Database { + continue + } + + // Match user (* = wildcard) + if user != "*" && user != cfg.User { + continue + } + + return pass + } + + return "" +} +``` + +### Level 4: Smart Environment Detection (Advanced) +**Goal**: Detect distribution and suggest optimal configuration + +**Implementation**: +```go +type OSDistribution struct { + Name string + Family string // debian, redhat, arch, etc. + PostgreSQLVersion string + DefaultAuthMethod string + SocketLocation string + SuggestedUser string +} + +func detectDistribution() *OSDistribution { + // Read /etc/os-release + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return &OSDistribution{Name: "unknown"} + } + + content := string(data) + dist := &OSDistribution{} + + // Parse os-release + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "ID=") { + dist.Name = strings.Trim(strings.TrimPrefix(line, "ID="), "\"") + } + if strings.HasPrefix(line, "ID_LIKE=") { + dist.Family = strings.Trim(strings.TrimPrefix(line, "ID_LIKE="), "\"") + } + } + + // Distribution-specific defaults + switch dist.Name { + case "centos", "rhel", "fedora": + dist.DefaultAuthMethod = "peer" + dist.SocketLocation = "/var/run/postgresql" + dist.SuggestedUser = "postgres" + + case "debian", "ubuntu": + dist.DefaultAuthMethod = "peer" + dist.SocketLocation = "/var/run/postgresql" + dist.SuggestedUser = "postgres" + + case "arch", "manjaro": + dist.DefaultAuthMethod = "peer" + dist.SocketLocation = "/run/postgresql" + dist.SuggestedUser = "postgres" + + case "alpine": + dist.DefaultAuthMethod = "md5" + dist.SocketLocation = "/run/postgresql" + dist.SuggestedUser = "postgres" + } + + return dist +} +``` + +## Implementation Phases + +### Phase 1: Detection & Guidance (1-2 hours) +- ✅ Detect OS user vs DB user mismatch +- ✅ Detect PostgreSQL authentication method (peer/ident/md5) +- ✅ Provide helpful error messages with solutions +- ✅ Show example commands for current system + +**Files to modify**: +- `internal/config/config.go` - Add OS user detection +- `internal/database/postgresql.go` - Add auth method detection +- `cmd/root.go` - Add pre-connection validation + +### Phase 2: pgpass Support (2-3 hours) +- ✅ Read and parse ~/.pgpass file +- ✅ Support wildcard matching +- ✅ Check multiple pgpass locations +- ✅ Fall back to password prompt if needed + +**Files to modify**: +- `internal/config/config.go` - Add pgpass loading +- `internal/database/postgresql.go` - Integrate pgpass passwords + +### Phase 3: Auto-Sudo (3-4 hours) - OPTIONAL +- ⚠️ Automatically detect when sudo is needed +- ⚠️ Re-execute command with sudo -u +- ⚠️ Preserve all arguments and flags +- ⚠️ Handle interactive prompts + +**Considerations**: +- Security implications of auto-sudo +- May surprise users (implicit behavior change) +- Could interfere with scripting/automation + +### Phase 4: Distribution-Aware Setup (4-5 hours) - OPTIONAL +- Detect Linux distribution +- Provide distribution-specific guidance +- Auto-configure optimal settings +- Generate setup scripts for first-run + +## Recommended Approach: Phase 1 + Phase 2 + +**Why this combination?** +1. **Phase 1**: Immediate value - users understand what's wrong +2. **Phase 2**: Standard PostgreSQL solution - no surprises +3. **Skip Phase 3**: Auto-sudo can be confusing/dangerous +4. **Skip Phase 4**: Users know their own distribution + +**User Experience Flow**: +```bash +# User runs without proper auth +$ ./dbbackup status --user postgres +⚠️ Authentication Note: + PostgreSQL is using 'peer' authentication + OS user 'root' cannot authenticate as DB user 'postgres' + +💡 Solutions: + 1. Run as matching user: sudo -u postgres ./dbbackup + 2. Provide password: ./dbbackup --password + 3. Set PGPASSWORD environment variable + 4. Configure ~/.pgpass file (recommended) + +📝 To create ~/.pgpass file: + echo "localhost:5432:*:postgres:yourpassword" > ~/.pgpass + chmod 0600 ~/.pgpass + +# User fixes authentication +$ sudo -u postgres ./dbbackup status --user postgres +✅ Connected successfully +``` + +## Testing Matrix + +### PostgreSQL Authentication Methods +- [ ] **peer** (Unix socket) - CentOS/RHEL default +- [ ] **ident** (TCP/IP) - Some distributions +- [ ] **md5** (Password) - Common for remote +- [ ] **scram-sha-256** (Password) - Modern PostgreSQL +- [ ] **trust** (No auth) - Development only + +### Operating Systems +- [ ] **CentOS Stream 10** (current system) +- [ ] **Ubuntu 22.04/24.04** (most popular) +- [ ] **Debian 12** (stable) +- [ ] **Fedora 40** (cutting edge) +- [ ] **Alpine Linux** (containers) + +### Scenarios +- [ ] Root user connecting as postgres user +- [ ] Postgres user connecting as postgres user +- [ ] Regular user with pgpass file +- [ ] Regular user with PGPASSWORD env +- [ ] Regular user with --password flag +- [ ] TCP vs Unix socket connections +- [ ] Remote database connections + +## Security Considerations + +1. **pgpass file permissions**: Must be 0600 (owner read/write only) +2. **Password in command line**: Discourage --password flag (visible in ps) +3. **PGPASSWORD env**: Better than command line, but still visible +4. **Auto-sudo**: Could be security risk if not carefully implemented +5. **Error messages**: Don't expose sensitive connection details + +## Backward Compatibility + +✅ **No breaking changes** - All existing workflows continue to work: +- `sudo -u postgres ./dbbackup` ✅ +- `PGPASSWORD=secret ./dbbackup` ✅ +- `./dbbackup --password secret` ✅ +- Current socket detection logic ✅ + +## Conclusion + +**Recommended Implementation**: Phase 1 + Phase 2 +- **Effort**: 3-5 hours total +- **Value**: High - users can authenticate without sudo +- **Risk**: Low - using standard PostgreSQL mechanisms +- **Complexity**: Medium - well-defined scope + +**Skip**: Phase 3 (Auto-sudo) +- Can surprise users with implicit behavior +- Security implications +- Not standard PostgreSQL practice + +**Defer**: Phase 4 (Distribution detection) +- Nice-to-have but not essential +- Users generally know their own system +- Can be added later if needed diff --git a/dbbackup b/dbbackup index 59a1f77..bdd2bc9 100755 Binary files a/dbbackup and b/dbbackup differ diff --git a/internal/auth/helper.go b/internal/auth/helper.go new file mode 100644 index 0000000..5e40498 --- /dev/null +++ b/internal/auth/helper.go @@ -0,0 +1,338 @@ +package auth + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "dbbackup/internal/config" +) + +// AuthMethod represents a PostgreSQL authentication method +type AuthMethod string + +const ( + AuthPeer AuthMethod = "peer" + AuthIdent AuthMethod = "ident" + AuthMD5 AuthMethod = "md5" + AuthScramSHA256 AuthMethod = "scram-sha-256" + AuthPassword AuthMethod = "password" + AuthTrust AuthMethod = "trust" + AuthUnknown AuthMethod = "unknown" +) + +// DetectPostgreSQLAuthMethod attempts to detect the authentication method +// by reading pg_hba.conf or checking common patterns +func DetectPostgreSQLAuthMethod(host string, port int, user string) AuthMethod { + // For localhost connections, check pg_hba.conf + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + if method := checkPgHbaConf(user); method != AuthUnknown { + return method + } + } + + // Default assumptions based on connection type + if host == "localhost" { + return AuthPeer // Most common for local connections + } + + return AuthMD5 // Most common for remote connections +} + +// checkPgHbaConf reads pg_hba.conf to determine authentication method +func checkPgHbaConf(user string) AuthMethod { + // Common pg_hba.conf locations + locations := []string{ + "/var/lib/pgsql/data/pg_hba.conf", + "/etc/postgresql/*/main/pg_hba.conf", + "/var/lib/postgresql/data/pg_hba.conf", + "/usr/local/pgsql/data/pg_hba.conf", + } + + // Try to find pg_hba.conf using psql if available + if hbaFile := findHbaFileViaPostgres(); hbaFile != "" { + locations = append([]string{hbaFile}, locations...) + } + + for _, location := range locations { + if method := parsePgHbaConf(location, user); method != AuthUnknown { + return method + } + } + + return AuthUnknown +} + +// findHbaFileViaPostgres asks PostgreSQL for the hba_file location +func findHbaFileViaPostgres() string { + cmd := exec.Command("psql", "-U", "postgres", "-t", "-c", "SHOW hba_file;") + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +// parsePgHbaConf parses pg_hba.conf and returns the authentication method +func parsePgHbaConf(path string, user string) AuthMethod { + // Try with sudo if we can't read directly + file, err := os.Open(path) + if err != nil { + // Try with sudo + cmd := exec.Command("sudo", "cat", path) + output, err := cmd.Output() + if err != nil { + return AuthUnknown + } + return parseHbaContent(string(output), user) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var content strings.Builder + for scanner.Scan() { + content.WriteString(scanner.Text()) + content.WriteString("\n") + } + + return parseHbaContent(content.String(), user) +} + +// parseHbaContent parses the content of pg_hba.conf +func parseHbaContent(content string, user string) AuthMethod { + lines := strings.Split(content, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + + // Skip comments and empty lines + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Parse HBA line: TYPE DATABASE USER ADDRESS METHOD + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + + connType := fields[0] + // database := fields[1] + hbaUser := fields[2] + var method string + + if connType == "local" { + // local all all peer + if len(fields) >= 4 { + method = fields[3] + } + } else if connType == "host" { + // host all all 127.0.0.1/32 md5 + if len(fields) >= 5 { + method = fields[4] + } + } + + // Check if this rule applies to our user + if hbaUser == "all" || hbaUser == user { + switch strings.ToLower(method) { + case "peer": + return AuthPeer + case "ident": + return AuthIdent + case "md5": + return AuthMD5 + case "scram-sha-256": + return AuthScramSHA256 + case "password": + return AuthPassword + case "trust": + return AuthTrust + } + } + } + + return AuthUnknown +} + +// CheckAuthenticationMismatch checks if there's an authentication mismatch +// and returns helpful guidance if needed +func CheckAuthenticationMismatch(cfg *config.Config) (bool, string) { + // Only check for PostgreSQL + if !cfg.IsPostgreSQL() { + return false, "" + } + + // Get current OS user + currentOSUser := config.GetCurrentOSUser() + requestedDBUser := cfg.User + + // If users match, no problem + if currentOSUser == requestedDBUser { + return false, "" + } + + // If password is provided, user can authenticate + if cfg.Password != "" { + return false, "" + } + + // Detect authentication method + authMethod := DetectPostgreSQLAuthMethod(cfg.Host, cfg.Port, cfg.User) + + // peer and ident require OS user = DB user + if authMethod == AuthPeer || authMethod == AuthIdent { + return true, buildAuthMismatchMessage(currentOSUser, requestedDBUser, authMethod) + } + + return false, "" +} + +// buildAuthMismatchMessage creates a helpful error message +func buildAuthMismatchMessage(osUser, dbUser string, method AuthMethod) string { + var msg strings.Builder + + msg.WriteString("\n⚠️ Authentication Mismatch Detected\n") + msg.WriteString(strings.Repeat("=", 60) + "\n\n") + + msg.WriteString(fmt.Sprintf(" PostgreSQL is using '%s' authentication\n", method)) + msg.WriteString(fmt.Sprintf(" OS user '%s' cannot authenticate as DB user '%s'\n\n", osUser, dbUser)) + + msg.WriteString("💡 Solutions (choose one):\n\n") + + msg.WriteString(fmt.Sprintf(" 1. Run as matching user:\n")) + msg.WriteString(fmt.Sprintf(" sudo -u %s %s\n\n", dbUser, getCommandLine())) + + msg.WriteString(" 2. Configure ~/.pgpass file (recommended):\n") + msg.WriteString(fmt.Sprintf(" echo \"localhost:5432:*:%s:your_password\" > ~/.pgpass\n", dbUser)) + msg.WriteString(" chmod 0600 ~/.pgpass\n\n") + + msg.WriteString(" 3. Set PGPASSWORD environment variable:\n") + msg.WriteString(fmt.Sprintf(" export PGPASSWORD=your_password\n")) + msg.WriteString(fmt.Sprintf(" %s\n\n", getCommandLine())) + + msg.WriteString(" 4. Provide password via flag:\n") + msg.WriteString(fmt.Sprintf(" %s --password your_password\n\n", getCommandLine())) + + msg.WriteString("📝 Note: For production use, ~/.pgpass or PGPASSWORD are recommended\n") + msg.WriteString(" to avoid exposing passwords in command history.\n\n") + + msg.WriteString(strings.Repeat("=", 60) + "\n") + + return msg.String() +} + +// getCommandLine reconstructs the current command line +func getCommandLine() string { + if len(os.Args) == 0 { + return "./dbbackup" + } + + // Build command without password if present + var parts []string + skipNext := false + + for _, arg := range os.Args { + if skipNext { + skipNext = false + continue + } + + if arg == "--password" || arg == "-p" { + skipNext = true + continue + } + + if strings.HasPrefix(arg, "--password=") { + continue + } + + parts = append(parts, arg) + } + + return strings.Join(parts, " ") +} + +// LoadPasswordFromPgpass attempts to load password from .pgpass file +func LoadPasswordFromPgpass(cfg *config.Config) (string, bool) { + pgpassLocations := []string{ + filepath.Join(os.Getenv("HOME"), ".pgpass"), + "/var/lib/pgsql/.pgpass", + filepath.Join("/home", cfg.User, ".pgpass"), + } + + for _, pgpassPath := range pgpassLocations { + if password := parsePgpass(pgpassPath, cfg); password != "" { + return password, true + } + } + + return "", false +} + +// parsePgpass reads and parses .pgpass file +// Format: hostname:port:database:username:password +func parsePgpass(path string, cfg *config.Config) string { + file, err := os.Open(path) + if err != nil { + return "" + } + defer file.Close() + + // Check file permissions (should be 0600) + info, err := file.Stat() + if err != nil { + return "" + } + + // Warn if permissions are too open (but still use it) + if info.Mode().Perm() != 0600 { + // Silently skip - PostgreSQL also skips world-readable pgpass files + return "" + } + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip comments and empty lines + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.Split(line, ":") + if len(parts) != 5 { + continue + } + + host, port, db, user, pass := parts[0], parts[1], parts[2], parts[3], parts[4] + + // Match hostname (* = wildcard) + if host != "*" && host != cfg.Host { + continue + } + + // Match port (* = wildcard) + if port != "*" && port != strconv.Itoa(cfg.Port) { + continue + } + + // Match database (* = wildcard) + if db != "*" && db != cfg.Database { + continue + } + + // Match user (* = wildcard) + if user != "*" && user != cfg.User { + continue + } + + return pass + } + + return "" +} diff --git a/internal/config/config.go b/internal/config/config.go index 9284846..82a7bfe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -387,6 +387,11 @@ func getCurrentUser() string { return "postgres" } +// GetCurrentOSUser returns the current OS user (exported for auth checking) +func GetCurrentOSUser() string { + return getCurrentUser() +} + func getDefaultBackupDir() string { // Try to create a sensible default backup directory homeDir, _ := os.UserHomeDir() diff --git a/internal/database/postgresql.go b/internal/database/postgresql.go index ee57af3..6c71188 100644 --- a/internal/database/postgresql.go +++ b/internal/database/postgresql.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "dbbackup/internal/auth" "dbbackup/internal/config" "dbbackup/internal/logger" @@ -35,6 +36,20 @@ func NewPostgreSQL(cfg *config.Config, log logger.Logger) *PostgreSQL { // Connect establishes a connection to PostgreSQL using pgx for better performance func (p *PostgreSQL) Connect(ctx context.Context) error { + // Try to load password from .pgpass if not provided + if p.cfg.Password == "" { + if password, found := auth.LoadPasswordFromPgpass(p.cfg); found { + p.cfg.Password = password + p.log.Debug("Loaded password from .pgpass file") + } + } + + // Check for authentication mismatch before attempting connection + if mismatch, msg := auth.CheckAuthenticationMismatch(p.cfg); mismatch { + fmt.Println(msg) + return fmt.Errorf("authentication configuration required") + } + // Build PostgreSQL DSN (pgx format) dsn := p.buildPgxDSN() p.dsn = dsn