From a5ee1259127c0fce2ecf0a10babcc7fb0f9013a4 Mon Sep 17 00:00:00 2001 From: Justin George Date: Tue, 16 Sep 2025 22:41:10 -0700 Subject: [PATCH 01/19] GSSAPI phase 1 --- pgdog/src/auth/scram/server.rs | 5 ++ pgdog/src/backend/server.rs | 12 +++ pgdog/src/config/auth.rs | 6 ++ pgdog/src/frontend/client/mod.rs | 7 ++ pgdog/src/net/error.rs | 6 ++ pgdog/src/net/messages/auth/mod.rs | 100 ++++++++++++++++++++++++ pgdog/src/net/messages/auth/password.rs | 40 ++++++++++ 7 files changed, 176 insertions(+) diff --git a/pgdog/src/auth/scram/server.rs b/pgdog/src/auth/scram/server.rs index 3cb6a3a36..c18850a9b 100644 --- a/pgdog/src/auth/scram/server.rs +++ b/pgdog/src/auth/scram/server.rs @@ -189,6 +189,11 @@ impl Server { } } } + + Password::GssapiResponse { .. } => { + error!("GSSAPI authentication not yet implemented"); + return Ok(false); + } } } diff --git a/pgdog/src/backend/server.rs b/pgdog/src/backend/server.rs index 9f4d1dbfa..0b553cca4 100644 --- a/pgdog/src/backend/server.rs +++ b/pgdog/src/backend/server.rs @@ -191,6 +191,18 @@ impl Server { let client = md5::Client::new_salt(&addr.user, &addr.password, &salt)?; stream.send_flush(&client.response()).await?; } + Authentication::Gssapi | Authentication::Sspi => { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "GSSAPI authentication not yet implemented", + ))); + } + Authentication::GssapiContinue(_) => { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "GSSAPI authentication not yet implemented", + ))); + } } } diff --git a/pgdog/src/config/auth.rs b/pgdog/src/config/auth.rs index b2780751e..3055d98ee 100644 --- a/pgdog/src/config/auth.rs +++ b/pgdog/src/config/auth.rs @@ -17,6 +17,7 @@ pub enum AuthType { #[default] Scram, Trust, + Gssapi, } impl AuthType { @@ -31,6 +32,10 @@ impl AuthType { pub fn trust(&self) -> bool { matches!(self, Self::Trust) } + + pub fn gssapi(&self) -> bool { + matches!(self, Self::Gssapi) + } } impl FromStr for AuthType { @@ -41,6 +46,7 @@ impl FromStr for AuthType { "md5" => Ok(Self::Md5), "scram" => Ok(Self::Scram), "trust" => Ok(Self::Trust), + "gssapi" => Ok(Self::Gssapi), _ => Err(format!("Invalid auth type: {}", s)), } } diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index 928abbbbf..116c0eac8 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -181,6 +181,13 @@ impl Client { } (AuthType::Trust, _) => true, + + (AuthType::Gssapi, _) => { + // GSSAPI authentication not yet implemented + // For now, we reject GSSAPI authentication attempts + error!("GSSAPI authentication requested but not yet implemented"); + false + } }; if !auth_ok { diff --git a/pgdog/src/net/error.rs b/pgdog/src/net/error.rs index ffb51c0f9..ec75f72d6 100644 --- a/pgdog/src/net/error.rs +++ b/pgdog/src/net/error.rs @@ -96,4 +96,10 @@ pub enum Error { #[error("not a boolean")] NotBoolean, + + #[error("GSSAPI authentication failed")] + GssapiAuthFailed, + + #[error("GSSAPI context initialization failed: {0}")] + GssapiInitFailed(String), } diff --git a/pgdog/src/net/messages/auth/mod.rs b/pgdog/src/net/messages/auth/mod.rs index 0abbd7b8d..4974ad409 100644 --- a/pgdog/src/net/messages/auth/mod.rs +++ b/pgdog/src/net/messages/auth/mod.rs @@ -24,6 +24,12 @@ pub enum Authentication { Md5(Bytes), /// AuthenticationCleartextPassword (B). ClearTextPassword, + /// AuthenticationGSS (B) - Code 7 + Gssapi, + /// AuthenticationGSSContinue (B) - Code 8 + GssapiContinue(Vec), + /// AuthenticationSSPI (B) - Code 9 (Windows) + Sspi, } impl Authentication { @@ -49,6 +55,15 @@ impl FromBytes for Authentication { bytes.copy_to_slice(&mut salt); Ok(Authentication::Md5(Bytes::from(salt))) } + 7 => Ok(Authentication::Gssapi), + 8 => { + let mut data = Vec::new(); + while bytes.has_remaining() { + data.push(bytes.get_u8()); + } + Ok(Authentication::GssapiContinue(data)) + } + 9 => Ok(Authentication::Sspi), 10 => { let mechanism = c_string_buf(&mut bytes); Ok(Authentication::Sasl(mechanism)) @@ -116,6 +131,91 @@ impl ToBytes for Authentication { Ok(payload.freeze()) } + + Authentication::Gssapi => { + payload.put_i32(7); + Ok(payload.freeze()) + } + + Authentication::GssapiContinue(data) => { + payload.put_i32(8); + payload.put(Bytes::from(data.clone())); + Ok(payload.freeze()) + } + + Authentication::Sspi => { + payload.put_i32(9); + Ok(payload.freeze()) + } } } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_gssapi_authentication() { + let auth = Authentication::Gssapi; + let bytes = auth.to_bytes().unwrap(); + let auth = Authentication::from_bytes(bytes).unwrap(); + match auth { + Authentication::Gssapi => (), + _ => panic!("Expected GSSAPI authentication"), + } + } + + #[test] + fn test_gssapi_continue() { + let data = vec![1, 2, 3, 4, 5]; + let auth = Authentication::GssapiContinue(data.clone()); + let bytes = auth.to_bytes().unwrap(); + let auth = Authentication::from_bytes(bytes).unwrap(); + match auth { + Authentication::GssapiContinue(received_data) => { + assert_eq!(received_data, data); + } + _ => panic!("Expected GssapiContinue authentication"), + } + } + + #[test] + fn test_sspi_authentication() { + let auth = Authentication::Sspi; + let bytes = auth.to_bytes().unwrap(); + let auth = Authentication::from_bytes(bytes).unwrap(); + match auth { + Authentication::Sspi => (), + _ => panic!("Expected SSPI authentication"), + } + } + + #[test] + fn test_gssapi_message_codes() { + // Test that the correct protocol codes are used + let gssapi = Authentication::Gssapi; + let bytes = gssapi.to_bytes().unwrap(); + // Check for code 7 after the message header + assert_eq!(bytes[5], 0); // First 3 bytes of i32(7) in big-endian + assert_eq!(bytes[6], 0); + assert_eq!(bytes[7], 0); + assert_eq!(bytes[8], 7); + + let gssapi_continue = Authentication::GssapiContinue(vec![42]); + let bytes = gssapi_continue.to_bytes().unwrap(); + // Check for code 8 + assert_eq!(bytes[5], 0); // First 3 bytes of i32(8) in big-endian + assert_eq!(bytes[6], 0); + assert_eq!(bytes[7], 0); + assert_eq!(bytes[8], 8); + + let sspi = Authentication::Sspi; + let bytes = sspi.to_bytes().unwrap(); + // Check for code 9 + assert_eq!(bytes[5], 0); // First 3 bytes of i32(9) in big-endian + assert_eq!(bytes[6], 0); + assert_eq!(bytes[7], 0); + assert_eq!(bytes[8], 9); + } +} diff --git a/pgdog/src/net/messages/auth/password.rs b/pgdog/src/net/messages/auth/password.rs index 83bf85cef..28d28a3bf 100644 --- a/pgdog/src/net/messages/auth/password.rs +++ b/pgdog/src/net/messages/auth/password.rs @@ -13,6 +13,8 @@ pub enum Password { /// PasswordMessage (F) or SASLResponse (F) /// TODO: This requires a NULL byte at end. Need to rewrite this struct. PasswordMessage { response: String }, + /// GSSResponse (F) - Also uses code 'p' + GssapiResponse { data: Vec }, } impl Password { @@ -30,10 +32,15 @@ impl Password { } } + pub fn gssapi_response(data: Vec) -> Self { + Self::GssapiResponse { data } + } + pub fn password(&self) -> Option<&str> { match self { Password::SASLInitialResponse { .. } => None, Password::PasswordMessage { response } => Some(response), + Password::GssapiResponse { .. } => None, } } } @@ -75,6 +82,10 @@ impl ToBytes for Password { Password::PasswordMessage { response } => { payload.put(Bytes::copy_from_slice(response.as_bytes())); } + + Password::GssapiResponse { data } => { + payload.put(Bytes::from(data.clone())); + } } Ok(payload.freeze()) @@ -86,3 +97,32 @@ impl Protocol for Password { 'p' } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_gssapi_response() { + let data = vec![10, 20, 30, 40, 50]; + let password = Password::gssapi_response(data.clone()); + let bytes = password.to_bytes().unwrap(); + + // Verify the message starts with 'p' and contains our data + assert_eq!(bytes[0], b'p'); + + // The actual data should be at the end of the message + let _payload_len = bytes.len() - 5; // Skip 'p' and 4-byte length + let payload_start = 5; + let payload = &bytes[payload_start..]; + assert_eq!(payload, &data[..]); + } + + #[test] + fn test_gssapi_response_password_method() { + let data = vec![1, 2, 3]; + let password = Password::gssapi_response(data); + // GssapiResponse should return None for password() + assert_eq!(password.password(), None); + } +} From 42bdc27564fd083fb31fdc7eb899263e1172cfd8 Mon Sep 17 00:00:00 2001 From: Justin George Date: Tue, 16 Sep 2025 23:24:33 -0700 Subject: [PATCH 02/19] gssapi phase 2 --- integration/complex/gssapi/README.md | 125 ++++++++++++++++ integration/complex/gssapi/pgdog.toml | 95 +++++++++++++ integration/complex/gssapi/users.toml | 56 ++++++++ pgdog/src/backend/pool/address.rs | 123 ++++++++++++++++ pgdog/src/config/core.rs | 97 +++++++++++++ pgdog/src/config/database.rs | 4 + pgdog/src/config/gssapi.rs | 196 ++++++++++++++++++++++++++ pgdog/src/config/mod.rs | 4 + 8 files changed, 700 insertions(+) create mode 100644 integration/complex/gssapi/README.md create mode 100644 integration/complex/gssapi/pgdog.toml create mode 100644 integration/complex/gssapi/users.toml create mode 100644 pgdog/src/config/gssapi.rs diff --git a/integration/complex/gssapi/README.md b/integration/complex/gssapi/README.md new file mode 100644 index 000000000..7f89d71f2 --- /dev/null +++ b/integration/complex/gssapi/README.md @@ -0,0 +1,125 @@ +# GSSAPI (Kerberos) Authentication Configuration Example + +This directory contains example configurations for using GSSAPI/Kerberos authentication with PGDog. + +## Overview + +PGDog supports GSSAPI authentication in a dual-context model: +- **Frontend**: Accepts GSSAPI authentication from clients +- **Backend**: Uses service credentials to authenticate to PostgreSQL servers + +This approach preserves connection pooling while providing strong authentication. + +## Files + +- `pgdog.toml` - Main configuration with GSSAPI settings +- `users.toml` - User mappings for GSSAPI principals + +## Key Features Demonstrated + +### 1. Global GSSAPI Configuration +- Server keytab for accepting client connections +- Default backend credentials for PostgreSQL servers +- Ticket refresh intervals +- Realm stripping for username mapping + +### 2. Per-Server Backend Authentication +- Different keytabs for different PostgreSQL servers +- Useful for multi-tenant or sharded deployments +- Fine-grained access control per database + +### 3. Mixed Authentication +- GSSAPI for some databases, password for others +- Fallback options for migration scenarios + +## Setup Requirements + +### Prerequisites +1. Kerberos KDC (Key Distribution Center) configured +2. Service principals created for PGDog and PostgreSQL servers +3. Keytab files generated and placed in appropriate locations +4. PostgreSQL servers configured to accept GSSAPI authentication + +### Keytab Files + +#### Frontend (Client-facing) +```bash +# Create service principal for PGDog +kadmin.local -q "addprinc -randkey postgres/pgdog.example.com" +kadmin.local -q "ktadd -k /etc/pgdog/pgdog.keytab postgres/pgdog.example.com" +``` + +#### Backend (PostgreSQL-facing) +```bash +# Create service principal for backend connections +kadmin.local -q "addprinc -randkey pgdog-service" +kadmin.local -q "ktadd -k /etc/pgdog/backend.keytab pgdog-service" + +# For per-server authentication +kadmin.local -q "addprinc -randkey pgdog-shard1" +kadmin.local -q "ktadd -k /etc/pgdog/shard1.keytab pgdog-shard1" +``` + +### PostgreSQL Configuration + +Configure PostgreSQL servers to accept GSSAPI authentication from PGDog's service principal: + +```postgresql +# pg_hba.conf +host all pgdog-service@EXAMPLE.COM 0.0.0.0/0 gss +host all pgdog-shard1@EXAMPLE.COM 0.0.0.0/0 gss +``` + +## Authentication Flow + +1. **Client → PGDog**: Client authenticates using their Kerberos principal (e.g., alice@EXAMPLE.COM) +2. **Username Mapping**: PGDog maps the principal to a user in users.toml (strips realm if configured) +3. **PGDog → PostgreSQL**: PGDog uses its service credentials to connect to the backend +4. **Connection Pooling**: PGDog maintains pooled connections using its service identity + +## Security Considerations + +- Keytab files should be readable only by the PGDog process user +- Use separate service principals for different environments (dev/staging/prod) +- Regularly rotate keytabs and update Kerberos passwords +- Consider using GSSAPI encryption if SQL inspection is not required +- Monitor ticket refresh logs for authentication issues + +## Testing + +```bash +# Test client authentication +kinit alice@EXAMPLE.COM +psql -h pgdog.example.com -p 6432 -d production -U alice + +# Verify PGDog's service ticket +klist -k /etc/pgdog/pgdog.keytab + +# Check backend connectivity +kinit -kt /etc/pgdog/backend.keytab pgdog-service@EXAMPLE.COM +psql -h pg1.example.com -p 5432 -d postgres -U pgdog-service +``` + +## Troubleshooting + +### Common Issues + +1. **Clock Skew**: Ensure all servers have synchronized time (use NTP) +2. **DNS Resolution**: Kerberos requires proper forward and reverse DNS +3. **Keytab Permissions**: Check file ownership and permissions (600 or 400) +4. **Principal Names**: Verify exact principal names including realm +5. **Ticket Expiration**: Monitor ticket refresh intervals + +### Debug Logging + +Enable Kerberos debug output: +```bash +export KRB5_TRACE=/tmp/krb5_trace.log +``` + +## Migration from Password Authentication + +1. Enable `fallback_enabled = true` in GSSAPI configuration +2. Deploy PGDog with both authentication methods available +3. Migrate users gradually to GSSAPI +4. Once all users migrated, disable fallback \ No newline at end of file diff --git a/integration/complex/gssapi/pgdog.toml b/integration/complex/gssapi/pgdog.toml new file mode 100644 index 000000000..ed829f69c --- /dev/null +++ b/integration/complex/gssapi/pgdog.toml @@ -0,0 +1,95 @@ +# Example PGDog configuration with GSSAPI authentication +# This demonstrates both simple and per-server GSSAPI setups +# +# GSSAPI Authentication Flow: +# 1. Clients authenticate to PGDog using GSSAPI (Kerberos) +# 2. PGDog uses its own service credentials to authenticate to backend PostgreSQL servers +# 3. Connection pooling is preserved since PGDog maintains the backend connections + +[general] +host = "0.0.0.0" +port = 6432 +workers = 2 +default_pool_size = 10 +min_pool_size = 1 +pooler_mode = "transaction" +auth_type = "gssapi" # Enable GSSAPI as the primary authentication method + +# Global GSSAPI configuration +[gssapi] +enabled = true + +# Frontend authentication (accepting client connections) +# This keytab contains PGDog's service principal for accepting client connections +server_keytab = "/etc/pgdog/pgdog.keytab" +# Service principal name that clients will use to connect to PGDog +# Format: service/hostname@REALM +server_principal = "postgres/pgdog.example.com@EXAMPLE.COM" + +# Backend authentication defaults (connecting to PostgreSQL servers) +# These can be overridden per-database for multi-server deployments +default_backend_keytab = "/etc/pgdog/backend.keytab" +default_backend_principal = "pgdog-service@EXAMPLE.COM" + +# Strip realm from client principals for username mapping +# When true: alice@EXAMPLE.COM becomes "alice" in users.toml +strip_realm = true + +# Ticket refresh interval in seconds (2 hours) +# Kerberos tickets are refreshed before expiration +ticket_refresh_interval = 7200 + +# Fall back to password auth if GSSAPI fails +# Useful during migration or for mixed environments +fallback_enabled = false + +# Admin database configuration +[admin] +name = "admin" +password = "admin" +user = "admin" + +# Database using global GSSAPI defaults +# This database inherits default_backend_keytab and default_backend_principal +# from the [gssapi] section above +[[databases]] +name = "production" +host = "pg1.example.com" +port = 5432 +role = "primary" + +# Sharded database with specific GSSAPI configuration +# Each shard can have its own service identity for fine-grained access control +[[databases]] +name = "shard1" +host = "pg-shard1.example.com" +port = 5432 +role = "primary" +shard = 1 +# Override global GSSAPI settings for this specific server +# Useful when different PostgreSQL servers require different service principals +gssapi_keytab = "/etc/pgdog/shard1.keytab" +gssapi_principal = "pgdog-shard1@EXAMPLE.COM" + +# Another sharded database with its own GSSAPI identity +[[databases]] +name = "shard2" +host = "pg-shard2.example.com" +port = 5432 +role = "primary" +shard = 2 +# Each shard can have different credentials for security isolation +gssapi_keytab = "/etc/pgdog/shard2.keytab" +gssapi_principal = "pgdog-shard2@EXAMPLE.COM" + +# Mixed authentication example: Database using password authentication +# Not all databases need to use GSSAPI - password auth can still be used +[[databases]] +name = "analytics" +host = "pg-analytics.example.com" +port = 5432 +role = "primary" +# When no GSSAPI configuration is provided, falls back to password authentication +# The credentials here override any user-specific settings in users.toml +user = "analytics_user" +password = "analytics_password" \ No newline at end of file diff --git a/integration/complex/gssapi/users.toml b/integration/complex/gssapi/users.toml new file mode 100644 index 000000000..1409e547c --- /dev/null +++ b/integration/complex/gssapi/users.toml @@ -0,0 +1,56 @@ +# Users configuration for GSSAPI authentication +# +# Important: When using GSSAPI authentication: +# - User names should match the Kerberos principals (after realm stripping if enabled) +# - Passwords in users.toml are ignored for GSSAPI-authenticated users +# - PGDog uses its own service credentials to connect to backend PostgreSQL servers +# - The server_user/server_password fields control backend database authentication + +[[users]] +# GSSAPI-authenticated user example +# Client authenticates as alice@EXAMPLE.COM via Kerberos +# If strip_realm = true in pgdog.toml, the principal becomes "alice" +name = "alice" +database = "production" +# No password field needed - GSSAPI handles client authentication +# The server_user is the PostgreSQL user that PGDog connects as +server_user = "app_user" +# Connection pool settings +pool_size = 20 +min_pool_size = 2 + +[[users]] +# GSSAPI user connecting to shard1 +# bob@EXAMPLE.COM -> "bob" (with strip_realm = true) +name = "bob" +database = "shard1" +# PGDog will use shard1's specific keytab to connect as app_user +server_user = "app_user" +pool_size = 15 + +[[users]] +# GSSAPI user connecting to shard2 +name = "charlie" +database = "shard2" +# PGDog will use shard2's specific keytab to connect as app_user +server_user = "app_user" +pool_size = 15 + +[[users]] +# Mixed authentication example: Password-authenticated user +# This user connects to the analytics database which doesn't use GSSAPI +name = "analyst" +database = "analytics" +# Client authenticates to PGDog with this password +password = "analyst_password" +# PGDog connects to PostgreSQL with these credentials +server_user = "analytics_user" +server_password = "analytics_password" +pool_size = 10 + +[[users]] +# Admin user for PGDog administration +# Uses password authentication regardless of GSSAPI settings +name = "admin" +database = "admin" +password = "admin" \ No newline at end of file diff --git a/pgdog/src/backend/pool/address.rs b/pgdog/src/backend/pool/address.rs index f438f76d3..48a0e5085 100644 --- a/pgdog/src/backend/pool/address.rs +++ b/pgdog/src/backend/pool/address.rs @@ -20,11 +20,38 @@ pub struct Address { pub user: String, /// Password. pub password: String, + /// GSSAPI keytab path for backend authentication. + pub gssapi_keytab: Option, + /// GSSAPI principal for backend authentication. + pub gssapi_principal: Option, } impl Address { /// Create new address from config values. pub fn new(database: &Database, user: &User) -> Self { + let cfg = config(); + + // Determine GSSAPI settings (database-specific or global defaults) + let (gssapi_keytab, gssapi_principal) = if let Some(ref gssapi_config) = cfg.config.gssapi { + if gssapi_config.enabled { + let keytab = database.gssapi_keytab.clone().or_else(|| { + gssapi_config + .default_backend_keytab + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + }); + let principal = database + .gssapi_principal + .clone() + .or_else(|| gssapi_config.default_backend_principal.clone()); + (keytab, principal) + } else { + (None, None) + } + } else { + (None, None) + }; + Address { host: database.host.clone(), port: database.port, @@ -47,6 +74,8 @@ impl Address { } else { user.password().to_string() }, + gssapi_keytab, + gssapi_principal, } } @@ -74,6 +103,8 @@ impl Address { user: "pgdog".into(), password: "pgdog".into(), database_name: "pgdog".into(), + gssapi_keytab: None, + gssapi_principal: None, } } } @@ -100,6 +131,8 @@ impl TryFrom for Address { password, user, database_name, + gssapi_keytab: None, + gssapi_principal: None, }) } } @@ -107,6 +140,8 @@ impl TryFrom for Address { #[cfg(test)] mod test { use super::*; + use crate::config::{set, ConfigAndUsers, GssapiConfig}; + use std::path::PathBuf; #[test] fn test_defaults() { @@ -153,5 +188,93 @@ mod test { assert_eq!(addr.database_name, "pgdb"); assert_eq!(addr.user, "user"); assert_eq!(addr.password, "password"); + assert_eq!(addr.gssapi_keytab, None); + assert_eq!(addr.gssapi_principal, None); + } + + #[test] + fn test_gssapi_config_in_address() { + // Set up config with GSSAPI + let mut config = ConfigAndUsers::default(); + config.config.gssapi = Some(GssapiConfig { + enabled: true, + server_keytab: Some(PathBuf::from("/etc/pgdog/pgdog.keytab")), + default_backend_keytab: Some(PathBuf::from("/etc/pgdog/backend.keytab")), + default_backend_principal: Some("pgdog@REALM".to_string()), + ..Default::default() + }); + + // Database with specific GSSAPI settings + let database1 = Database { + name: "shard1".into(), + host: "pg1.example.com".into(), + port: 5432, + gssapi_keytab: Some("/etc/pgdog/shard1.keytab".into()), + gssapi_principal: Some("pgdog-shard1@REALM".into()), + ..Default::default() + }; + + // Database using defaults + let database2 = Database { + name: "shard2".into(), + host: "pg2.example.com".into(), + port: 5432, + ..Default::default() + }; + + let user = User { + name: "testuser".into(), + database: "shard1".into(), + ..Default::default() + }; + + // Store the config so Address::new can access it + set(config).unwrap(); + + // Test database with specific GSSAPI settings + let addr1 = Address::new(&database1, &user); + assert_eq!(addr1.gssapi_keytab, Some("/etc/pgdog/shard1.keytab".into())); + assert_eq!(addr1.gssapi_principal, Some("pgdog-shard1@REALM".into())); + + // Test database using default GSSAPI settings + let addr2 = Address::new(&database2, &user); + assert_eq!( + addr2.gssapi_keytab, + Some("/etc/pgdog/backend.keytab".into()) + ); + assert_eq!(addr2.gssapi_principal, Some("pgdog@REALM".into())); + } + + #[test] + fn test_gssapi_disabled() { + // Set up config with GSSAPI disabled + let mut config = ConfigAndUsers::default(); + config.config.gssapi = Some(GssapiConfig { + enabled: false, + server_keytab: Some(PathBuf::from("/etc/pgdog/pgdog.keytab")), + default_backend_keytab: Some(PathBuf::from("/etc/pgdog/backend.keytab")), + ..Default::default() + }); + + let database = Database { + name: "test".into(), + host: "localhost".into(), + port: 5432, + gssapi_keytab: Some("/etc/pgdog/test.keytab".into()), + ..Default::default() + }; + + let user = User { + name: "testuser".into(), + database: "test".into(), + ..Default::default() + }; + + set(config).unwrap(); + + // When GSSAPI is disabled, keytab/principal should be None + let addr = Address::new(&database, &user); + assert_eq!(addr.gssapi_keytab, None); + assert_eq!(addr.gssapi_principal, None); } } diff --git a/pgdog/src/config/core.rs b/pgdog/src/config/core.rs index 362776207..8ef262e00 100644 --- a/pgdog/src/config/core.rs +++ b/pgdog/src/config/core.rs @@ -7,6 +7,7 @@ use tracing::{info, warn}; use super::database::Database; use super::error::Error; use super::general::General; +use super::gssapi::GssapiConfig; use super::networking::{MultiTenant, Tcp}; use super::pooling::{PoolerMode, Stats}; use super::replication::{MirrorConfig, Mirroring, ReplicaLag, Replication}; @@ -162,6 +163,10 @@ pub struct Config { /// Mirroring configurations. #[serde(default)] pub mirroring: Vec, + + /// GSSAPI authentication configuration. + #[serde(default)] + pub gssapi: Option, } impl Config { @@ -463,4 +468,96 @@ exposure = 0.75 .get_mirroring_config("source_db", "non_existent") .is_none()); } + + #[test] + fn test_gssapi_config_in_main_config() { + let source = r#" +[general] +host = "0.0.0.0" +port = 6432 +auth_type = "gssapi" + +[gssapi] +enabled = true +server_keytab = "/etc/pgdog/pgdog.keytab" +server_principal = "postgres/pgdog.example.com@EXAMPLE.COM" +default_backend_keytab = "/etc/pgdog/backend.keytab" +default_backend_principal = "pgdog@EXAMPLE.COM" +strip_realm = true +ticket_refresh_interval = 7200 +fallback_enabled = false + +[[databases]] +name = "production" +host = "pg1.example.com" +port = 5432 + +[[databases]] +name = "shard1" +host = "pg-shard1.example.com" +port = 5432 +gssapi_keytab = "/etc/pgdog/shard1.keytab" +gssapi_principal = "pgdog-shard1@EXAMPLE.COM" +"#; + + let config: Config = toml::from_str(source).unwrap(); + + // Verify GSSAPI config is loaded + assert!(config.gssapi.is_some()); + let gssapi_config = config.gssapi.as_ref().unwrap(); + assert!(gssapi_config.enabled); + assert_eq!( + gssapi_config.server_keytab, + Some(PathBuf::from("/etc/pgdog/pgdog.keytab")) + ); + assert_eq!( + gssapi_config.server_principal, + Some("postgres/pgdog.example.com@EXAMPLE.COM".to_string()) + ); + assert_eq!( + gssapi_config.default_backend_keytab, + Some(PathBuf::from("/etc/pgdog/backend.keytab")) + ); + assert_eq!( + gssapi_config.default_backend_principal, + Some("pgdog@EXAMPLE.COM".to_string()) + ); + assert!(gssapi_config.strip_realm); + assert_eq!(gssapi_config.ticket_refresh_interval, 7200); + assert!(!gssapi_config.fallback_enabled); + + // Verify database GSSAPI configs + assert_eq!(config.databases[0].name, "production"); + assert!(config.databases[0].gssapi_keytab.is_none()); + assert!(config.databases[0].gssapi_principal.is_none()); + + assert_eq!(config.databases[1].name, "shard1"); + assert_eq!( + config.databases[1].gssapi_keytab, + Some("/etc/pgdog/shard1.keytab".to_string()) + ); + assert_eq!( + config.databases[1].gssapi_principal, + Some("pgdog-shard1@EXAMPLE.COM".to_string()) + ); + } + + #[test] + fn test_config_without_gssapi() { + let source = r#" +[general] +host = "0.0.0.0" +port = 6432 + +[[databases]] +name = "production" +host = "127.0.0.1" +port = 5432 +"#; + + let config: Config = toml::from_str(source).unwrap(); + assert!(config.gssapi.is_none()); + assert!(config.databases[0].gssapi_keytab.is_none()); + assert!(config.databases[0].gssapi_principal.is_none()); + } } diff --git a/pgdog/src/config/database.rs b/pgdog/src/config/database.rs index 7b0099f89..da63c9a32 100644 --- a/pgdog/src/config/database.rs +++ b/pgdog/src/config/database.rs @@ -103,6 +103,10 @@ pub struct Database { pub idle_timeout: Option, /// Read-only mode. pub read_only: Option, + /// GSSAPI keytab for this specific backend server. + pub gssapi_keytab: Option, + /// GSSAPI principal for this specific backend server. + pub gssapi_principal: Option, } impl Database { diff --git a/pgdog/src/config/gssapi.rs b/pgdog/src/config/gssapi.rs new file mode 100644 index 000000000..5855b3d3b --- /dev/null +++ b/pgdog/src/config/gssapi.rs @@ -0,0 +1,196 @@ +//! GSSAPI authentication configuration. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// GSSAPI authentication configuration. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct GssapiConfig { + /// Enable GSSAPI authentication. + #[serde(default)] + pub enabled: bool, + + /// Keytab file for accepting client connections (frontend). + /// This is the keytab containing PGDog's service principal. + pub server_keytab: Option, + + /// Service principal name for PGDog (frontend). + /// Default: postgres/hostname@REALM + pub server_principal: Option, + + /// Default keytab for backend connections. + /// Can be overridden per-database. + pub default_backend_keytab: Option, + + /// Default principal for backend connections. + /// Can be overridden per-database. + pub default_backend_principal: Option, + + /// Strip realm from client principals. + /// If true: user@REALM -> user + #[serde(default = "GssapiConfig::default_strip_realm")] + pub strip_realm: bool, + + /// Ticket refresh interval in seconds. + /// How often to refresh Kerberos tickets before they expire. + #[serde(default = "GssapiConfig::default_ticket_refresh_interval")] + pub ticket_refresh_interval: u64, + + /// Fall back to other auth methods if GSSAPI fails. + #[serde(default)] + pub fallback_enabled: bool, + + /// Require GSSAPI encryption (gssencmode). + /// Note: Enabling this will prevent SQL inspection/modification. + #[serde(default)] + pub require_encryption: bool, +} + +impl Default for GssapiConfig { + fn default() -> Self { + Self { + enabled: false, + server_keytab: None, + server_principal: None, + default_backend_keytab: None, + default_backend_principal: None, + strip_realm: Self::default_strip_realm(), + ticket_refresh_interval: Self::default_ticket_refresh_interval(), + fallback_enabled: false, + require_encryption: false, + } + } +} + +impl GssapiConfig { + fn default_strip_realm() -> bool { + true + } + + fn default_ticket_refresh_interval() -> u64 { + 14400 // 4 hours + } + + /// Check if GSSAPI is properly configured. + pub fn is_configured(&self) -> bool { + self.enabled && self.server_keytab.is_some() + } + + /// Check if backend GSSAPI is configured. + pub fn has_backend_config(&self) -> bool { + self.default_backend_keytab.is_some() && self.default_backend_principal.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_values() { + let config = GssapiConfig::default(); + assert!(!config.enabled); + assert!(config.server_keytab.is_none()); + assert!(config.server_principal.is_none()); + assert!(config.strip_realm); + assert_eq!(config.ticket_refresh_interval, 14400); + assert!(!config.fallback_enabled); + assert!(!config.require_encryption); + } + + #[test] + fn test_is_configured() { + let mut config = GssapiConfig::default(); + assert!(!config.is_configured()); + + config.enabled = true; + assert!(!config.is_configured()); + + config.server_keytab = Some(PathBuf::from("/etc/pgdog/pgdog.keytab")); + assert!(config.is_configured()); + } + + #[test] + fn test_has_backend_config() { + let mut config = GssapiConfig::default(); + assert!(!config.has_backend_config()); + + config.default_backend_keytab = Some(PathBuf::from("/etc/pgdog/backend.keytab")); + assert!(!config.has_backend_config()); + + config.default_backend_principal = Some("pgdog@REALM".to_string()); + assert!(config.has_backend_config()); + } + + #[test] + fn test_gssapi_config_from_toml() { + let toml_str = r#" + enabled = true + server_keytab = "/etc/pgdog/pgdog.keytab" + server_principal = "postgres/pgdog.example.com@EXAMPLE.COM" + default_backend_keytab = "/etc/pgdog/backend.keytab" + default_backend_principal = "pgdog@EXAMPLE.COM" + strip_realm = false + ticket_refresh_interval = 7200 + fallback_enabled = true + require_encryption = false + "#; + + let config: GssapiConfig = toml::from_str(toml_str).unwrap(); + assert!(config.enabled); + assert_eq!( + config.server_keytab, + Some(PathBuf::from("/etc/pgdog/pgdog.keytab")) + ); + assert_eq!( + config.server_principal, + Some("postgres/pgdog.example.com@EXAMPLE.COM".to_string()) + ); + assert_eq!( + config.default_backend_keytab, + Some(PathBuf::from("/etc/pgdog/backend.keytab")) + ); + assert_eq!( + config.default_backend_principal, + Some("pgdog@EXAMPLE.COM".to_string()) + ); + assert!(!config.strip_realm); + assert_eq!(config.ticket_refresh_interval, 7200); + assert!(config.fallback_enabled); + assert!(!config.require_encryption); + assert!(config.is_configured()); + assert!(config.has_backend_config()); + } + + #[test] + fn test_partial_gssapi_config() { + let toml_str = r#" + enabled = true + server_keytab = "/etc/pgdog/pgdog.keytab" + "#; + + let config: GssapiConfig = toml::from_str(toml_str).unwrap(); + assert!(config.enabled); + assert_eq!( + config.server_keytab, + Some(PathBuf::from("/etc/pgdog/pgdog.keytab")) + ); + assert!(config.server_principal.is_none()); + assert!(config.strip_realm); // Should use default + assert_eq!(config.ticket_refresh_interval, 14400); // Should use default + assert!(!config.fallback_enabled); // Should use default + assert!(config.is_configured()); + assert!(!config.has_backend_config()); + } + + #[test] + fn test_minimal_gssapi_config() { + let toml_str = r#"enabled = false"#; + + let config: GssapiConfig = toml::from_str(toml_str).unwrap(); + assert!(!config.enabled); + assert!(!config.is_configured()); + assert!(!config.has_backend_config()); + } +} diff --git a/pgdog/src/config/mod.rs b/pgdog/src/config/mod.rs index 3efcd93c1..0f551d0dc 100644 --- a/pgdog/src/config/mod.rs +++ b/pgdog/src/config/mod.rs @@ -7,6 +7,7 @@ pub mod core; pub mod database; pub mod error; pub mod general; +pub mod gssapi; pub mod networking; pub mod overrides; pub mod pooling; @@ -51,6 +52,9 @@ pub use sharding::{ // Re-export from replication module pub use replication::{MirrorConfig, Mirroring, ReplicaLag, Replication}; +// Re-export from gssapi module +pub use gssapi::GssapiConfig; + use parking_lot::Mutex; use std::sync::Arc; use std::{env, path::PathBuf}; From 9279bfe44296eabff6d67f95dcd61f7b6bb1bcf8 Mon Sep 17 00:00:00 2001 From: Justin George Date: Wed, 17 Sep 2025 01:05:36 -0700 Subject: [PATCH 03/19] gssapi phase 3 --- .cargo/config.toml | 15 ++ Cargo.lock | 43 ++++++ pgdog/Cargo.toml | 2 + pgdog/src/auth/gssapi/context.rs | 133 ++++++++++++++++ pgdog/src/auth/gssapi/error.rs | 82 ++++++++++ pgdog/src/auth/gssapi/mod.rs | 30 ++++ pgdog/src/auth/gssapi/ticket_cache.rs | 165 ++++++++++++++++++++ pgdog/src/auth/gssapi/ticket_manager.rs | 197 ++++++++++++++++++++++++ pgdog/src/auth/mod.rs | 1 + pgdog/src/auth/scram/server.rs | 5 +- pgdog/src/backend/server.rs | 14 +- pgdog/src/frontend/client/mod.rs | 7 +- pgdog/tests/gssapi_integration_test.rs | 149 ++++++++++++++++++ 13 files changed, 829 insertions(+), 14 deletions(-) create mode 100644 pgdog/src/auth/gssapi/context.rs create mode 100644 pgdog/src/auth/gssapi/error.rs create mode 100644 pgdog/src/auth/gssapi/mod.rs create mode 100644 pgdog/src/auth/gssapi/ticket_cache.rs create mode 100644 pgdog/src/auth/gssapi/ticket_manager.rs create mode 100644 pgdog/tests/gssapi_integration_test.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index b0de92499..7782ab4b7 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,18 @@ [target.x86_64-unknown-linux-gnu] linker = "/usr/bin/clang" rustflags = ["-C", "link-arg=--ld-path=/usr/bin/mold"] + +# Configuration for macOS to use MIT Kerberos from Homebrew +[target.aarch64-apple-darwin] +linker = "clang" +rustflags = [ + "-L", "/opt/homebrew/opt/krb5/lib", + "-C", "link-args=-Wl,-rpath,/opt/homebrew/opt/krb5/lib" +] + +[target.x86_64-apple-darwin] +linker = "clang" +rustflags = [ + "-L", "/opt/homebrew/opt/krb5/lib", + "-C", "link-args=-Wl,-rpath,/opt/homebrew/opt/krb5/lib" +] diff --git a/Cargo.lock b/Cargo.lock index c0b010d73..128fb2f5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -770,6 +770,19 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.9.0" @@ -1256,6 +1269,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.3" @@ -1769,6 +1788,28 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "libgssapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "834339e86b2561169d45d3b01741967fee3e5716c7d0b6e33cd4e3b34c9558cd" +dependencies = [ + "bitflags 2.9.1", + "bytes", + "lazy_static", + "libgssapi-sys", +] + +[[package]] +name = "libgssapi-sys" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7518e6902e94f92e7c7271232684b60988b4bd813529b4ef9d97aead96956ae8" +dependencies = [ + "bindgen 0.71.1", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.7" @@ -2341,6 +2382,7 @@ dependencies = [ "chrono", "clap", "csv-core", + "dashmap", "fnv", "futures", "hickory-resolver", @@ -2349,6 +2391,7 @@ dependencies = [ "hyper-util", "indexmap", "lazy_static", + "libgssapi", "lru 0.16.0", "md5", "once_cell", diff --git a/pgdog/Cargo.toml b/pgdog/Cargo.toml index d8a763ec9..33463eb42 100644 --- a/pgdog/Cargo.toml +++ b/pgdog/Cargo.toml @@ -60,6 +60,8 @@ indexmap = "2.9" lru = "0.16" hickory-resolver = "0.25.2" lazy_static = "1" +libgssapi = "0.9" +dashmap = "5.5" [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemallocator = "0.6" diff --git a/pgdog/src/auth/gssapi/context.rs b/pgdog/src/auth/gssapi/context.rs new file mode 100644 index 000000000..65d430a51 --- /dev/null +++ b/pgdog/src/auth/gssapi/context.rs @@ -0,0 +1,133 @@ +//! GSSAPI context wrapper for authentication negotiation + +use super::error::{GssapiError, Result}; +use libgssapi::{ + context::{ClientCtx, CtxFlags, SecurityContext}, + credential::{Cred, CredUsage}, + name::Name, + oid::{OidSet, GSS_MECH_KRB5, GSS_NT_HOSTBASED_SERVICE}, +}; +use std::path::Path; + +/// Wrapper for GSSAPI security context +pub struct GssapiContext { + /// The underlying libgssapi context + inner: ClientCtx, + /// The target service principal + target_principal: String, + /// Whether the context is complete + is_complete: bool, +} + +impl GssapiContext { + /// Create a new initiator context (for connecting to a backend) + pub fn new_initiator( + keytab: impl AsRef, + principal: impl Into, + target: impl Into, + ) -> Result { + let keytab = keytab.as_ref(); + let principal = principal.into(); + let target_principal = target.into(); + + // Set the keytab + std::env::set_var("KRB5_CLIENT_KTNAME", keytab); + + // Parse our principal + let our_name = Name::new(principal.as_bytes(), Some(&GSS_NT_HOSTBASED_SERVICE)) + .map_err(|e| GssapiError::InvalidPrincipal(format!("{}: {}", principal, e)))?; + + // Create the desired mechanisms set + let mut desired_mechs = OidSet::new() + .map_err(|e| GssapiError::LibGssapi(format!("Failed to create OidSet: {}", e)))?; + desired_mechs + .add(&GSS_MECH_KRB5) + .map_err(|e| GssapiError::LibGssapi(format!("Failed to add mechanism: {}", e)))?; + + // Acquire credentials + let credential = Cred::acquire( + Some(&our_name), + None, + CredUsage::Initiate, + Some(&desired_mechs), + ) + .map_err(|e| { + GssapiError::CredentialAcquisitionFailed(format!("Failed for {}: {}", principal, e)) + })?; + + // Parse target service principal + let target_name = Name::new(target_principal.as_bytes(), Some(&GSS_NT_HOSTBASED_SERVICE)) + .map_err(|e| { + GssapiError::InvalidPrincipal(format!("{}: {}", target_principal, e)) + })?; + + // Create the client context + let flags = CtxFlags::GSS_C_MUTUAL_FLAG | CtxFlags::GSS_C_SEQUENCE_FLAG; + + let inner = ClientCtx::new(Some(credential), target_name, flags, Some(&GSS_MECH_KRB5)); + + Ok(Self { + inner, + target_principal, + is_complete: false, + }) + } + + /// Initiate the GSSAPI handshake (get the first token) + pub fn initiate(&mut self) -> Result> { + match self.inner.step(None, None)? { + Some(token) => Ok(token.to_vec()), + None => { + self.is_complete = true; + Ok(Vec::new()) + } + } + } + + /// Process a response token from the server + pub fn process_response(&mut self, token: &[u8]) -> Result>> { + match self.inner.step(Some(token), None)? { + Some(response) => Ok(Some(response.to_vec())), + None => { + self.is_complete = true; + Ok(None) + } + } + } + + /// Check if the context establishment is complete + pub fn is_complete(&self) -> bool { + self.is_complete + } + + /// Get the target principal + pub fn target_principal(&self) -> &str { + &self.target_principal + } + + /// Get the authenticated client name (for server contexts) + pub fn client_name(&mut self) -> Result { + self.inner + .source_name() + .map(|name| name.to_string()) + .map_err(|e| GssapiError::ContextError(format!("Failed to get client name: {}", e))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_context_creation() { + // This will fail without a real keytab, but tests the API + let result = GssapiContext::new_initiator( + "/etc/test.keytab", + "pgdog@EXAMPLE.COM", + "postgres/db.example.com@EXAMPLE.COM", + ); + + // We expect this to fail without a real keytab + assert!(result.is_err()); + } +} diff --git a/pgdog/src/auth/gssapi/error.rs b/pgdog/src/auth/gssapi/error.rs new file mode 100644 index 000000000..9e24995c4 --- /dev/null +++ b/pgdog/src/auth/gssapi/error.rs @@ -0,0 +1,82 @@ +//! GSSAPI-specific error types + +use std::fmt; +use std::path::PathBuf; + +/// Result type for GSSAPI operations +pub type Result = std::result::Result; + +/// GSSAPI-specific errors +#[derive(Debug)] +pub enum GssapiError { + /// Keytab file not found + KeytabNotFound(PathBuf), + + /// Invalid principal name + InvalidPrincipal(String), + + /// Ticket has expired + TicketExpired, + + /// Failed to acquire credentials + CredentialAcquisitionFailed(String), + + /// GSSAPI context error + ContextError(String), + + /// Token processing error + TokenError(String), + + /// Refresh failed + RefreshFailed(String), + + /// Internal libgssapi error + LibGssapi(String), + + /// I/O error + Io(std::io::Error), + + /// Configuration error + Config(String), +} + +impl fmt::Display for GssapiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::KeytabNotFound(path) => write!(f, "Keytab file not found: {}", path.display()), + Self::InvalidPrincipal(principal) => write!(f, "Invalid principal: {}", principal), + Self::TicketExpired => write!(f, "Kerberos ticket has expired"), + Self::CredentialAcquisitionFailed(msg) => { + write!(f, "Failed to acquire credentials: {}", msg) + } + Self::ContextError(msg) => write!(f, "GSSAPI context error: {}", msg), + Self::TokenError(msg) => write!(f, "Token processing error: {}", msg), + Self::RefreshFailed(msg) => write!(f, "Ticket refresh failed: {}", msg), + Self::LibGssapi(msg) => write!(f, "GSSAPI library error: {}", msg), + Self::Io(err) => write!(f, "I/O error: {}", err), + Self::Config(msg) => write!(f, "Configuration error: {}", msg), + } + } +} + +impl std::error::Error for GssapiError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + _ => None, + } + } +} + +impl From for GssapiError { + fn from(err: std::io::Error) -> Self { + Self::Io(err) + } +} + +// Convert libgssapi errors when we implement the actual functionality +impl From for GssapiError { + fn from(err: libgssapi::error::Error) -> Self { + Self::LibGssapi(err.to_string()) + } +} diff --git a/pgdog/src/auth/gssapi/mod.rs b/pgdog/src/auth/gssapi/mod.rs new file mode 100644 index 000000000..10662ced7 --- /dev/null +++ b/pgdog/src/auth/gssapi/mod.rs @@ -0,0 +1,30 @@ +//! GSSAPI authentication module for PGDog. +//! +//! This module provides Kerberos/GSSAPI authentication support for both +//! frontend (client to PGDog) and backend (PGDog to PostgreSQL) connections. + +pub mod context; +pub mod error; +pub mod ticket_cache; +pub mod ticket_manager; + +pub use context::GssapiContext; +pub use error::{GssapiError, Result}; +pub use ticket_cache::TicketCache; +pub use ticket_manager::TicketManager; + +/// Handle GSSAPI authentication from a client +pub fn handle_gssapi_auth(_client_token: Vec) -> Result { + // TODO: Implement + unimplemented!("handle_gssapi_auth not yet implemented") +} + +/// Response from GSSAPI authentication handling +pub struct GssapiResponse { + /// Whether authentication is complete + pub is_complete: bool, + /// Token to send back to client (if any) + pub token: Option>, + /// Principal name extracted from context (if complete) + pub principal: Option, +} diff --git a/pgdog/src/auth/gssapi/ticket_cache.rs b/pgdog/src/auth/gssapi/ticket_cache.rs new file mode 100644 index 000000000..9aa8420c3 --- /dev/null +++ b/pgdog/src/auth/gssapi/ticket_cache.rs @@ -0,0 +1,165 @@ +//! Per-server Kerberos ticket cache + +use super::error::{GssapiError, Result}; +use libgssapi::{ + credential::{Cred, CredUsage}, + name::Name, + oid::{OidSet, GSS_MECH_KRB5, GSS_NT_KRB5_PRINCIPAL}, +}; +use parking_lot::RwLock; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// Cache for a single server's Kerberos ticket +pub struct TicketCache { + /// The principal for this cache + principal: String, + /// Path to the keytab file + keytab_path: PathBuf, + /// The acquired credential (if any) + credential: RwLock>>, + /// When the ticket was last refreshed + last_refresh: RwLock, + /// How often to refresh the ticket + refresh_interval: Duration, +} + +impl TicketCache { + /// Create a new ticket cache + pub fn new(principal: impl Into, keytab_path: impl Into) -> Self { + Self { + principal: principal.into(), + keytab_path: keytab_path.into(), + credential: RwLock::new(None), + last_refresh: RwLock::new(Instant::now()), + refresh_interval: Duration::from_secs(14400), // 4 hours default + } + } + + /// Set the refresh interval + pub fn set_refresh_interval(&mut self, interval: Duration) { + self.refresh_interval = interval; + } + + /// Get the principal name + pub fn principal(&self) -> &str { + &self.principal + } + + /// Get the keytab path + pub fn keytab_path(&self) -> &PathBuf { + &self.keytab_path + } + + /// Acquire a ticket from the keytab + pub fn acquire_ticket(&self) -> Result> { + // Check if keytab exists + if !self.keytab_path.exists() { + return Err(GssapiError::KeytabNotFound(self.keytab_path.clone())); + } + + // Set the KRB5_CLIENT_KTNAME environment variable to point to our keytab + std::env::set_var("KRB5_CLIENT_KTNAME", &self.keytab_path); + + // Parse the principal name + let name = Name::new(self.principal.as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL)) + .map_err(|e| GssapiError::InvalidPrincipal(format!("{}: {}", self.principal, e)))?; + + // Create the desired mechanisms set + let mut desired_mechs = OidSet::new() + .map_err(|e| GssapiError::LibGssapi(format!("Failed to create OidSet: {}", e)))?; + desired_mechs + .add(&GSS_MECH_KRB5) + .map_err(|e| GssapiError::LibGssapi(format!("Failed to add mechanism: {}", e)))?; + + // Acquire credentials from the keytab + let credential = Cred::acquire( + Some(&name), + None, // No specific time requirement + CredUsage::Initiate, + Some(&desired_mechs), + ) + .map_err(|e| { + GssapiError::CredentialAcquisitionFailed(format!( + "Failed for {}: {}", + self.principal, e + )) + })?; + + let credential = Arc::new(credential); + + // Store the credential + *self.credential.write() = Some(credential.clone()); + *self.last_refresh.write() = Instant::now(); + + Ok(credential) + } + + /// Get the cached credential, acquiring it if necessary + pub fn get_credential(&self) -> Result> { + // Check if we have a cached credential + if let Some(cred) = self.credential.read().as_ref() { + // Check if it needs refresh + if self.last_refresh.read().elapsed() < self.refresh_interval { + return Ok(cred.clone()); + } + } + + // Need to acquire or refresh + self.acquire_ticket() + } + + /// Check if the ticket needs refresh + pub fn needs_refresh(&self) -> bool { + self.last_refresh.read().elapsed() >= self.refresh_interval + } + + /// Get the last refresh time + pub fn last_refresh(&self) -> Instant { + *self.last_refresh.read() + } + + /// Refresh the ticket + pub fn refresh(&self) -> Result<()> { + self.acquire_ticket()?; + Ok(()) + } + + /// Clear the cached credential + pub fn clear(&self) { + *self.credential.write() = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ticket_cache_creation() { + let cache = TicketCache::new("test@EXAMPLE.COM", "/etc/test.keytab"); + assert_eq!(cache.principal(), "test@EXAMPLE.COM"); + assert_eq!(cache.keytab_path(), &PathBuf::from("/etc/test.keytab")); + } + + #[test] + fn test_missing_keytab_error() { + let cache = TicketCache::new("test@REALM", "/nonexistent/keytab"); + let result = cache.acquire_ticket(); + assert!(result.is_err()); + match result.unwrap_err() { + GssapiError::KeytabNotFound(path) => { + assert_eq!(path, PathBuf::from("/nonexistent/keytab")); + } + _ => panic!("Expected KeytabNotFound error"), + } + } + + #[test] + fn test_refresh_interval() { + let mut cache = TicketCache::new("test@REALM", "/etc/test.keytab"); + cache.set_refresh_interval(Duration::from_secs(3600)); + assert!(!cache.needs_refresh()); // Just created, doesn't need refresh + } +} diff --git a/pgdog/src/auth/gssapi/ticket_manager.rs b/pgdog/src/auth/gssapi/ticket_manager.rs new file mode 100644 index 000000000..1d208c635 --- /dev/null +++ b/pgdog/src/auth/gssapi/ticket_manager.rs @@ -0,0 +1,197 @@ +//! Global ticket manager for all backend servers + +use super::error::Result; +use super::ticket_cache::TicketCache; +use dashmap::DashMap; +use lazy_static::lazy_static; +use libgssapi::credential::Cred; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::task::JoinHandle; + +lazy_static! { + /// Global ticket manager instance + static ref INSTANCE: Arc = Arc::new(TicketManager::new()); +} + +/// Manages Kerberos tickets for multiple backend servers +pub struct TicketManager { + /// Map of server address to ticket cache + caches: Arc>>, + /// Background refresh tasks + refresh_tasks: Arc>>, +} + +impl TicketManager { + /// Create a new ticket manager + pub fn new() -> Self { + Self { + caches: Arc::new(DashMap::new()), + refresh_tasks: Arc::new(DashMap::new()), + } + } + + /// Get the global ticket manager instance + pub fn global() -> Arc { + INSTANCE.clone() + } + + /// Get or acquire a ticket for a server + pub fn get_ticket( + &self, + server: impl Into, + keytab: impl AsRef, + principal: impl Into, + ) -> Result> { + let server = server.into(); + let keytab_path = keytab.as_ref().to_path_buf(); + let principal = principal.into(); + + // Check if we already have a cache for this server + if let Some(cache) = self.caches.get(&server) { + return cache.get_credential(); + } + + // Create a new cache + let cache = Arc::new(TicketCache::new(principal, keytab_path)); + + // Acquire the initial ticket + let credential = cache.acquire_ticket()?; + + // Store the cache + self.caches.insert(server.clone(), cache.clone()); + + // Start background refresh task + self.start_refresh_task(server, cache); + + Ok(credential) + } + + /// Start a background refresh task for a cache + fn start_refresh_task(&self, server: String, cache: Arc) { + let _refresh_tasks = self.refresh_tasks.clone(); + let server_clone = server.clone(); + + let task = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(3600)); // Check every hour + interval.tick().await; // Skip the immediate tick + + loop { + interval.tick().await; + + if cache.needs_refresh() { + match cache.refresh() { + Ok(()) => { + tracing::info!("Refreshed ticket for {}", server_clone); + } + Err(e) => { + tracing::error!("Failed to refresh ticket for {}: {}", server_clone, e); + // Continue trying - the old ticket might still be valid + } + } + } + } + }); + + self.refresh_tasks.insert(server, task); + } + + /// Get a cache for a server (if it exists) + pub fn get_cache(&self, server: &str) -> Option> { + self.caches.get(server).map(|entry| entry.clone()) + } + + /// Get the last refresh time for a server + pub fn get_last_refresh(&self, server: &str) -> Option { + self.caches.get(server).map(|cache| cache.last_refresh()) + } + + /// Set the refresh interval for all future caches + pub fn set_refresh_interval(&self, _interval: Duration) { + // This would need to be stored and applied to new caches + // For simplicity, we'll make this a no-op for now + } + + /// Get the number of cached tickets + pub fn cache_count(&self) -> usize { + self.caches.len() + } + + /// Shutdown the ticket manager, cleaning up all resources + pub fn shutdown(&self) { + // Cancel all refresh tasks + for task in self.refresh_tasks.iter() { + task.value().abort(); + } + self.refresh_tasks.clear(); + + // Clear all caches + for cache in self.caches.iter() { + cache.value().clear(); + } + self.caches.clear(); + } + + /// Remove a specific server's cache + pub fn remove_cache(&self, server: &str) { + // Cancel the refresh task if it exists + if let Some((_, task)) = self.refresh_tasks.remove(server) { + task.abort(); + } + + // Remove the cache + if let Some((_, cache)) = self.caches.remove(server) { + cache.clear(); + } + } +} + +impl Default for TicketManager { + fn default() -> Self { + Self::new() + } +} + +impl Drop for TicketManager { + fn drop(&mut self) { + self.shutdown(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ticket_manager_creation() { + let manager = TicketManager::new(); + assert_eq!(manager.cache_count(), 0); + } + + #[test] + fn test_ticket_manager_global() { + let manager1 = TicketManager::global(); + let manager2 = TicketManager::global(); + assert!(Arc::ptr_eq(&manager1, &manager2)); + } + + #[test] + fn test_cache_management() { + let manager = TicketManager::new(); + + // This will fail because the keytab doesn't exist, but it tests the structure + let result = manager.get_ticket("server1:5432", "/nonexistent/keytab", "test@REALM"); + assert!(result.is_err()); + + // Even though ticket acquisition failed, the cache should not be stored + assert_eq!(manager.cache_count(), 0); + } + + #[test] + fn test_shutdown() { + let manager = TicketManager::new(); + manager.shutdown(); + assert_eq!(manager.cache_count(), 0); + } +} diff --git a/pgdog/src/auth/mod.rs b/pgdog/src/auth/mod.rs index 98d6160a8..2bf996d75 100644 --- a/pgdog/src/auth/mod.rs +++ b/pgdog/src/auth/mod.rs @@ -1,6 +1,7 @@ //! PostgreSQL authentication mechanisms. pub mod error; +pub mod gssapi; pub mod md5; pub mod scram; diff --git a/pgdog/src/auth/scram/server.rs b/pgdog/src/auth/scram/server.rs index c18850a9b..aa5822fa8 100644 --- a/pgdog/src/auth/scram/server.rs +++ b/pgdog/src/auth/scram/server.rs @@ -191,8 +191,9 @@ impl Server { } Password::GssapiResponse { .. } => { - error!("GSSAPI authentication not yet implemented"); - return Ok(false); + // TODO: Implement GSSAPI response handling + // This will be implemented in Phase 3 + panic!("GSSAPI response handling not implemented"); } } } diff --git a/pgdog/src/backend/server.rs b/pgdog/src/backend/server.rs index 0b553cca4..d9036cb12 100644 --- a/pgdog/src/backend/server.rs +++ b/pgdog/src/backend/server.rs @@ -192,16 +192,14 @@ impl Server { stream.send_flush(&client.response()).await?; } Authentication::Gssapi | Authentication::Sspi => { - return Err(Error::Io(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "GSSAPI authentication not yet implemented", - ))); + // TODO: Implement GSSAPI authentication + // This will be implemented in Phase 3 + panic!("GSSAPI authentication not implemented"); } Authentication::GssapiContinue(_) => { - return Err(Error::Io(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "GSSAPI authentication not yet implemented", - ))); + // TODO: Implement GSSAPI continuation + // This will be implemented in Phase 3 + panic!("GSSAPI continuation not implemented"); } } } diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index 116c0eac8..b9db9aae8 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -183,10 +183,9 @@ impl Client { (AuthType::Trust, _) => true, (AuthType::Gssapi, _) => { - // GSSAPI authentication not yet implemented - // For now, we reject GSSAPI authentication attempts - error!("GSSAPI authentication requested but not yet implemented"); - false + // TODO: Implement GSSAPI authentication + // This will be implemented in Phase 3 + panic!("GSSAPI authentication not implemented"); } }; diff --git a/pgdog/tests/gssapi_integration_test.rs b/pgdog/tests/gssapi_integration_test.rs new file mode 100644 index 000000000..ad57f3f0c --- /dev/null +++ b/pgdog/tests/gssapi_integration_test.rs @@ -0,0 +1,149 @@ +//! GSSAPI authentication integration tests +//! +//! These tests are designed to fail initially as we implement the GSSAPI functionality. +//! They demonstrate the expected API and behavior for GSSAPI authentication. + +use pgdog::auth::gssapi::{TicketCache, TicketManager}; +use std::path::PathBuf; + +/// Test that TicketCache can acquire a credential from a keytab +#[test] +fn test_ticket_cache_acquires_credential() { + // This test MUST FAIL initially because TicketCache doesn't exist yet + let keytab_path = PathBuf::from("/etc/pgdog/test.keytab"); + let principal = "test@EXAMPLE.COM"; + + let cache = TicketCache::new(principal, keytab_path); + let ticket = cache.acquire_ticket(); + + assert!( + ticket.is_ok(), + "Failed to acquire ticket: {:?}", + ticket.err() + ); +} + +/// Test that TicketManager maintains per-server caches +#[test] +fn test_ticket_manager_per_server_cache() { + // This test MUST FAIL initially because TicketManager doesn't exist yet + let manager = TicketManager::new(); + + // Get ticket for server1 + let ticket1 = manager.get_ticket( + "server1:5432", + "/etc/pgdog/keytab1.keytab", + "principal1@REALM", + ); + + // Get ticket for server2 + let ticket2 = manager.get_ticket( + "server2:5432", + "/etc/pgdog/keytab2.keytab", + "principal2@REALM", + ); + + assert!(ticket1.is_ok(), "Failed to get ticket for server1"); + assert!(ticket2.is_ok(), "Failed to get ticket for server2"); + + // Verify they are different tickets + let cache1 = manager.get_cache("server1:5432"); + let cache2 = manager.get_cache("server2:5432"); + assert!(cache1.is_some()); + assert!(cache2.is_some()); + assert_ne!(cache1.unwrap().principal(), cache2.unwrap().principal()); +} + +/// Test GSSAPI frontend authentication flow +#[test] +fn test_gssapi_frontend_authentication() { + // This test MUST FAIL initially because handle_gssapi_auth doesn't exist yet + use pgdog::auth::gssapi::handle_gssapi_auth; + + let client_token = vec![0x60, 0x81]; // Mock GSSAPI token header + let result = handle_gssapi_auth(client_token); + + assert!( + result.is_ok(), + "Failed to handle GSSAPI auth: {:?}", + result.err() + ); + + let response = result.unwrap(); + assert!(response.is_complete || response.token.is_some()); +} + +/// Test ticket refresh mechanism +#[test] +fn test_ticket_refresh() { + // This test MUST FAIL initially + use std::time::Duration; + use tokio::time::sleep; + + let manager = TicketManager::new(); + manager.set_refresh_interval(Duration::from_secs(1)); // Short interval for testing + + let ticket = manager.get_ticket("server:5432", "/etc/pgdog/test.keytab", "test@REALM"); + assert!(ticket.is_ok()); + + let initial_refresh_time = manager.get_last_refresh("server:5432"); + + // Wait for refresh + std::thread::sleep(Duration::from_secs(2)); + + let new_refresh_time = manager.get_last_refresh("server:5432"); + assert!( + new_refresh_time > initial_refresh_time, + "Ticket was not refreshed" + ); +} + +/// Test GSSAPI context creation for backend connection +#[test] +fn test_backend_gssapi_context() { + // This test MUST FAIL initially + use pgdog::auth::gssapi::GssapiContext; + + let keytab = "/etc/pgdog/backend.keytab"; + let principal = "pgdog@REALM"; + let target = "postgres/db.example.com@REALM"; + + let mut context = GssapiContext::new_initiator(keytab, principal, target); + assert!(context.is_ok()); + + let mut ctx = context.unwrap(); + let initial_token = ctx.initiate(); + assert!(initial_token.is_ok()); + assert!(!initial_token.unwrap().is_empty()); +} + +/// Test error handling for missing keytab +#[test] +fn test_missing_keytab_error() { + // This test MUST FAIL initially (but in a controlled way) + let cache = TicketCache::new("test@REALM", PathBuf::from("/nonexistent/keytab")); + let ticket = cache.acquire_ticket(); + + assert!(ticket.is_err()); + let err = ticket.unwrap_err(); + assert!(err.to_string().contains("keytab")); +} + +/// Test cleanup of ticket caches on shutdown +#[test] +fn test_ticket_manager_cleanup() { + // This test MUST FAIL initially + let manager = TicketManager::new(); + + // Add some tickets + manager.get_ticket("server1:5432", "/etc/keytab1", "principal1@REALM"); + manager.get_ticket("server2:5432", "/etc/keytab2", "principal2@REALM"); + + assert_eq!(manager.cache_count(), 2); + + // Cleanup + manager.shutdown(); + + assert_eq!(manager.cache_count(), 0); + assert!(manager.get_cache("server1:5432").is_none()); +} From 0b3a898044364fbe03913b95374c7ba1a510c4ba Mon Sep 17 00:00:00 2001 From: Justin George Date: Wed, 17 Sep 2025 02:58:44 -0700 Subject: [PATCH 04/19] gssapi phase 4 --- Cargo.lock | 4 +- pgdog/Cargo.toml | 8 +- pgdog/src/auth/gssapi/context.rs | 66 ++++++++- pgdog/src/auth/gssapi/error.rs | 1 + pgdog/src/auth/gssapi/mod.rs | 40 ++++- pgdog/src/auth/gssapi/server.rs | 189 ++++++++++++++++++++++++ pgdog/src/auth/gssapi/tests.rs | 143 ++++++++++++++++++ pgdog/src/auth/gssapi/ticket_cache.rs | 112 ++++++++++++-- pgdog/src/auth/gssapi/ticket_manager.rs | 18 ++- pgdog/src/frontend/client/mod.rs | 103 ++++++++++++- pgdog/tests/gssapi_integration_test.rs | 51 +++++-- 11 files changed, 696 insertions(+), 39 deletions(-) create mode 100644 pgdog/src/auth/gssapi/server.rs create mode 100644 pgdog/src/auth/gssapi/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 128fb2f5c..0ce7ac979 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1790,9 +1790,9 @@ checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libgssapi" -version = "0.9.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "834339e86b2561169d45d3b01741967fee3e5716c7d0b6e33cd4e3b34c9558cd" +checksum = "e8663f3a3a93dd394b669dd9b213b457c5e0d2bc5a1b13a0950bd733c6fb6e37" dependencies = [ "bitflags 2.9.1", "bytes", diff --git a/pgdog/Cargo.toml b/pgdog/Cargo.toml index 33463eb42..0190ebdbc 100644 --- a/pgdog/Cargo.toml +++ b/pgdog/Cargo.toml @@ -12,8 +12,9 @@ default-run = "pgdog" [features] +default = [] tui = ["ratatui"] -# default = ["tui"] +gssapi = ["libgssapi"] [dependencies] @@ -60,9 +61,12 @@ indexmap = "2.9" lru = "0.16" hickory-resolver = "0.25.2" lazy_static = "1" -libgssapi = "0.9" dashmap = "5.5" +[dependencies.libgssapi] +version = "0.7" +optional = true + [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemallocator = "0.6" diff --git a/pgdog/src/auth/gssapi/context.rs b/pgdog/src/auth/gssapi/context.rs index 65d430a51..306dad680 100644 --- a/pgdog/src/auth/gssapi/context.rs +++ b/pgdog/src/auth/gssapi/context.rs @@ -1,15 +1,18 @@ //! GSSAPI context wrapper for authentication negotiation use super::error::{GssapiError, Result}; +use std::path::Path; + +#[cfg(feature = "gssapi")] use libgssapi::{ context::{ClientCtx, CtxFlags, SecurityContext}, credential::{Cred, CredUsage}, name::Name, oid::{OidSet, GSS_MECH_KRB5, GSS_NT_HOSTBASED_SERVICE}, }; -use std::path::Path; /// Wrapper for GSSAPI security context +#[cfg(feature = "gssapi")] pub struct GssapiContext { /// The underlying libgssapi context inner: ClientCtx, @@ -19,6 +22,16 @@ pub struct GssapiContext { is_complete: bool, } +/// Mock GSSAPI context for when the feature is disabled. +#[cfg(not(feature = "gssapi"))] +pub struct GssapiContext { + /// The target service principal + target_principal: String, + /// Whether the context is complete + is_complete: bool, +} + +#[cfg(feature = "gssapi")] impl GssapiContext { /// Create a new initiator context (for connecting to a backend) pub fn new_initiator( @@ -114,6 +127,52 @@ impl GssapiContext { } } +#[cfg(not(feature = "gssapi"))] +impl GssapiContext { + /// Create a new initiator context (mock version) + pub fn new_initiator( + _keytab: impl AsRef, + _principal: impl Into, + target: impl Into, + ) -> Result { + Ok(Self { + target_principal: target.into(), + is_complete: false, + }) + } + + /// Initiate the GSSAPI handshake (mock version) + pub fn initiate(&mut self) -> Result> { + Err(GssapiError::LibGssapi( + "GSSAPI support not compiled in".to_string(), + )) + } + + /// Process a response token from the server (mock version) + pub fn process_response(&mut self, _token: &[u8]) -> Result>> { + Err(GssapiError::LibGssapi( + "GSSAPI support not compiled in".to_string(), + )) + } + + /// Check if the context establishment is complete + pub fn is_complete(&self) -> bool { + self.is_complete + } + + /// Get the target principal + pub fn target_principal(&self) -> &str { + &self.target_principal + } + + /// Get the authenticated client name (mock version) + pub fn client_name(&mut self) -> Result { + Err(GssapiError::LibGssapi( + "GSSAPI support not compiled in".to_string(), + )) + } +} + #[cfg(test)] mod tests { use super::*; @@ -127,7 +186,10 @@ mod tests { "postgres/db.example.com@EXAMPLE.COM", ); - // We expect this to fail without a real keytab + #[cfg(feature = "gssapi")] assert!(result.is_err()); + + #[cfg(not(feature = "gssapi"))] + assert!(result.is_ok()); } } diff --git a/pgdog/src/auth/gssapi/error.rs b/pgdog/src/auth/gssapi/error.rs index 9e24995c4..8f868b311 100644 --- a/pgdog/src/auth/gssapi/error.rs +++ b/pgdog/src/auth/gssapi/error.rs @@ -75,6 +75,7 @@ impl From for GssapiError { } // Convert libgssapi errors when we implement the actual functionality +#[cfg(feature = "gssapi")] impl From for GssapiError { fn from(err: libgssapi::error::Error) -> Self { Self::LibGssapi(err.to_string()) diff --git a/pgdog/src/auth/gssapi/mod.rs b/pgdog/src/auth/gssapi/mod.rs index 10662ced7..c6c7d5427 100644 --- a/pgdog/src/auth/gssapi/mod.rs +++ b/pgdog/src/auth/gssapi/mod.rs @@ -5,18 +5,52 @@ pub mod context; pub mod error; +pub mod server; pub mod ticket_cache; pub mod ticket_manager; +#[cfg(test)] +mod tests; + pub use context::GssapiContext; pub use error::{GssapiError, Result}; +pub use server::GssapiServer; pub use ticket_cache::TicketCache; pub use ticket_manager::TicketManager; +use std::sync::Arc; +use tokio::sync::Mutex; + /// Handle GSSAPI authentication from a client -pub fn handle_gssapi_auth(_client_token: Vec) -> Result { - // TODO: Implement - unimplemented!("handle_gssapi_auth not yet implemented") +pub async fn handle_gssapi_auth( + server: Arc>, + client_token: Vec, +) -> Result { + let mut server = server.lock().await; + + match server.accept(&client_token)? { + Some(response_token) => { + // More negotiation needed + Ok(GssapiResponse { + is_complete: false, + token: Some(response_token), + principal: None, + }) + } + None => { + // Authentication complete + let principal = server + .client_principal() + .ok_or_else(|| GssapiError::ContextError("No client principal found".to_string()))? + .to_string(); + + Ok(GssapiResponse { + is_complete: true, + token: None, + principal: Some(principal), + }) + } + } } /// Response from GSSAPI authentication handling diff --git a/pgdog/src/auth/gssapi/server.rs b/pgdog/src/auth/gssapi/server.rs new file mode 100644 index 000000000..1d5973119 --- /dev/null +++ b/pgdog/src/auth/gssapi/server.rs @@ -0,0 +1,189 @@ +//! Server-side GSSAPI authentication handler. + +use super::error::{GssapiError, Result}; +use std::path::Path; + +#[cfg(feature = "gssapi")] +use libgssapi::{ + context::{SecurityContext, ServerCtx}, + credential::{Cred, CredUsage}, + name::Name, + oid::{OidSet, GSS_MECH_KRB5, GSS_NT_HOSTBASED_SERVICE}, +}; + +/// Server-side GSSAPI context for accepting client connections. +#[cfg(feature = "gssapi")] +pub struct GssapiServer { + /// The underlying libgssapi server context. + inner: Option, + /// Server credentials. + credential: Cred, + /// Whether the context establishment is complete. + is_complete: bool, + /// The authenticated client principal (once complete). + client_principal: Option, +} + +/// Mock GSSAPI server for when the feature is disabled. +#[cfg(not(feature = "gssapi"))] +pub struct GssapiServer { + /// Whether the context establishment is complete. + is_complete: bool, + /// The authenticated client principal (once complete). + client_principal: Option, +} + +#[cfg(feature = "gssapi")] +impl GssapiServer { + /// Create a new acceptor context. + pub fn new_acceptor(keytab: impl AsRef, principal: Option<&str>) -> Result { + let keytab = keytab.as_ref(); + + // Set the keytab for the server + std::env::set_var("KRB5_KTNAME", keytab); + + // Create credentials for accepting + let credential = if let Some(principal) = principal { + // Parse the service principal + let service_name = Name::new(principal.as_bytes(), Some(&GSS_NT_HOSTBASED_SERVICE)) + .map_err(|e| GssapiError::InvalidPrincipal(format!("{}: {}", principal, e)))?; + + // Create the desired mechanisms set + let mut desired_mechs = OidSet::new() + .map_err(|e| GssapiError::LibGssapi(format!("Failed to create OidSet: {}", e)))?; + desired_mechs + .add(&GSS_MECH_KRB5) + .map_err(|e| GssapiError::LibGssapi(format!("Failed to add mechanism: {}", e)))?; + + // Acquire credentials for the specified principal + Cred::acquire( + Some(&service_name), + None, + CredUsage::Accept, + Some(&desired_mechs), + ) + .map_err(|e| { + GssapiError::CredentialAcquisitionFailed(format!( + "Failed to acquire credentials for {}: {}", + principal, e + )) + })? + } else { + // Use default service principal + Cred::acquire(None, None, CredUsage::Accept, None).map_err(|e| { + GssapiError::CredentialAcquisitionFailed(format!( + "Failed to acquire default credentials: {}", + e + )) + })? + }; + + Ok(Self { + inner: None, + credential, + is_complete: false, + client_principal: None, + }) + } + + /// Process a token from the client. + pub fn accept(&mut self, client_token: &[u8]) -> Result>> { + if self.is_complete { + return Err(GssapiError::ContextError( + "Context already complete".to_string(), + )); + } + + // Create or reuse the server context + let ctx = match self.inner.take() { + Some(ctx) => ctx, + None => ServerCtx::new(self.credential.clone()), + }; + + // Process the client token + match ctx.step(client_token) { + Ok((ctx, Some(response))) => { + // More negotiation needed + self.inner = Some(ctx); + Ok(Some(response.to_vec())) + } + Ok((mut ctx, None)) => { + // Context established successfully + self.is_complete = true; + + // Extract the client principal + match ctx.source_name() { + Ok(name) => { + self.client_principal = Some(name.to_string()); + } + Err(e) => { + return Err(GssapiError::ContextError(format!( + "Failed to get client principal: {}", + e + ))); + } + } + + Ok(None) + } + Err(e) => Err(GssapiError::ContextError(format!( + "GSSAPI negotiation failed: {}", + e + ))), + } + } + + /// Check if the context establishment is complete. + pub fn is_complete(&self) -> bool { + self.is_complete + } + + /// Get the authenticated client principal. + pub fn client_principal(&self) -> Option<&str> { + self.client_principal.as_deref() + } +} + +#[cfg(not(feature = "gssapi"))] +impl GssapiServer { + /// Create a new acceptor context (mock version). + pub fn new_acceptor(_keytab: impl AsRef, _principal: Option<&str>) -> Result { + Err(GssapiError::LibGssapi( + "GSSAPI support not compiled in".to_string(), + )) + } + + /// Process a token from the client (mock version). + pub fn accept(&mut self, _client_token: &[u8]) -> Result>> { + Err(GssapiError::LibGssapi( + "GSSAPI support not compiled in".to_string(), + )) + } + + /// Check if the context establishment is complete. + pub fn is_complete(&self) -> bool { + self.is_complete + } + + /// Get the authenticated client principal. + pub fn client_principal(&self) -> Option<&str> { + self.client_principal.as_deref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_server_creation() { + // This will fail without a real keytab + let result = GssapiServer::new_acceptor( + "/etc/pgdog/pgdog.keytab", + Some("postgres/pgdog.example.com@EXAMPLE.COM"), + ); + + // We expect this to fail without a real keytab or when feature is disabled + assert!(result.is_err()); + } +} diff --git a/pgdog/src/auth/gssapi/tests.rs b/pgdog/src/auth/gssapi/tests.rs new file mode 100644 index 000000000..2cc4061dc --- /dev/null +++ b/pgdog/src/auth/gssapi/tests.rs @@ -0,0 +1,143 @@ +//! Unit tests for GSSAPI authentication module + +#[cfg(test)] +mod tests { + use super::super::*; + use std::path::PathBuf; + use std::time::Duration; + + #[test] + fn test_gssapi_response_creation() { + let response = GssapiResponse { + is_complete: false, + token: Some(vec![1, 2, 3, 4]), + principal: None, + }; + assert!(!response.is_complete); + assert!(response.token.is_some()); + assert!(response.principal.is_none()); + } + + #[test] + fn test_gssapi_response_complete() { + let response = GssapiResponse { + is_complete: true, + token: None, + principal: Some("user@REALM".to_string()), + }; + assert!(response.is_complete); + assert!(response.token.is_none()); + assert_eq!(response.principal, Some("user@REALM".to_string())); + } + + #[test] + fn test_principal_realm_stripping() { + let principal = "user@EXAMPLE.COM"; + let stripped = principal.split('@').next().unwrap_or(principal); + assert_eq!(stripped, "user"); + + let principal_no_realm = "user"; + let stripped = principal_no_realm + .split('@') + .next() + .unwrap_or(principal_no_realm); + assert_eq!(stripped, "user"); + } + + #[test] + fn test_gssapi_error_types() { + let err = GssapiError::KeytabNotFound(PathBuf::from("/etc/test.keytab")); + assert!(matches!(err, GssapiError::KeytabNotFound(_))); + + let err = GssapiError::InvalidPrincipal("bad@principal".to_string()); + assert!(matches!(err, GssapiError::InvalidPrincipal(_))); + + let err = GssapiError::CredentialAcquisitionFailed("test failure".to_string()); + assert!(matches!(err, GssapiError::CredentialAcquisitionFailed(_))); + + let err = GssapiError::ContextError("context failed".to_string()); + assert!(matches!(err, GssapiError::ContextError(_))); + + let err = GssapiError::LibGssapi("lib error".to_string()); + assert!(matches!(err, GssapiError::LibGssapi(_))); + } + + #[cfg(not(feature = "gssapi"))] + #[test] + fn test_mock_gssapi_server() { + // When GSSAPI feature is disabled, ensure we get appropriate errors + let result = GssapiServer::new_acceptor("/etc/test.keytab", Some("test@REALM")); + assert!(result.is_err()); + if let Err(err) = result { + match err { + GssapiError::LibGssapi(msg) => { + assert!(msg.contains("not compiled")); + } + _ => panic!("Expected LibGssapi error"), + } + } + } + + #[cfg(not(feature = "gssapi"))] + #[test] + fn test_mock_gssapi_context() { + let result = + GssapiContext::new_initiator("/etc/test.keytab", "client@REALM", "service@REALM"); + // Mock version should succeed in creation + assert!(result.is_ok()); + + let mut ctx = result.unwrap(); + assert_eq!(ctx.target_principal(), "service@REALM"); + assert!(!ctx.is_complete()); + + // But operations should fail + let result = ctx.initiate(); + assert!(result.is_err()); + } + + #[cfg(not(feature = "gssapi"))] + #[test] + fn test_mock_ticket_cache() { + let cache = TicketCache::new("test@REALM", PathBuf::from("/etc/test.keytab")); + assert_eq!(cache.principal(), "test@REALM"); + assert_eq!(cache.keytab_path(), &PathBuf::from("/etc/test.keytab")); + assert!(!cache.needs_refresh()); + + let result = cache.acquire_ticket(); + assert!(result.is_err()); + } + + #[test] + fn test_ticket_manager_singleton() { + let manager1 = TicketManager::global(); + let manager2 = TicketManager::global(); + + // Should be the same instance + assert!(std::sync::Arc::ptr_eq(&manager1, &manager2)); + } + + #[cfg(feature = "gssapi")] + #[test] + fn test_gssapi_server_with_feature() { + // With GSSAPI feature enabled, test should fail due to missing keytab + let result = GssapiServer::new_acceptor("/nonexistent/test.keytab", Some("test@REALM")); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_handle_gssapi_auth_mock() { + // This test works regardless of feature flag + #[cfg(not(feature = "gssapi"))] + { + let server = GssapiServer::new_acceptor("/test.keytab", None); + assert!(server.is_err()); + } + + #[cfg(feature = "gssapi")] + { + // With real GSSAPI, this will fail without a keytab + let server = GssapiServer::new_acceptor("/test.keytab", None); + assert!(server.is_err()); + } + } +} diff --git a/pgdog/src/auth/gssapi/ticket_cache.rs b/pgdog/src/auth/gssapi/ticket_cache.rs index 9aa8420c3..14dc8b10f 100644 --- a/pgdog/src/auth/gssapi/ticket_cache.rs +++ b/pgdog/src/auth/gssapi/ticket_cache.rs @@ -1,17 +1,19 @@ //! Per-server Kerberos ticket cache use super::error::{GssapiError, Result}; +use parking_lot::RwLock; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +#[cfg(feature = "gssapi")] use libgssapi::{ credential::{Cred, CredUsage}, name::Name, oid::{OidSet, GSS_MECH_KRB5, GSS_NT_KRB5_PRINCIPAL}, }; -use parking_lot::RwLock; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::{Duration, Instant}; /// Cache for a single server's Kerberos ticket +#[cfg(feature = "gssapi")] pub struct TicketCache { /// The principal for this cache principal: String, @@ -25,6 +27,20 @@ pub struct TicketCache { refresh_interval: Duration, } +/// Mock ticket cache for when the feature is disabled +#[cfg(not(feature = "gssapi"))] +pub struct TicketCache { + /// The principal for this cache + principal: String, + /// Path to the keytab file + keytab_path: PathBuf, + /// When the ticket was last refreshed + last_refresh: RwLock, + /// How often to refresh the ticket + refresh_interval: Duration, +} + +#[cfg(feature = "gssapi")] impl TicketCache { /// Create a new ticket cache pub fn new(principal: impl Into, keytab_path: impl Into) -> Self { @@ -132,6 +148,68 @@ impl TicketCache { } } +#[cfg(not(feature = "gssapi"))] +impl TicketCache { + /// Create a new ticket cache + pub fn new(principal: impl Into, keytab_path: impl Into) -> Self { + Self { + principal: principal.into(), + keytab_path: keytab_path.into(), + last_refresh: RwLock::new(Instant::now()), + refresh_interval: Duration::from_secs(14400), // 4 hours default + } + } + + /// Set the refresh interval + pub fn set_refresh_interval(&mut self, interval: Duration) { + self.refresh_interval = interval; + } + + /// Get the principal name + pub fn principal(&self) -> &str { + &self.principal + } + + /// Get the keytab path + pub fn keytab_path(&self) -> &PathBuf { + &self.keytab_path + } + + /// Acquire a ticket from the keytab (mock) + pub fn acquire_ticket(&self) -> Result<()> { + Err(GssapiError::LibGssapi( + "GSSAPI support not compiled in".to_string(), + )) + } + + /// Get the cached credential (mock) + pub fn get_credential(&self) -> Result<()> { + Err(GssapiError::LibGssapi( + "GSSAPI support not compiled in".to_string(), + )) + } + + /// Check if the ticket needs refresh + pub fn needs_refresh(&self) -> bool { + false + } + + /// Get the last refresh time + pub fn last_refresh(&self) -> Instant { + *self.last_refresh.read() + } + + /// Refresh the ticket (mock) + pub fn refresh(&self) -> Result<()> { + Err(GssapiError::LibGssapi( + "GSSAPI support not compiled in".to_string(), + )) + } + + /// Clear the cached credential + pub fn clear(&self) {} +} + #[cfg(test)] mod tests { use super::*; @@ -146,13 +224,27 @@ mod tests { #[test] fn test_missing_keytab_error() { let cache = TicketCache::new("test@REALM", "/nonexistent/keytab"); - let result = cache.acquire_ticket(); - assert!(result.is_err()); - match result.unwrap_err() { - GssapiError::KeytabNotFound(path) => { - assert_eq!(path, PathBuf::from("/nonexistent/keytab")); + #[cfg(feature = "gssapi")] + { + let result = cache.acquire_ticket(); + assert!(result.is_err()); + match result.unwrap_err() { + GssapiError::KeytabNotFound(path) => { + assert_eq!(path, PathBuf::from("/nonexistent/keytab")); + } + _ => panic!("Expected KeytabNotFound error"), + } + } + #[cfg(not(feature = "gssapi"))] + { + let result = cache.acquire_ticket(); + assert!(result.is_err()); + match result.unwrap_err() { + GssapiError::LibGssapi(msg) => { + assert!(msg.contains("not compiled")); + } + _ => panic!("Expected LibGssapi error"), } - _ => panic!("Expected KeytabNotFound error"), } } diff --git a/pgdog/src/auth/gssapi/ticket_manager.rs b/pgdog/src/auth/gssapi/ticket_manager.rs index 1d208c635..9720dbe02 100644 --- a/pgdog/src/auth/gssapi/ticket_manager.rs +++ b/pgdog/src/auth/gssapi/ticket_manager.rs @@ -4,12 +4,14 @@ use super::error::Result; use super::ticket_cache::TicketCache; use dashmap::DashMap; use lazy_static::lazy_static; -use libgssapi::credential::Cred; use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::task::JoinHandle; +#[cfg(feature = "gssapi")] +use libgssapi::credential::Cred; + lazy_static! { /// Global ticket manager instance static ref INSTANCE: Arc = Arc::new(TicketManager::new()); @@ -38,6 +40,7 @@ impl TicketManager { } /// Get or acquire a ticket for a server + #[cfg(feature = "gssapi")] pub fn get_ticket( &self, server: impl Into, @@ -68,6 +71,19 @@ impl TicketManager { Ok(credential) } + /// Get or acquire a ticket for a server (mock version) + #[cfg(not(feature = "gssapi"))] + pub fn get_ticket( + &self, + _server: impl Into, + _keytab: impl AsRef, + _principal: impl Into, + ) -> Result<()> { + Err(super::error::GssapiError::LibGssapi( + "GSSAPI support not compiled in".to_string(), + )) + } + /// Start a background refresh task for a cache fn start_refresh_task(&self, server: String, cache: Arc) { let _refresh_tasks = self.refresh_tasks.clone(); diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index b9db9aae8..0cb2210e9 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -183,9 +183,106 @@ impl Client { (AuthType::Trust, _) => true, (AuthType::Gssapi, _) => { - // TODO: Implement GSSAPI authentication - // This will be implemented in Phase 3 - panic!("GSSAPI authentication not implemented"); + if let Some(gssapi_config) = config.config.gssapi.as_ref() { + if !gssapi_config.is_configured() { + error!("GSSAPI authentication requested but not properly configured"); + false + } else { + // Initialize the GSSAPI server context + match crate::auth::gssapi::GssapiServer::new_acceptor( + gssapi_config.server_keytab.as_ref().unwrap(), + gssapi_config.server_principal.as_deref(), + ) { + Ok(server) => { + let server = std::sync::Arc::new(tokio::sync::Mutex::new(server)); + + // Send initial GSSAPI authentication request + stream.send_flush(&Authentication::Gssapi).await?; + + // GSSAPI negotiation loop + let mut auth_ok = false; + loop { + // Read client token + let message = stream.read().await?; + let client_token = + match Password::from_bytes(message.to_bytes()?)? { + Password::GssapiResponse { data } => data, + _ => { + error!("Expected GSSAPI token from client"); + break; + } + }; + + // Process token + let response = match crate::auth::gssapi::handle_gssapi_auth( + server.clone(), + client_token, + ) + .await + { + Ok(response) => response, + Err(e) => { + error!("GSSAPI authentication failed: {}", e); + break; + } + }; + + if response.is_complete { + // Authentication successful + if let Some(principal) = response.principal { + // Apply realm stripping if configured + let extracted_user = if gssapi_config.strip_realm { + principal + .split('@') + .next() + .unwrap_or(&principal) + .to_string() + } else { + principal.clone() + }; + + // Verify the extracted user matches the requested user + if extracted_user == user { + auth_ok = true; + debug!( + "GSSAPI authentication successful for principal: {}", + principal + ); + } else { + error!( + "GSSAPI principal {} does not match requested user {}", + extracted_user, user + ); + } + } + break; + } else if let Some(token) = response.token { + // Send continuation token + stream + .send_flush(&Authentication::GssapiContinue(token)) + .await?; + } else { + error!("GSSAPI negotiation error: no token in incomplete response"); + break; + } + } + + auth_ok + } + Err(e) => { + error!("Failed to initialize GSSAPI server: {}", e); + if gssapi_config.fallback_enabled { + // Fall back to next auth method + true // Will be handled by fallback logic + } else { + false + } + } + } + } + } else { + false + } } }; diff --git a/pgdog/tests/gssapi_integration_test.rs b/pgdog/tests/gssapi_integration_test.rs index ad57f3f0c..e71021449 100644 --- a/pgdog/tests/gssapi_integration_test.rs +++ b/pgdog/tests/gssapi_integration_test.rs @@ -55,13 +55,23 @@ fn test_ticket_manager_per_server_cache() { } /// Test GSSAPI frontend authentication flow -#[test] -fn test_gssapi_frontend_authentication() { - // This test MUST FAIL initially because handle_gssapi_auth doesn't exist yet - use pgdog::auth::gssapi::handle_gssapi_auth; - +#[tokio::test] +async fn test_gssapi_frontend_authentication() { + // This test demonstrates the async API + use pgdog::auth::gssapi::{handle_gssapi_auth, GssapiServer}; + use std::sync::Arc; + use tokio::sync::Mutex; + + // This will fail without a real keytab + let server = GssapiServer::new_acceptor("/test.keytab", None); + if server.is_err() { + // Expected to fail without real keytab + return; + } + + let server = Arc::new(Mutex::new(server.unwrap())); let client_token = vec![0x60, 0x81]; // Mock GSSAPI token header - let result = handle_gssapi_auth(client_token); + let result = handle_gssapi_auth(server, client_token).await; assert!( result.is_ok(), @@ -76,9 +86,8 @@ fn test_gssapi_frontend_authentication() { /// Test ticket refresh mechanism #[test] fn test_ticket_refresh() { - // This test MUST FAIL initially + // This test demonstrates ticket refresh use std::time::Duration; - use tokio::time::sleep; let manager = TicketManager::new(); manager.set_refresh_interval(Duration::from_secs(1)); // Short interval for testing @@ -101,20 +110,30 @@ fn test_ticket_refresh() { /// Test GSSAPI context creation for backend connection #[test] fn test_backend_gssapi_context() { - // This test MUST FAIL initially + // This test demonstrates GssapiContext API use pgdog::auth::gssapi::GssapiContext; let keytab = "/etc/pgdog/backend.keytab"; let principal = "pgdog@REALM"; let target = "postgres/db.example.com@REALM"; - let mut context = GssapiContext::new_initiator(keytab, principal, target); - assert!(context.is_ok()); - - let mut ctx = context.unwrap(); - let initial_token = ctx.initiate(); - assert!(initial_token.is_ok()); - assert!(!initial_token.unwrap().is_empty()); + let context = GssapiContext::new_initiator(keytab, principal, target); + + #[cfg(feature = "gssapi")] + { + // With real GSSAPI, this will fail without keytab + assert!(context.is_err()); + } + + #[cfg(not(feature = "gssapi"))] + { + // Mock version should succeed in creation + assert!(context.is_ok()); + let mut ctx = context.unwrap(); + // But operations will fail + let initial_token = ctx.initiate(); + assert!(initial_token.is_err()); + } } /// Test error handling for missing keytab From 5f6841d4b9ae2089d82c88796c6d58626cb5a553 Mon Sep 17 00:00:00 2001 From: Justin George Date: Wed, 17 Sep 2025 19:00:25 -0700 Subject: [PATCH 05/19] gssapi mostly working... --- Cargo.lock | 4 +- pgdog/Cargo.toml | 2 +- pgdog/src/auth/gssapi/context.rs | 25 ++- pgdog/src/auth/gssapi/server.rs | 20 ++- pgdog/src/auth/gssapi/tests.rs | 1 - pgdog/src/auth/gssapi/ticket_cache.rs | 1 + pgdog/src/auth/gssapi/ticket_manager.rs | 49 ++++-- pgdog/src/backend/pool/address.rs | 53 +++++- pgdog/src/backend/server.rs | 136 ++++++++++++++- pgdog/src/config/database.rs | 2 + pgdog/src/config/gssapi.rs | 5 + pgdog/src/config/users.rs | 2 + pgdog/src/frontend/client/mod.rs | 223 +++++++++++++----------- pgdog/src/frontend/listener.rs | 11 +- pgdog/src/net/messages/hello.rs | 64 ++++++- pgdog/tests/backend_gssapi_test.rs | 175 +++++++++++++++++++ pgdog/tests/gssapi_integration_test.rs | 4 +- 17 files changed, 613 insertions(+), 164 deletions(-) create mode 100644 pgdog/tests/backend_gssapi_test.rs diff --git a/Cargo.lock b/Cargo.lock index 0ce7ac979..128fb2f5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1790,9 +1790,9 @@ checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libgssapi" -version = "0.7.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8663f3a3a93dd394b669dd9b213b457c5e0d2bc5a1b13a0950bd733c6fb6e37" +checksum = "834339e86b2561169d45d3b01741967fee3e5716c7d0b6e33cd4e3b34c9558cd" dependencies = [ "bitflags 2.9.1", "bytes", diff --git a/pgdog/Cargo.toml b/pgdog/Cargo.toml index 0190ebdbc..5596f6773 100644 --- a/pgdog/Cargo.toml +++ b/pgdog/Cargo.toml @@ -64,7 +64,7 @@ lazy_static = "1" dashmap = "5.5" [dependencies.libgssapi] -version = "0.7" +version = "0.9.1" optional = true [target.'cfg(not(target_env = "msvc"))'.dependencies] diff --git a/pgdog/src/auth/gssapi/context.rs b/pgdog/src/auth/gssapi/context.rs index 306dad680..ef17f6630 100644 --- a/pgdog/src/auth/gssapi/context.rs +++ b/pgdog/src/auth/gssapi/context.rs @@ -8,7 +8,7 @@ use libgssapi::{ context::{ClientCtx, CtxFlags, SecurityContext}, credential::{Cred, CredUsage}, name::Name, - oid::{OidSet, GSS_MECH_KRB5, GSS_NT_HOSTBASED_SERVICE}, + oid::{OidSet, GSS_MECH_KRB5, GSS_NT_KRB5_PRINCIPAL}, }; /// Wrapper for GSSAPI security context @@ -39,16 +39,12 @@ impl GssapiContext { principal: impl Into, target: impl Into, ) -> Result { - let keytab = keytab.as_ref(); + let _keytab = keytab.as_ref(); let principal = principal.into(); let target_principal = target.into(); - // Set the keytab - std::env::set_var("KRB5_CLIENT_KTNAME", keytab); - - // Parse our principal - let our_name = Name::new(principal.as_bytes(), Some(&GSS_NT_HOSTBASED_SERVICE)) - .map_err(|e| GssapiError::InvalidPrincipal(format!("{}: {}", principal, e)))?; + // TicketManager has already set up the credential cache with KRB5CCNAME + // We just need to acquire credentials from that cache // Create the desired mechanisms set let mut desired_mechs = OidSet::new() @@ -57,9 +53,10 @@ impl GssapiContext { .add(&GSS_MECH_KRB5) .map_err(|e| GssapiError::LibGssapi(format!("Failed to add mechanism: {}", e)))?; - // Acquire credentials + // Acquire credentials from the cache that TicketManager populated + // Pass None to use the default principal from the cache let credential = Cred::acquire( - Some(&our_name), + None, // Use the principal from the cache that TicketManager set up None, CredUsage::Initiate, Some(&desired_mechs), @@ -68,11 +65,9 @@ impl GssapiContext { GssapiError::CredentialAcquisitionFailed(format!("Failed for {}: {}", principal, e)) })?; - // Parse target service principal - let target_name = Name::new(target_principal.as_bytes(), Some(&GSS_NT_HOSTBASED_SERVICE)) - .map_err(|e| { - GssapiError::InvalidPrincipal(format!("{}: {}", target_principal, e)) - })?; + // Parse target service principal (use KRB5_PRINCIPAL to avoid hostname canonicalization) + let target_name = Name::new(target_principal.as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL)) + .map_err(|e| GssapiError::InvalidPrincipal(format!("{}: {}", target_principal, e)))?; // Create the client context let flags = CtxFlags::GSS_C_MUTUAL_FLAG | CtxFlags::GSS_C_SEQUENCE_FLAG; diff --git a/pgdog/src/auth/gssapi/server.rs b/pgdog/src/auth/gssapi/server.rs index 1d5973119..c00fba73f 100644 --- a/pgdog/src/auth/gssapi/server.rs +++ b/pgdog/src/auth/gssapi/server.rs @@ -2,13 +2,14 @@ use super::error::{GssapiError, Result}; use std::path::Path; +use std::sync::Arc; #[cfg(feature = "gssapi")] use libgssapi::{ context::{SecurityContext, ServerCtx}, credential::{Cred, CredUsage}, name::Name, - oid::{OidSet, GSS_MECH_KRB5, GSS_NT_HOSTBASED_SERVICE}, + oid::{OidSet, GSS_MECH_KRB5, GSS_NT_KRB5_PRINCIPAL}, }; /// Server-side GSSAPI context for accepting client connections. @@ -17,7 +18,7 @@ pub struct GssapiServer { /// The underlying libgssapi server context. inner: Option, /// Server credentials. - credential: Cred, + credential: Arc, /// Whether the context establishment is complete. is_complete: bool, /// The authenticated client principal (once complete). @@ -44,8 +45,8 @@ impl GssapiServer { // Create credentials for accepting let credential = if let Some(principal) = principal { - // Parse the service principal - let service_name = Name::new(principal.as_bytes(), Some(&GSS_NT_HOSTBASED_SERVICE)) + // Parse the service principal (use KRB5_PRINCIPAL to avoid canonicalization) + let service_name = Name::new(principal.as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL)) .map_err(|e| GssapiError::InvalidPrincipal(format!("{}: {}", principal, e)))?; // Create the desired mechanisms set @@ -80,7 +81,7 @@ impl GssapiServer { Ok(Self { inner: None, - credential, + credential: Arc::new(credential), is_complete: false, client_principal: None, }) @@ -95,19 +96,19 @@ impl GssapiServer { } // Create or reuse the server context - let ctx = match self.inner.take() { + let mut ctx = match self.inner.take() { Some(ctx) => ctx, - None => ServerCtx::new(self.credential.clone()), + None => ServerCtx::new(Some(self.credential.as_ref().clone())), }; // Process the client token match ctx.step(client_token) { - Ok((ctx, Some(response))) => { + Ok(Some(response)) => { // More negotiation needed self.inner = Some(ctx); Ok(Some(response.to_vec())) } - Ok((mut ctx, None)) => { + Ok(None) => { // Context established successfully self.is_complete = true; @@ -124,6 +125,7 @@ impl GssapiServer { } } + self.inner = Some(ctx); Ok(None) } Err(e) => Err(GssapiError::ContextError(format!( diff --git a/pgdog/src/auth/gssapi/tests.rs b/pgdog/src/auth/gssapi/tests.rs index 2cc4061dc..c5bece979 100644 --- a/pgdog/src/auth/gssapi/tests.rs +++ b/pgdog/src/auth/gssapi/tests.rs @@ -4,7 +4,6 @@ mod tests { use super::super::*; use std::path::PathBuf; - use std::time::Duration; #[test] fn test_gssapi_response_creation() { diff --git a/pgdog/src/auth/gssapi/ticket_cache.rs b/pgdog/src/auth/gssapi/ticket_cache.rs index 14dc8b10f..60b681a12 100644 --- a/pgdog/src/auth/gssapi/ticket_cache.rs +++ b/pgdog/src/auth/gssapi/ticket_cache.rs @@ -3,6 +3,7 @@ use super::error::{GssapiError, Result}; use parking_lot::RwLock; use std::path::PathBuf; +use std::sync::Arc; use std::time::{Duration, Instant}; #[cfg(feature = "gssapi")] diff --git a/pgdog/src/auth/gssapi/ticket_manager.rs b/pgdog/src/auth/gssapi/ticket_manager.rs index 9720dbe02..ba75f6ac9 100644 --- a/pgdog/src/auth/gssapi/ticket_manager.rs +++ b/pgdog/src/auth/gssapi/ticket_manager.rs @@ -9,9 +9,6 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::task::JoinHandle; -#[cfg(feature = "gssapi")] -use libgssapi::credential::Cred; - lazy_static! { /// Global ticket manager instance static ref INSTANCE: Arc = Arc::new(TicketManager::new()); @@ -40,27 +37,55 @@ impl TicketManager { } /// Get or acquire a ticket for a server + /// Returns Ok(()) when the credential cache is ready to use #[cfg(feature = "gssapi")] pub fn get_ticket( &self, server: impl Into, keytab: impl AsRef, principal: impl Into, - ) -> Result> { + ) -> Result<()> { let server = server.into(); let keytab_path = keytab.as_ref().to_path_buf(); let principal = principal.into(); // Check if we already have a cache for this server - if let Some(cache) = self.caches.get(&server) { - return cache.get_credential(); + if self.caches.contains_key(&server) { + // Cache already exists and environment is set + return Ok(()); } - // Create a new cache - let cache = Arc::new(TicketCache::new(principal, keytab_path)); + // Create a unique credential cache file for this server connection + let cache_file = format!("/tmp/krb5cc_pgdog_{}", server.replace(":", "_")); + let cache_path = format!("FILE:{}", cache_file); + + // Set the environment variable for this thread + std::env::set_var("KRB5CCNAME", &cache_path); + + // Use kinit to get a ticket from the keytab into the unique cache + let output = std::process::Command::new("kinit") + .arg("-kt") + .arg(&keytab_path) + .arg(&principal) + .env("KRB5CCNAME", &cache_path) + .env("KRB5_CONFIG", "/opt/homebrew/etc/krb5.conf") + .output() + .map_err(|e| { + super::error::GssapiError::LibGssapi(format!("Failed to run kinit: {}", e)) + })?; + + if !output.status.success() { + return Err(super::error::GssapiError::CredentialAcquisitionFailed( + format!( + "kinit failed for {}: {}", + principal, + String::from_utf8_lossy(&output.stderr) + ), + )); + } - // Acquire the initial ticket - let credential = cache.acquire_ticket()?; + // Create a new cache object for tracking (but don't acquire credentials - kinit already did that) + let cache = Arc::new(TicketCache::new(principal, keytab_path)); // Store the cache self.caches.insert(server.clone(), cache.clone()); @@ -68,7 +93,8 @@ impl TicketManager { // Start background refresh task self.start_refresh_task(server, cache); - Ok(credential) + // Return success - the credential cache is now populated and KRB5CCNAME is set + Ok(()) } /// Get or acquire a ticket for a server (mock version) @@ -85,6 +111,7 @@ impl TicketManager { } /// Start a background refresh task for a cache + #[allow(dead_code)] fn start_refresh_task(&self, server: String, cache: Arc) { let _refresh_tasks = self.refresh_tasks.clone(); let server_clone = server.clone(); diff --git a/pgdog/src/backend/pool/address.rs b/pgdog/src/backend/pool/address.rs index 48a0e5085..b600802b1 100644 --- a/pgdog/src/backend/pool/address.rs +++ b/pgdog/src/backend/pool/address.rs @@ -24,6 +24,8 @@ pub struct Address { pub gssapi_keytab: Option, /// GSSAPI principal for backend authentication. pub gssapi_principal: Option, + /// GSSAPI target service principal (what we authenticate to). + pub gssapi_target_principal: Option, } impl Address { @@ -52,6 +54,27 @@ impl Address { (None, None) }; + // Clean precedence resolution - no parsing needed + let username = if let Some(user) = database.user.clone() { + user + } else if let Some(user) = user.server_user.clone() { + user + } else { + user.name.clone() + }; + + // Target principal precedence: user > database > global default + let gssapi_target_principal = user + .gssapi_target_principal + .clone() + .or_else(|| database.gssapi_target_principal.clone()) + .or_else(|| { + cfg.config + .gssapi + .as_ref() + .and_then(|g| g.default_backend_target_principal.clone()) + }); + Address { host: database.host.clone(), port: database.port, @@ -60,13 +83,7 @@ impl Address { } else { database.name.clone() }, - user: if let Some(user) = database.user.clone() { - user - } else if let Some(user) = user.server_user.clone() { - user - } else { - user.name.clone() - }, + user: username, password: if let Some(password) = database.password.clone() { password } else if let Some(password) = user.server_password.clone() { @@ -76,9 +93,15 @@ impl Address { }, gssapi_keytab, gssapi_principal, + gssapi_target_principal, } } + /// Check if this address has GSSAPI configuration. + pub fn has_gssapi(&self) -> bool { + self.gssapi_keytab.is_some() && self.gssapi_principal.is_some() + } + pub async fn addr(&self) -> Result { let dns_cache_override_enabled = config().config.general.dns_ttl().is_some(); @@ -105,6 +128,7 @@ impl Address { database_name: "pgdog".into(), gssapi_keytab: None, gssapi_principal: None, + gssapi_target_principal: None, } } } @@ -133,6 +157,7 @@ impl TryFrom for Address { database_name, gssapi_keytab: None, gssapi_principal: None, + gssapi_target_principal: None, }) } } @@ -190,6 +215,7 @@ mod test { assert_eq!(addr.password, "password"); assert_eq!(addr.gssapi_keytab, None); assert_eq!(addr.gssapi_principal, None); + assert_eq!(addr.gssapi_target_principal, None); } #[test] @@ -201,6 +227,7 @@ mod test { server_keytab: Some(PathBuf::from("/etc/pgdog/pgdog.keytab")), default_backend_keytab: Some(PathBuf::from("/etc/pgdog/backend.keytab")), default_backend_principal: Some("pgdog@REALM".to_string()), + default_backend_target_principal: Some("postgres/default@REALM".to_string()), ..Default::default() }); @@ -211,6 +238,7 @@ mod test { port: 5432, gssapi_keytab: Some("/etc/pgdog/shard1.keytab".into()), gssapi_principal: Some("pgdog-shard1@REALM".into()), + gssapi_target_principal: Some("postgres/shard1@REALM".into()), ..Default::default() }; @@ -235,6 +263,10 @@ mod test { let addr1 = Address::new(&database1, &user); assert_eq!(addr1.gssapi_keytab, Some("/etc/pgdog/shard1.keytab".into())); assert_eq!(addr1.gssapi_principal, Some("pgdog-shard1@REALM".into())); + assert_eq!( + addr1.gssapi_target_principal, + Some("postgres/shard1@REALM".into()) + ); // Test database using default GSSAPI settings let addr2 = Address::new(&database2, &user); @@ -243,6 +275,10 @@ mod test { Some("/etc/pgdog/backend.keytab".into()) ); assert_eq!(addr2.gssapi_principal, Some("pgdog@REALM".into())); + assert_eq!( + addr2.gssapi_target_principal, + Some("postgres/default@REALM".into()) + ); } #[test] @@ -272,9 +308,10 @@ mod test { set(config).unwrap(); - // When GSSAPI is disabled, keytab/principal should be None + // When GSSAPI is disabled, keytab/principal/target_principal should be None let addr = Address::new(&database, &user); assert_eq!(addr.gssapi_keytab, None); assert_eq!(addr.gssapi_principal, None); + assert_eq!(addr.gssapi_target_principal, None); } } diff --git a/pgdog/src/backend/server.rs b/pgdog/src/backend/server.rs index d9036cb12..a6259fba8 100644 --- a/pgdog/src/backend/server.rs +++ b/pgdog/src/backend/server.rs @@ -16,7 +16,11 @@ use super::{ Stats, }; use crate::{ - auth::{md5, scram::Client}, + auth::{ + gssapi::{GssapiContext, TicketManager}, + md5, + scram::Client, + }, frontend::ClientRequest, net::{ messages::{ @@ -154,6 +158,57 @@ impl Server { .await?; stream.flush().await?; + // Check if GSSAPI is configured for this server + let mut gssapi_context = if addr.gssapi_keytab.is_some() && addr.gssapi_principal.is_some() + { + let keytab = addr.gssapi_keytab.as_ref().unwrap(); + let principal = addr.gssapi_principal.as_ref().unwrap(); + + // Use configured target principal if available, otherwise fallback to default format + let target = if let Some(ref target_principal) = addr.gssapi_target_principal { + target_principal.clone() + } else { + // Fallback: Use localhost explicitly since that's what our PostgreSQL service principal uses + let hostname = if addr.host == "127.0.0.1" { + "localhost" + } else { + &addr.host + }; + format!("postgres/{}", hostname) + }; + + // Use TicketManager to set up a credential cache for this server + // This ensures we use the correct principal for each backend connection + let cache_key = format!("{}:{}", addr.host, addr.port); + match TicketManager::global().get_ticket(&cache_key, keytab, principal) { + Ok(()) => { + debug!( + "Acquired ticket for {} using principal {}", + cache_key, principal + ); + + // Now create the GSSAPI context which will use the credential cache + // that TicketManager set up with KRB5CCNAME + match GssapiContext::new_initiator(keytab, principal, &target) { + Ok(ctx) => { + debug!("Initialized GSSAPI context for {} -> {}", principal, target); + Some(ctx) + } + Err(e) => { + warn!("Failed to initialize GSSAPI context: {}", e); + None + } + } + } + Err(e) => { + warn!("Failed to acquire ticket for {}: {}", cache_key, e); + None + } + } + } else { + None + }; + // Perform authentication. let mut scram = Client::new(&addr.user, &addr.password); loop { @@ -191,15 +246,78 @@ impl Server { let client = md5::Client::new_salt(&addr.user, &addr.password, &salt)?; stream.send_flush(&client.response()).await?; } - Authentication::Gssapi | Authentication::Sspi => { - // TODO: Implement GSSAPI authentication - // This will be implemented in Phase 3 - panic!("GSSAPI authentication not implemented"); + Authentication::Gssapi => { + if let Some(ref mut ctx) = gssapi_context { + // Send initial GSSAPI token + match ctx.initiate() { + Ok(initial_token) => { + let response = Password::gssapi_response(initial_token); + stream.send_flush(&response).await?; + } + Err(e) => { + error!("Failed to initiate GSSAPI: {}", e); + return Err(Error::ConnectionError(Box::new( + ErrorResponse::from_err(&e), + ))); + } + } + } else { + // No GSSAPI configured, server requires it + error!( + "Server requires GSSAPI but no keytab configured for {}", + addr.host + ); + return Err(Error::ConnectionError(Box::new( + ErrorResponse::auth( + &addr.user, + &format!("Server {} requires GSSAPI authentication but no keytab configured", addr.host), + ), + ))); + } + } + Authentication::Sspi => { + // SSPI is Windows-specific GSSAPI variant + error!("SSPI authentication not supported"); + return Err(Error::ConnectionError(Box::new(ErrorResponse::auth( + &addr.user, + "SSPI authentication is not supported", + )))); } - Authentication::GssapiContinue(_) => { - // TODO: Implement GSSAPI continuation - // This will be implemented in Phase 3 - panic!("GSSAPI continuation not implemented"); + Authentication::GssapiContinue(server_token) => { + if let Some(ref mut ctx) = gssapi_context { + // Process server token and send response if needed + match ctx.process_response(&server_token) { + Ok(Some(response_token)) => { + let response = Password::gssapi_response(response_token); + stream.send_flush(&response).await?; + } + Ok(None) => { + // Authentication should be complete + if !ctx.is_complete() { + error!("GSSAPI negotiation incomplete but no token to send"); + return Err(Error::ConnectionError(Box::new( + ErrorResponse::auth( + &addr.user, + "GSSAPI negotiation incomplete", + ), + ))); + } + // Continue to wait for Authentication::Ok + } + Err(e) => { + error!("GSSAPI negotiation failed: {}", e); + return Err(Error::ConnectionError(Box::new( + ErrorResponse::from_err(&e), + ))); + } + } + } else { + error!("Received GSSAPI continue without context"); + return Err(Error::ConnectionError(Box::new(ErrorResponse::auth( + &addr.user, + "Received GSSAPI continue without context", + )))); + } } } } diff --git a/pgdog/src/config/database.rs b/pgdog/src/config/database.rs index da63c9a32..25805b999 100644 --- a/pgdog/src/config/database.rs +++ b/pgdog/src/config/database.rs @@ -107,6 +107,8 @@ pub struct Database { pub gssapi_keytab: Option, /// GSSAPI principal for this specific backend server. pub gssapi_principal: Option, + /// GSSAPI target service principal for this specific backend server. + pub gssapi_target_principal: Option, } impl Database { diff --git a/pgdog/src/config/gssapi.rs b/pgdog/src/config/gssapi.rs index 5855b3d3b..27fdc07c5 100644 --- a/pgdog/src/config/gssapi.rs +++ b/pgdog/src/config/gssapi.rs @@ -27,6 +27,10 @@ pub struct GssapiConfig { /// Can be overridden per-database. pub default_backend_principal: Option, + /// Default target service principal for backend connections. + /// Can be overridden per-database or per-user. + pub default_backend_target_principal: Option, + /// Strip realm from client principals. /// If true: user@REALM -> user #[serde(default = "GssapiConfig::default_strip_realm")] @@ -55,6 +59,7 @@ impl Default for GssapiConfig { server_principal: None, default_backend_keytab: None, default_backend_principal: None, + default_backend_target_principal: None, strip_realm: Self::default_strip_realm(), ticket_refresh_interval: Self::default_ticket_refresh_interval(), fallback_enabled: false, diff --git a/pgdog/src/config/users.rs b/pgdog/src/config/users.rs index 44912332b..e7fceb269 100644 --- a/pgdog/src/config/users.rs +++ b/pgdog/src/config/users.rs @@ -98,6 +98,8 @@ pub struct User { pub two_phase_commit: Option, /// Automatic transactions. pub two_phase_commit_auto: Option, + /// GSSAPI target service principal for this specific user. + pub gssapi_target_principal: Option, } impl User { diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index 0cb2210e9..d68aea157 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -157,7 +157,125 @@ impl Client { }; let auth_type = &config.config.general.auth_type; - let auth_ok = match (auth_type, stream.is_tls()) { + + // Check if GSSAPI is configured and available + let gssapi_available = config + .config + .gssapi + .as_ref() + .map(|g| g.is_configured()) + .unwrap_or(false); + + // Try GSSAPI first if configured (regardless of auth_type setting) + // This allows clients that support GSSAPI to use it when available + let auth_ok = if gssapi_available && !admin { + match &config.config.gssapi { + Some(gssapi_config) if gssapi_config.is_configured() => { + // Initialize the GSSAPI server context + match crate::auth::gssapi::GssapiServer::new_acceptor( + gssapi_config.server_keytab.as_ref().unwrap(), + gssapi_config.server_principal.as_deref(), + ) { + Ok(server) => { + let server = std::sync::Arc::new(tokio::sync::Mutex::new(server)); + + // Send initial GSSAPI authentication request + stream.send_flush(&Authentication::Gssapi).await?; + + // GSSAPI negotiation loop + let mut auth_ok = false; + loop { + // Read client token + let message = stream.read().await?; + let client_token = match Password::from_bytes(message.to_bytes()?)? { + Password::GssapiResponse { data } => data, + _ => { + error!("Expected GSSAPI token from client"); + break; + } + }; + + // Process token + let response = match crate::auth::gssapi::handle_gssapi_auth( + server.clone(), + client_token, + ) + .await + { + Ok(response) => response, + Err(e) => { + error!("GSSAPI authentication failed: {}", e); + break; + } + }; + + if response.is_complete { + // Authentication successful + if let Some(principal) = response.principal { + // Apply realm stripping if configured + let extracted_user = if gssapi_config.strip_realm { + principal + .split('@') + .next() + .unwrap_or(&principal) + .to_string() + } else { + principal.clone() + }; + + // Verify the extracted user matches the requested user + if extracted_user == user { + auth_ok = true; + debug!( + "GSSAPI authentication successful for principal: {}", + principal + ); + } else { + error!( + "GSSAPI principal {} does not match requested user {}", + extracted_user, user + ); + } + } + break; + } else if let Some(token) = response.token { + // Send continuation token + stream + .send_flush(&Authentication::GssapiContinue(token)) + .await?; + } else { + error!("GSSAPI negotiation error: no token in incomplete response"); + break; + } + } + + // If GSSAPI failed but fallback is enabled, try regular auth + if !auth_ok && gssapi_config.fallback_enabled { + // Fall through to regular authentication + None + } else { + Some(auth_ok) + } + } + Err(e) => { + error!("Failed to initialize GSSAPI server: {}", e); + if gssapi_config.fallback_enabled { + // Fall back to regular auth + None + } else { + Some(false) + } + } + } + } + _ => None, + } + } else { + None + }; + + // If GSSAPI wasn't tried or failed with fallback, use regular auth + let auth_ok = auth_ok.unwrap_or_else(|| match (auth_type, stream.is_tls()) { // TODO: SCRAM doesn't work with TLS currently because of // lack of support for channel binding in our scram library. // Defaulting to MD5. @@ -181,109 +299,6 @@ impl Client { } (AuthType::Trust, _) => true, - - (AuthType::Gssapi, _) => { - if let Some(gssapi_config) = config.config.gssapi.as_ref() { - if !gssapi_config.is_configured() { - error!("GSSAPI authentication requested but not properly configured"); - false - } else { - // Initialize the GSSAPI server context - match crate::auth::gssapi::GssapiServer::new_acceptor( - gssapi_config.server_keytab.as_ref().unwrap(), - gssapi_config.server_principal.as_deref(), - ) { - Ok(server) => { - let server = std::sync::Arc::new(tokio::sync::Mutex::new(server)); - - // Send initial GSSAPI authentication request - stream.send_flush(&Authentication::Gssapi).await?; - - // GSSAPI negotiation loop - let mut auth_ok = false; - loop { - // Read client token - let message = stream.read().await?; - let client_token = - match Password::from_bytes(message.to_bytes()?)? { - Password::GssapiResponse { data } => data, - _ => { - error!("Expected GSSAPI token from client"); - break; - } - }; - - // Process token - let response = match crate::auth::gssapi::handle_gssapi_auth( - server.clone(), - client_token, - ) - .await - { - Ok(response) => response, - Err(e) => { - error!("GSSAPI authentication failed: {}", e); - break; - } - }; - - if response.is_complete { - // Authentication successful - if let Some(principal) = response.principal { - // Apply realm stripping if configured - let extracted_user = if gssapi_config.strip_realm { - principal - .split('@') - .next() - .unwrap_or(&principal) - .to_string() - } else { - principal.clone() - }; - - // Verify the extracted user matches the requested user - if extracted_user == user { - auth_ok = true; - debug!( - "GSSAPI authentication successful for principal: {}", - principal - ); - } else { - error!( - "GSSAPI principal {} does not match requested user {}", - extracted_user, user - ); - } - } - break; - } else if let Some(token) = response.token { - // Send continuation token - stream - .send_flush(&Authentication::GssapiContinue(token)) - .await?; - } else { - error!("GSSAPI negotiation error: no token in incomplete response"); - break; - } - } - - auth_ok - } - Err(e) => { - error!("Failed to initialize GSSAPI server: {}", e); - if gssapi_config.fallback_enabled { - // Fall back to next auth method - true // Will be handled by fallback logic - } else { - false - } - } - } - } - } else { - false - } - } }; if !auth_ok { diff --git a/pgdog/src/frontend/listener.rs b/pgdog/src/frontend/listener.rs index f4ceabf7c..7346dfbb5 100644 --- a/pgdog/src/frontend/listener.rs +++ b/pgdog/src/frontend/listener.rs @@ -8,7 +8,10 @@ use crate::backend::databases::{databases, reload, shutdown}; use crate::config::config; use crate::frontend::client::query_engine::two_pc::Manager; use crate::net::messages::BackendKeyData; -use crate::net::messages::{hello::SslReply, Startup}; +use crate::net::messages::{ + hello::{GssapiReply, SslReply}, + Startup, +}; use crate::net::{self, tls::acceptor}; use crate::net::{tweak, Stream}; use crate::sighup::Sighup; @@ -169,6 +172,12 @@ impl Listener { } } + Startup::Gssapi => { + // For now, we don't support GSSAPI encryption, only authentication + // Send 'N' to indicate we don't support GSSAPI encryption + stream.send_flush(&GssapiReply::No).await?; + } + Startup::Startup { params } => { Client::spawn(stream, params, addr, comms).await?; break; diff --git a/pgdog/src/net/messages/hello.rs b/pgdog/src/net/messages/hello.rs index 4cde01675..7bc1de28f 100644 --- a/pgdog/src/net/messages/hello.rs +++ b/pgdog/src/net/messages/hello.rs @@ -21,6 +21,8 @@ use super::{super::Parameter, FromBytes, Payload, Protocol, ToBytes}; pub enum Startup { /// SSLRequest (F) Ssl, + /// GSSENCRequest (F) + Gssapi, /// StartupMessage (F) Startup { params: Parameters }, /// CancelRequet (F) @@ -38,6 +40,8 @@ impl Startup { match code { // SSLRequest (F) 80877103 => Ok(Startup::Ssl), + // GSSENCRequest (F) + 80877104 => Ok(Startup::Gssapi), // StartupMessage (F) 196608 => { let mut params = Parameters::default(); @@ -91,7 +95,7 @@ impl Startup { /// If no such parameter exists, `None` is returned. pub fn parameter(&self, name: &str) -> Option<&str> { match self { - Startup::Ssl | Startup::Cancel { .. } => None, + Startup::Ssl | Startup::Gssapi | Startup::Cancel { .. } => None, Startup::Startup { params } => params.get(name).and_then(|s| s.as_str()), } } @@ -131,6 +135,15 @@ impl super::ToBytes for Startup { Ok(buf.freeze()) } + Startup::Gssapi => { + let mut buf = BytesMut::new(); + + buf.put_i32(8); + buf.put_i32(80877104); + + Ok(buf.freeze()) + } + Startup::Cancel { pid, secret } => { let mut payload = Payload::new(); @@ -173,6 +186,13 @@ pub enum SslReply { No, } +/// Reply to a GSSENCRequest (F) message. +#[derive(Debug, PartialEq)] +pub enum GssapiReply { + Yes, + No, +} + impl ToBytes for SslReply { fn to_bytes(&self) -> Result { Ok(match self { @@ -215,6 +235,48 @@ impl FromBytes for SslReply { } } +impl ToBytes for GssapiReply { + fn to_bytes(&self) -> Result { + Ok(match self { + GssapiReply::Yes => Bytes::from("G"), + GssapiReply::No => Bytes::from("N"), + }) + } +} + +impl std::fmt::Display for GssapiReply { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::Yes => "G", + Self::No => "N", + } + ) + } +} + +impl Protocol for GssapiReply { + fn code(&self) -> char { + match self { + GssapiReply::Yes => 'G', + GssapiReply::No => 'N', + } + } +} + +impl FromBytes for GssapiReply { + fn from_bytes(mut bytes: Bytes) -> Result { + let answer = bytes.get_u8() as char; + match answer { + 'G' => Ok(GssapiReply::Yes), + 'N' => Ok(GssapiReply::No), + answer => Err(Error::UnexpectedSslReply(answer)), + } + } +} + #[cfg(test)] mod test { use crate::net::messages::ToBytes; diff --git a/pgdog/tests/backend_gssapi_test.rs b/pgdog/tests/backend_gssapi_test.rs new file mode 100644 index 000000000..f448a8270 --- /dev/null +++ b/pgdog/tests/backend_gssapi_test.rs @@ -0,0 +1,175 @@ +//! Backend GSSAPI authentication tests +//! +//! These tests verify that PGDog can authenticate to PostgreSQL servers using GSSAPI/Kerberos. + +use pgdog::auth::gssapi::{GssapiContext, TicketManager}; +use pgdog::backend::pool::Address; + +#[test] +fn test_address_has_gssapi() { + // Test that Address correctly detects GSSAPI configuration + let mut addr = Address { + host: "test.example.com".to_string(), + port: 5432, + database_name: "testdb".to_string(), + user: "testuser".to_string(), + password: "testpass".to_string(), + gssapi_keytab: None, + gssapi_principal: None, + gssapi_target_principal: None, + }; + + assert!(!addr.has_gssapi()); + + addr.gssapi_keytab = Some("/etc/test.keytab".to_string()); + assert!(!addr.has_gssapi()); // Still need principal + + addr.gssapi_principal = Some("test@REALM".to_string()); + assert!(addr.has_gssapi()); // Now both are set +} + +#[test] +fn test_gssapi_context_for_backend() { + // Test creating a GSSAPI context for backend connection + let keytab = "/etc/pgdog/backend.keytab"; + let principal = "pgdog@REALM"; + let target = "postgres/db.example.com"; + + let context = GssapiContext::new_initiator(keytab, principal, target); + + #[cfg(feature = "gssapi")] + { + // With real GSSAPI, this will fail without a keytab + assert!(context.is_err()); + } + + #[cfg(not(feature = "gssapi"))] + { + // Mock version should succeed in creation + assert!(context.is_ok()); + let mut ctx = context.unwrap(); + assert_eq!(ctx.target_principal(), target); + assert!(!ctx.is_complete()); + + // But operations will fail + let result = ctx.initiate(); + assert!(result.is_err()); + } +} + +#[test] +fn test_backend_gssapi_target_principal() { + // Test that we construct the correct target principal + let host = "db.example.com"; + let target = format!("postgres/{}", host); + assert_eq!(target, "postgres/db.example.com"); + + // Test with IP address + let host = "192.168.1.1"; + let target = format!("postgres/{}", host); + assert_eq!(target, "postgres/192.168.1.1"); +} + +#[test] +fn test_ticket_manager_for_backend() { + // Test that TicketManager can handle backend server tickets + let manager = TicketManager::global(); + + // These will fail without real keytabs, but test the API + let ticket1 = manager.get_ticket( + "server1:5432", + "/etc/pgdog/server1.keytab", + "pgdog-server1@REALM", + ); + + let ticket2 = manager.get_ticket( + "server2:5432", + "/etc/pgdog/server2.keytab", + "pgdog-server2@REALM", + ); + + // Both should fail without real keytabs + assert!(ticket1.is_err()); + assert!(ticket2.is_err()); + + // But the caches should be separate + let cache1 = manager.get_cache("server1:5432"); + let cache2 = manager.get_cache("server2:5432"); + + // Caches won't exist because tickets failed to acquire + assert!(cache1.is_none()); + assert!(cache2.is_none()); +} + +/// Mock test for GSSAPI negotiation flow +#[tokio::test] +async fn test_backend_gssapi_negotiation_mock() { + // This test demonstrates the expected flow for backend GSSAPI + // In a real scenario, this would connect to a PostgreSQL server with GSSAPI enabled + + let keytab = "/etc/pgdog/backend.keytab"; + let principal = "pgdog@REALM"; + let target = "postgres/localhost"; + + let context = GssapiContext::new_initiator(keytab, principal, target); + + #[cfg(not(feature = "gssapi"))] + { + if let Ok(mut ctx) = context { + // Mock flow + assert!(!ctx.is_complete()); + + // Initial token would be sent to server + let initial = ctx.initiate(); + assert!(initial.is_err()); // Mock fails + + // In real flow, we'd receive server token and process it + // let server_token = receive_from_postgres(); + // let response = ctx.process_response(&server_token); + // send_to_postgres(response); + // ... repeat until ctx.is_complete() + } + } +} + +/// Test error handling when server requires GSSAPI but we don't have it configured +#[test] +fn test_backend_gssapi_not_configured() { + let addr = Address { + host: "test.example.com".to_string(), + port: 5432, + database_name: "testdb".to_string(), + user: "testuser".to_string(), + password: "testpass".to_string(), + gssapi_keytab: None, // Not configured + gssapi_principal: None, + gssapi_target_principal: None, + }; + + // When server requests GSSAPI and we don't have it, we should get an error + assert!(!addr.has_gssapi()); + // In the real connect() function, this would return an appropriate error +} + +/// Test that GSSAPI configuration is properly read from config +#[test] +fn test_backend_gssapi_from_config() { + use pgdog::config::GssapiConfig; + use std::path::PathBuf; + + let gssapi = GssapiConfig { + enabled: true, + server_keytab: Some(PathBuf::from("/etc/pgdog/pgdog.keytab")), + server_principal: Some("pgdog@REALM".to_string()), + default_backend_keytab: Some(PathBuf::from("/etc/pgdog/backend.keytab")), + default_backend_principal: Some("pgdog-backend@REALM".to_string()), + default_backend_target_principal: Some("postgres/test@REALM".to_string()), + strip_realm: true, + ticket_refresh_interval: 14400, + fallback_enabled: false, + require_encryption: false, + }; + + assert!(gssapi.is_configured()); + assert!(gssapi.has_backend_config()); +} diff --git a/pgdog/tests/gssapi_integration_test.rs b/pgdog/tests/gssapi_integration_test.rs index e71021449..b7beca89e 100644 --- a/pgdog/tests/gssapi_integration_test.rs +++ b/pgdog/tests/gssapi_integration_test.rs @@ -155,8 +155,8 @@ fn test_ticket_manager_cleanup() { let manager = TicketManager::new(); // Add some tickets - manager.get_ticket("server1:5432", "/etc/keytab1", "principal1@REALM"); - manager.get_ticket("server2:5432", "/etc/keytab2", "principal2@REALM"); + let _ = manager.get_ticket("server1:5432", "/etc/keytab1", "principal1@REALM"); + let _ = manager.get_ticket("server2:5432", "/etc/keytab2", "principal2@REALM"); assert_eq!(manager.cache_count(), 2); From ad4426d932e53cc2377fc91c825bbb09e7c66fab Mon Sep 17 00:00:00 2001 From: Justin George Date: Wed, 17 Sep 2025 22:16:18 -0700 Subject: [PATCH 06/19] working backend and frontend GSSAPI connections --- pgdog/src/auth/gssapi/mod.rs | 69 ++++++++-- pgdog/src/auth/gssapi/server.rs | 57 ++++++++- pgdog/src/backend/pool/connection/mod.rs | 12 ++ pgdog/src/frontend/client/mod.rs | 154 ++++++++++++++++++----- pgdog/src/net/messages/auth/password.rs | 13 ++ 5 files changed, 262 insertions(+), 43 deletions(-) diff --git a/pgdog/src/auth/gssapi/mod.rs b/pgdog/src/auth/gssapi/mod.rs index c6c7d5427..82055dc8c 100644 --- a/pgdog/src/auth/gssapi/mod.rs +++ b/pgdog/src/auth/gssapi/mod.rs @@ -26,29 +26,78 @@ pub async fn handle_gssapi_auth( server: Arc>, client_token: Vec, ) -> Result { + tracing::debug!( + "handle_gssapi_auth called with token of {} bytes", + client_token.len() + ); let mut server = server.lock().await; + tracing::debug!("Calling server.accept()"); match server.accept(&client_token)? { Some(response_token) => { - // More negotiation needed - Ok(GssapiResponse { - is_complete: false, - token: Some(response_token), - principal: None, - }) + // Check if authentication is complete despite having a token + if server.is_complete() { + let principal = server + .client_principal() + .ok_or_else(|| { + tracing::error!("Context complete but no principal found"); + GssapiError::ContextError("No client principal found".to_string()) + })? + .to_string(); + + tracing::info!( + "Authentication complete (with final token), principal: {}", + principal + ); + let response = GssapiResponse { + is_complete: true, + token: Some(response_token), // Send final token to client + principal: Some(principal.clone()), + }; + tracing::debug!( + "Returning GssapiResponse: is_complete=true, has_token=true, principal={}", + principal + ); + Ok(response) + } else { + // More negotiation needed + tracing::info!( + "server.accept returned token of {} bytes - negotiation continues", + response_token.len() + ); + let response = GssapiResponse { + is_complete: false, + token: Some(response_token), + principal: None, + }; + tracing::debug!( + "Returning GssapiResponse: is_complete=false, has_token=true, principal=None" + ); + Ok(response) + } } None => { // Authentication complete + tracing::info!("server.accept returned None - authentication complete"); let principal = server .client_principal() - .ok_or_else(|| GssapiError::ContextError("No client principal found".to_string()))? + .ok_or_else(|| { + tracing::error!("No client principal found in completed context"); + GssapiError::ContextError("No client principal found".to_string()) + })? .to_string(); - Ok(GssapiResponse { + tracing::info!("Successfully extracted principal: {}", principal); + let response = GssapiResponse { is_complete: true, token: None, - principal: Some(principal), - }) + principal: Some(principal.clone()), + }; + tracing::debug!( + "Returning GssapiResponse: is_complete=true, has_token=false, principal={}", + principal + ); + Ok(response) } } } diff --git a/pgdog/src/auth/gssapi/server.rs b/pgdog/src/auth/gssapi/server.rs index c00fba73f..3d96d69a2 100644 --- a/pgdog/src/auth/gssapi/server.rs +++ b/pgdog/src/auth/gssapi/server.rs @@ -89,7 +89,17 @@ impl GssapiServer { /// Process a token from the client. pub fn accept(&mut self, client_token: &[u8]) -> Result>> { + tracing::debug!( + "GssapiServer::accept called with token of {} bytes", + client_token.len() + ); + tracing::trace!( + "Token first 20 bytes: {:?}", + &client_token[..client_token.len().min(20)] + ); + if self.is_complete { + tracing::warn!("GssapiServer::accept called but context already complete"); return Err(GssapiError::ContextError( "Context already complete".to_string(), )); @@ -97,27 +107,67 @@ impl GssapiServer { // Create or reuse the server context let mut ctx = match self.inner.take() { - Some(ctx) => ctx, - None => ServerCtx::new(Some(self.credential.as_ref().clone())), + Some(ctx) => { + tracing::debug!("Reusing existing server context"); + ctx + } + None => { + tracing::debug!("Creating new server context"); + ServerCtx::new(Some(self.credential.as_ref().clone())) + } }; // Process the client token + tracing::debug!("Calling ctx.step with client token"); match ctx.step(client_token) { Ok(Some(response)) => { // More negotiation needed + tracing::debug!( + "ctx.step returned response token of {} bytes - negotiation continues", + response.len() + ); + tracing::trace!( + "Response token first 20 bytes: {:?}", + &response[..response.len().min(20)] + ); + + // Check if context is actually established despite returning a token + if ctx.is_complete() { + tracing::warn!("Context is complete but still returned a token - this might confuse the client"); + // Mark as complete and extract the principal + self.is_complete = true; + match ctx.source_name() { + Ok(name) => { + let principal = name.to_string(); + tracing::info!( + "Extracted client principal (with token): {}", + principal + ); + self.client_principal = Some(principal); + } + Err(e) => { + tracing::error!("Failed to get client principal: {}", e); + } + } + } + self.inner = Some(ctx); Ok(Some(response.to_vec())) } Ok(None) => { // Context established successfully + tracing::info!("ctx.step returned None - GSSAPI context established successfully"); self.is_complete = true; // Extract the client principal match ctx.source_name() { Ok(name) => { - self.client_principal = Some(name.to_string()); + let principal = name.to_string(); + tracing::info!("Extracted client principal: {}", principal); + self.client_principal = Some(principal); } Err(e) => { + tracing::error!("Failed to get client principal: {}", e); return Err(GssapiError::ContextError(format!( "Failed to get client principal: {}", e @@ -126,6 +176,7 @@ impl GssapiServer { } self.inner = Some(ctx); + tracing::debug!("GssapiServer::accept returning None (success)"); Ok(None) } Err(e) => Err(GssapiError::ContextError(format!( diff --git a/pgdog/src/backend/pool/connection/mod.rs b/pgdog/src/backend/pool/connection/mod.rs index 20a36d4c8..21da4761c 100644 --- a/pgdog/src/backend/pool/connection/mod.rs +++ b/pgdog/src/backend/pool/connection/mod.rs @@ -320,6 +320,18 @@ impl Connection { } } + // Check GSSAPI auth - if enabled and user doesn't exist, create a temporary user entry + let gssapi_enabled = config().config.gssapi.as_ref().map_or(false, |g| g.enabled); + if gssapi_enabled && !databases().exists(user) { + debug!( + "GSSAPI enabled and user {} not in databases, creating temporary entry", + self.user + ); + // Create a temporary user with no password for GSSAPI authentication + let new_user = User::new(&self.user, "", &self.database); + databases::add(new_user); + } + let databases = databases(); let cluster = databases.cluster(user)?; diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index d68aea157..d9692edcd 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -142,6 +142,8 @@ impl Client { }; // Get server parameters and send them to the client. + debug!("Attempting Connection::new for user={}, database={}, admin={}, has_passthrough_password={}", + user, database, admin, passthrough_password.is_some()); let mut conn = match Connection::new(user, database, admin, &passthrough_password) { Ok(conn) => conn, Err(_) => { @@ -166,11 +168,18 @@ impl Client { .map(|g| g.is_configured()) .unwrap_or(false); + debug!( + "GSSAPI authentication check: available={}, admin={}, user={}", + gssapi_available, admin, user + ); + // Try GSSAPI first if configured (regardless of auth_type setting) // This allows clients that support GSSAPI to use it when available let auth_ok = if gssapi_available && !admin { + debug!("Attempting GSSAPI authentication for user {}", user); match &config.config.gssapi { Some(gssapi_config) if gssapi_config.is_configured() => { + debug!("GSSAPI is configured, proceeding with authentication"); // Initialize the GSSAPI server context match crate::auth::gssapi::GssapiServer::new_acceptor( gssapi_config.server_keytab.as_ref().unwrap(), @@ -180,14 +189,39 @@ impl Client { let server = std::sync::Arc::new(tokio::sync::Mutex::new(server)); // Send initial GSSAPI authentication request + debug!("Sending AuthenticationGssapi to client"); stream.send_flush(&Authentication::Gssapi).await?; // GSSAPI negotiation loop let mut auth_ok = false; loop { // Read client token - let message = stream.read().await?; - let client_token = match Password::from_bytes(message.to_bytes()?)? { + debug!("Waiting for client GSSAPI token"); + trace!( + "About to call stream.read() to get GSSAPI token from client" + ); + let message = match tokio::time::timeout( + std::time::Duration::from_secs(5), + stream.read(), + ) + .await + { + Ok(Ok(msg)) => { + trace!("Successfully read message from client"); + msg + } + Ok(Err(e)) => { + error!("Error reading GSSAPI token from client: {}", e); + break; + } + Err(_) => { + error!("Timeout reading GSSAPI token from client after 5 seconds"); + break; + } + }; + debug!("Received message from client: {:?}", message); + let client_token = match Password::from_bytes(message.to_bytes()?)? + { Password::GssapiResponse { data } => data, _ => { error!("Expected GSSAPI token from client"); @@ -202,7 +236,13 @@ impl Client { ) .await { - Ok(response) => response, + Ok(response) => { + debug!("GSSAPI response: is_complete={}, has_principal={}, has_token={}", + response.is_complete, + response.principal.is_some(), + response.token.is_some()); + response + } Err(e) => { error!("GSSAPI authentication failed: {}", e); break; @@ -210,25 +250,45 @@ impl Client { }; if response.is_complete { + debug!("GSSAPI response indicates authentication complete"); + + // Send final token if present + if let Some(token) = response.token { + debug!( + "Sending final GSSAPI token of {} bytes", + token.len() + ); + stream + .send_flush(&Authentication::GssapiContinue(token)) + .await?; + } + // Authentication successful if let Some(principal) = response.principal { + debug!("Principal found: {}", principal); // Apply realm stripping if configured let extracted_user = if gssapi_config.strip_realm { - principal + let stripped = principal .split('@') .next() .unwrap_or(&principal) - .to_string() + .to_string(); + debug!( + "Stripped realm from {} to {}", + principal, stripped + ); + stripped } else { + debug!("Not stripping realm from {}", principal); principal.clone() }; // Verify the extracted user matches the requested user if extracted_user == user { auth_ok = true; - debug!( - "GSSAPI authentication successful for principal: {}", - principal + info!( + "GSSAPI authentication successful for principal: {} (matched user: {})", + principal, user ); } else { error!( @@ -236,15 +296,26 @@ impl Client { extracted_user, user ); } + } else { + error!("GSSAPI response marked complete but no principal provided"); } + debug!("Breaking from GSSAPI negotiation loop (complete)"); break; } else if let Some(token) = response.token { // Send continuation token + debug!( + "Sending GSSAPI continuation token of {} bytes", + token.len() + ); stream .send_flush(&Authentication::GssapiContinue(token)) .await?; + debug!("GSSAPI continuation token sent, waiting for next client token"); } else { - error!("GSSAPI negotiation error: no token in incomplete response"); + error!( + "GSSAPI negotiation error: no token in incomplete response" + ); + debug!("Breaking from GSSAPI negotiation loop (error)"); break; } } @@ -268,37 +339,60 @@ impl Client { } } } - _ => None, + Some(gssapi_config) => { + debug!( + "GSSAPI config incomplete: enabled={}, server_keytab={:?}", + gssapi_config.enabled, gssapi_config.server_keytab + ); + None + } + None => { + debug!("No GSSAPI configuration found"); + None + } } } else { None }; // If GSSAPI wasn't tried or failed with fallback, use regular auth - let auth_ok = auth_ok.unwrap_or_else(|| match (auth_type, stream.is_tls()) { - // TODO: SCRAM doesn't work with TLS currently because of - // lack of support for channel binding in our scram library. - // Defaulting to MD5. - (AuthType::Scram, true) | (AuthType::Md5, _) => { - let md5 = md5::Client::new(user, password); - stream.send_flush(&md5.challenge()).await?; - let password = Password::from_bytes(stream.read().await?.to_bytes()?)?; - if let Password::PasswordMessage { response } = password { - md5.check(&response) - } else { - false + debug!("GSSAPI auth result: {:?}", auth_ok); + let auth_ok = if let Some(gssapi_result) = auth_ok { + debug!("Using GSSAPI auth result: {}", gssapi_result); + gssapi_result + } else { + debug!("GSSAPI not used or failed with fallback, trying regular auth"); + match (auth_type, stream.is_tls()) { + // TODO: SCRAM doesn't work with TLS currently because of + // lack of support for channel binding in our scram library. + // Defaulting to MD5. + (AuthType::Scram, true) | (AuthType::Md5, _) => { + let md5 = md5::Client::new(user, password); + stream.send_flush(&md5.challenge()).await?; + let password = Password::from_bytes(stream.read().await?.to_bytes()?)?; + if let Password::PasswordMessage { response } = password { + md5.check(&response) + } else { + false + } } - } - (AuthType::Scram, false) => { - stream.send_flush(&Authentication::scram()).await?; + (AuthType::Scram, false) => { + stream.send_flush(&Authentication::scram()).await?; - let scram = Server::new(password); - let res = scram.handle(&mut stream).await; - matches!(res, Ok(true)) - } + let scram = Server::new(password); + let res = scram.handle(&mut stream).await; + matches!(res, Ok(true)) + } + + (AuthType::Trust, _) => true, - (AuthType::Trust, _) => true, + (AuthType::Gssapi, _) => { + // GSSAPI auth requested but not configured or already tried and failed + error!("GSSAPI authentication requested but not available"); + false + } + } }; if !auth_ok { diff --git a/pgdog/src/net/messages/auth/password.rs b/pgdog/src/net/messages/auth/password.rs index 28d28a3bf..1d7fb2192 100644 --- a/pgdog/src/net/messages/auth/password.rs +++ b/pgdog/src/net/messages/auth/password.rs @@ -49,6 +49,19 @@ impl FromBytes for Password { fn from_bytes(mut bytes: Bytes) -> Result { code!(bytes, 'p'); let _len = bytes.get_i32(); + + // Check if this looks like a GSSAPI token + // GSSAPI tokens typically start with ASN.1 tags like 0x60 (APPLICATION) + // or other ASN.1 constructed tags + if !bytes.is_empty() { + let first_byte = bytes[0]; + if first_byte == 0x60 || first_byte == 0xa0 || first_byte == 0x6f { + // This appears to be a GSSAPI token, read it as binary data + let data = bytes.to_vec(); + return Ok(Self::GssapiResponse { data }); + } + } + let content = c_string_buf(&mut bytes); if bytes.has_remaining() { From 89a4da5606120ae88696adb72150de844688fc68 Mon Sep 17 00:00:00 2001 From: Justin George Date: Wed, 17 Sep 2025 23:00:00 -0700 Subject: [PATCH 07/19] a couple test fixes --- pgdog/src/auth/gssapi/context.rs | 7 ++++++- pgdog/tests/backend_gssapi_test.rs | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pgdog/src/auth/gssapi/context.rs b/pgdog/src/auth/gssapi/context.rs index ef17f6630..8ec065e16 100644 --- a/pgdog/src/auth/gssapi/context.rs +++ b/pgdog/src/auth/gssapi/context.rs @@ -39,10 +39,15 @@ impl GssapiContext { principal: impl Into, target: impl Into, ) -> Result { - let _keytab = keytab.as_ref(); + let keytab = keytab.as_ref(); let principal = principal.into(); let target_principal = target.into(); + // Validate that the keytab file exists + if !keytab.exists() { + return Err(GssapiError::KeytabNotFound(keytab.to_path_buf())); + } + // TicketManager has already set up the credential cache with KRB5CCNAME // We just need to acquire credentials from that cache diff --git a/pgdog/tests/backend_gssapi_test.rs b/pgdog/tests/backend_gssapi_test.rs index f448a8270..94253c076 100644 --- a/pgdog/tests/backend_gssapi_test.rs +++ b/pgdog/tests/backend_gssapi_test.rs @@ -111,11 +111,11 @@ async fn test_backend_gssapi_negotiation_mock() { let principal = "pgdog@REALM"; let target = "postgres/localhost"; - let context = GssapiContext::new_initiator(keytab, principal, target); + let _context = GssapiContext::new_initiator(keytab, principal, target); #[cfg(not(feature = "gssapi"))] { - if let Ok(mut ctx) = context { + if let Ok(mut ctx) = _context { // Mock flow assert!(!ctx.is_complete()); From 0a5bc70b15dd358beb07d2ddf07c770847f7b6a0 Mon Sep 17 00:00:00 2001 From: Lev Kokotov Date: Thu, 18 Sep 2025 09:49:39 -0700 Subject: [PATCH 08/19] A few fixes (#480) * A few fixes * downgrade logs * save --- pgdog/src/auth/gssapi/context.rs | 8 ++-- pgdog/src/auth/gssapi/error.rs | 50 +++++++------------------ pgdog/src/auth/gssapi/mod.rs | 18 ++++----- pgdog/src/auth/gssapi/server.rs | 30 +++++++-------- pgdog/src/auth/gssapi/ticket_cache.rs | 7 ++-- pgdog/src/auth/gssapi/ticket_manager.rs | 21 ++++++----- pgdog/src/backend/server.rs | 27 +++++-------- pgdog/tests/backend_gssapi_test.rs | 30 ++++++++------- pgdog/tests/gssapi_integration_test.rs | 40 +++++++++++--------- 9 files changed, 105 insertions(+), 126 deletions(-) diff --git a/pgdog/src/auth/gssapi/context.rs b/pgdog/src/auth/gssapi/context.rs index 8ec065e16..39478194a 100644 --- a/pgdog/src/auth/gssapi/context.rs +++ b/pgdog/src/auth/gssapi/context.rs @@ -53,10 +53,10 @@ impl GssapiContext { // Create the desired mechanisms set let mut desired_mechs = OidSet::new() - .map_err(|e| GssapiError::LibGssapi(format!("Failed to create OidSet: {}", e)))?; + .map_err(|e| GssapiError::LibGssapi(format!("failed to create OidSet: {}", e)))?; desired_mechs .add(&GSS_MECH_KRB5) - .map_err(|e| GssapiError::LibGssapi(format!("Failed to add mechanism: {}", e)))?; + .map_err(|e| GssapiError::LibGssapi(format!("failed to add mechanism: {}", e)))?; // Acquire credentials from the cache that TicketManager populated // Pass None to use the default principal from the cache @@ -67,7 +67,7 @@ impl GssapiContext { Some(&desired_mechs), ) .map_err(|e| { - GssapiError::CredentialAcquisitionFailed(format!("Failed for {}: {}", principal, e)) + GssapiError::CredentialAcquisitionFailed(format!("failed for {}: {}", principal, e)) })?; // Parse target service principal (use KRB5_PRINCIPAL to avoid hostname canonicalization) @@ -123,7 +123,7 @@ impl GssapiContext { self.inner .source_name() .map(|name| name.to_string()) - .map_err(|e| GssapiError::ContextError(format!("Failed to get client name: {}", e))) + .map_err(|e| GssapiError::ContextError(format!("failed to get client name: {}", e))) } } diff --git a/pgdog/src/auth/gssapi/error.rs b/pgdog/src/auth/gssapi/error.rs index 8f868b311..f6643f5c3 100644 --- a/pgdog/src/auth/gssapi/error.rs +++ b/pgdog/src/auth/gssapi/error.rs @@ -1,79 +1,55 @@ //! GSSAPI-specific error types -use std::fmt; use std::path::PathBuf; +use thiserror::Error; /// Result type for GSSAPI operations pub type Result = std::result::Result; /// GSSAPI-specific errors -#[derive(Debug)] +#[derive(Debug, Error)] pub enum GssapiError { /// Keytab file not found + #[error("keytab file not found: {0}")] KeytabNotFound(PathBuf), /// Invalid principal name + #[error("invalid principal: {0}")] InvalidPrincipal(String), /// Ticket has expired + #[error("kerberos ticket has expired")] TicketExpired, /// Failed to acquire credentials + #[error("failed to acquire credentials: {0}")] CredentialAcquisitionFailed(String), /// GSSAPI context error + #[error("GSSAPI context error: {0}")] ContextError(String), /// Token processing error + #[error("token processing error: {0}")] TokenError(String), /// Refresh failed + #[error("ticket refresh failed: {0}")] RefreshFailed(String), /// Internal libgssapi error + #[error("GSSAPI library error: {0}")] LibGssapi(String), /// I/O error - Io(std::io::Error), + #[error("{0}")] + Io(#[from] std::io::Error), /// Configuration error + #[error("configuration error: {0}")] Config(String), } -impl fmt::Display for GssapiError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::KeytabNotFound(path) => write!(f, "Keytab file not found: {}", path.display()), - Self::InvalidPrincipal(principal) => write!(f, "Invalid principal: {}", principal), - Self::TicketExpired => write!(f, "Kerberos ticket has expired"), - Self::CredentialAcquisitionFailed(msg) => { - write!(f, "Failed to acquire credentials: {}", msg) - } - Self::ContextError(msg) => write!(f, "GSSAPI context error: {}", msg), - Self::TokenError(msg) => write!(f, "Token processing error: {}", msg), - Self::RefreshFailed(msg) => write!(f, "Ticket refresh failed: {}", msg), - Self::LibGssapi(msg) => write!(f, "GSSAPI library error: {}", msg), - Self::Io(err) => write!(f, "I/O error: {}", err), - Self::Config(msg) => write!(f, "Configuration error: {}", msg), - } - } -} - -impl std::error::Error for GssapiError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(err) => Some(err), - _ => None, - } - } -} - -impl From for GssapiError { - fn from(err: std::io::Error) -> Self { - Self::Io(err) - } -} - // Convert libgssapi errors when we implement the actual functionality #[cfg(feature = "gssapi")] impl From for GssapiError { diff --git a/pgdog/src/auth/gssapi/mod.rs b/pgdog/src/auth/gssapi/mod.rs index 82055dc8c..6c946a069 100644 --- a/pgdog/src/auth/gssapi/mod.rs +++ b/pgdog/src/auth/gssapi/mod.rs @@ -32,7 +32,7 @@ pub async fn handle_gssapi_auth( ); let mut server = server.lock().await; - tracing::debug!("Calling server.accept()"); + tracing::debug!("calling server.accept()"); match server.accept(&client_token)? { Some(response_token) => { // Check if authentication is complete despite having a token @@ -40,12 +40,11 @@ pub async fn handle_gssapi_auth( let principal = server .client_principal() .ok_or_else(|| { - tracing::error!("Context complete but no principal found"); - GssapiError::ContextError("No client principal found".to_string()) + GssapiError::ContextError("no client principal found".to_string()) })? .to_string(); - tracing::info!( + tracing::debug!( "Authentication complete (with final token), principal: {}", principal ); @@ -61,7 +60,7 @@ pub async fn handle_gssapi_auth( Ok(response) } else { // More negotiation needed - tracing::info!( + tracing::debug!( "server.accept returned token of {} bytes - negotiation continues", response_token.len() ); @@ -78,16 +77,13 @@ pub async fn handle_gssapi_auth( } None => { // Authentication complete - tracing::info!("server.accept returned None - authentication complete"); + tracing::debug!("server.accept returned None - authentication complete"); let principal = server .client_principal() - .ok_or_else(|| { - tracing::error!("No client principal found in completed context"); - GssapiError::ContextError("No client principal found".to_string()) - })? + .ok_or_else(|| GssapiError::ContextError("No client principal found".to_string()))? .to_string(); - tracing::info!("Successfully extracted principal: {}", principal); + tracing::debug!("successfully extracted principal: {}", principal); let response = GssapiResponse { is_complete: true, token: None, diff --git a/pgdog/src/auth/gssapi/server.rs b/pgdog/src/auth/gssapi/server.rs index 3d96d69a2..9292bafc5 100644 --- a/pgdog/src/auth/gssapi/server.rs +++ b/pgdog/src/auth/gssapi/server.rs @@ -2,6 +2,7 @@ use super::error::{GssapiError, Result}; use std::path::Path; +#[cfg(feature = "gssapi")] use std::sync::Arc; #[cfg(feature = "gssapi")] @@ -51,10 +52,10 @@ impl GssapiServer { // Create the desired mechanisms set let mut desired_mechs = OidSet::new() - .map_err(|e| GssapiError::LibGssapi(format!("Failed to create OidSet: {}", e)))?; + .map_err(|e| GssapiError::LibGssapi(format!("failed to create OidSet: {}", e)))?; desired_mechs .add(&GSS_MECH_KRB5) - .map_err(|e| GssapiError::LibGssapi(format!("Failed to add mechanism: {}", e)))?; + .map_err(|e| GssapiError::LibGssapi(format!("failed to add mechanism: {}", e)))?; // Acquire credentials for the specified principal Cred::acquire( @@ -65,7 +66,7 @@ impl GssapiServer { ) .map_err(|e| { GssapiError::CredentialAcquisitionFailed(format!( - "Failed to acquire credentials for {}: {}", + "failed to acquire credentials for {}: {}", principal, e )) })? @@ -73,7 +74,7 @@ impl GssapiServer { // Use default service principal Cred::acquire(None, None, CredUsage::Accept, None).map_err(|e| { GssapiError::CredentialAcquisitionFailed(format!( - "Failed to acquire default credentials: {}", + "failed to acquire default credentials: {}", e )) })? @@ -101,24 +102,24 @@ impl GssapiServer { if self.is_complete { tracing::warn!("GssapiServer::accept called but context already complete"); return Err(GssapiError::ContextError( - "Context already complete".to_string(), + "context already complete".to_string(), )); } // Create or reuse the server context let mut ctx = match self.inner.take() { Some(ctx) => { - tracing::debug!("Reusing existing server context"); + tracing::debug!("reusing existing server context"); ctx } None => { - tracing::debug!("Creating new server context"); + tracing::debug!("creating new server context"); ServerCtx::new(Some(self.credential.as_ref().clone())) } }; // Process the client token - tracing::debug!("Calling ctx.step with client token"); + tracing::debug!("calling ctx.step with client token"); match ctx.step(client_token) { Ok(Some(response)) => { // More negotiation needed @@ -133,20 +134,20 @@ impl GssapiServer { // Check if context is actually established despite returning a token if ctx.is_complete() { - tracing::warn!("Context is complete but still returned a token - this might confuse the client"); + tracing::warn!("context is complete but still returned a token - this might confuse the client"); // Mark as complete and extract the principal self.is_complete = true; match ctx.source_name() { Ok(name) => { let principal = name.to_string(); - tracing::info!( + tracing::debug!( "Extracted client principal (with token): {}", principal ); self.client_principal = Some(principal); } Err(e) => { - tracing::error!("Failed to get client principal: {}", e); + tracing::error!("failed to get client principal: {}", e); } } } @@ -156,20 +157,19 @@ impl GssapiServer { } Ok(None) => { // Context established successfully - tracing::info!("ctx.step returned None - GSSAPI context established successfully"); + tracing::debug!("ctx.step returned None - GSSAPI context established successfully"); self.is_complete = true; // Extract the client principal match ctx.source_name() { Ok(name) => { let principal = name.to_string(); - tracing::info!("Extracted client principal: {}", principal); + tracing::debug!("extracted client principal: {}", principal); self.client_principal = Some(principal); } Err(e) => { - tracing::error!("Failed to get client principal: {}", e); return Err(GssapiError::ContextError(format!( - "Failed to get client principal: {}", + "failed to get client principal: {}", e ))); } diff --git a/pgdog/src/auth/gssapi/ticket_cache.rs b/pgdog/src/auth/gssapi/ticket_cache.rs index 60b681a12..82c4d91e0 100644 --- a/pgdog/src/auth/gssapi/ticket_cache.rs +++ b/pgdog/src/auth/gssapi/ticket_cache.rs @@ -3,6 +3,7 @@ use super::error::{GssapiError, Result}; use parking_lot::RwLock; use std::path::PathBuf; +#[cfg(feature = "gssapi")] use std::sync::Arc; use std::time::{Duration, Instant}; @@ -85,10 +86,10 @@ impl TicketCache { // Create the desired mechanisms set let mut desired_mechs = OidSet::new() - .map_err(|e| GssapiError::LibGssapi(format!("Failed to create OidSet: {}", e)))?; + .map_err(|e| GssapiError::LibGssapi(format!("failed to create OidSet: {}", e)))?; desired_mechs .add(&GSS_MECH_KRB5) - .map_err(|e| GssapiError::LibGssapi(format!("Failed to add mechanism: {}", e)))?; + .map_err(|e| GssapiError::LibGssapi(format!("failed to add mechanism: {}", e)))?; // Acquire credentials from the keytab let credential = Cred::acquire( @@ -99,7 +100,7 @@ impl TicketCache { ) .map_err(|e| { GssapiError::CredentialAcquisitionFailed(format!( - "Failed for {}: {}", + "failed for {}: {}", self.principal, e )) })?; diff --git a/pgdog/src/auth/gssapi/ticket_manager.rs b/pgdog/src/auth/gssapi/ticket_manager.rs index ba75f6ac9..098f027f3 100644 --- a/pgdog/src/auth/gssapi/ticket_manager.rs +++ b/pgdog/src/auth/gssapi/ticket_manager.rs @@ -39,7 +39,7 @@ impl TicketManager { /// Get or acquire a ticket for a server /// Returns Ok(()) when the credential cache is ready to use #[cfg(feature = "gssapi")] - pub fn get_ticket( + pub async fn get_ticket( &self, server: impl Into, keytab: impl AsRef, @@ -63,15 +63,16 @@ impl TicketManager { std::env::set_var("KRB5CCNAME", &cache_path); // Use kinit to get a ticket from the keytab into the unique cache - let output = std::process::Command::new("kinit") + let output = tokio::process::Command::new("kinit") .arg("-kt") .arg(&keytab_path) .arg(&principal) .env("KRB5CCNAME", &cache_path) .env("KRB5_CONFIG", "/opt/homebrew/etc/krb5.conf") .output() + .await .map_err(|e| { - super::error::GssapiError::LibGssapi(format!("Failed to run kinit: {}", e)) + super::error::GssapiError::LibGssapi(format!("failed to run kinit: {}", e)) })?; if !output.status.success() { @@ -99,7 +100,7 @@ impl TicketManager { /// Get or acquire a ticket for a server (mock version) #[cfg(not(feature = "gssapi"))] - pub fn get_ticket( + pub async fn get_ticket( &self, _server: impl Into, _keytab: impl AsRef, @@ -126,10 +127,10 @@ impl TicketManager { if cache.needs_refresh() { match cache.refresh() { Ok(()) => { - tracing::info!("Refreshed ticket for {}", server_clone); + tracing::info!("[gssapi] refreshed ticket for \"{}\"", server_clone); } Err(e) => { - tracing::error!("Failed to refresh ticket for {}: {}", server_clone, e); + tracing::error!("failed to refresh ticket for {}: {}", server_clone, e); // Continue trying - the old ticket might still be valid } } @@ -219,12 +220,14 @@ mod tests { assert!(Arc::ptr_eq(&manager1, &manager2)); } - #[test] - fn test_cache_management() { + #[tokio::test] + async fn test_cache_management() { let manager = TicketManager::new(); // This will fail because the keytab doesn't exist, but it tests the structure - let result = manager.get_ticket("server1:5432", "/nonexistent/keytab", "test@REALM"); + let result = manager + .get_ticket("server1:5432", "/nonexistent/keytab", "test@REALM") + .await; assert!(result.is_err()); // Even though ticket acquisition failed, the cache should not be stored diff --git a/pgdog/src/backend/server.rs b/pgdog/src/backend/server.rs index a6259fba8..d1765e38f 100644 --- a/pgdog/src/backend/server.rs +++ b/pgdog/src/backend/server.rs @@ -159,11 +159,9 @@ impl Server { stream.flush().await?; // Check if GSSAPI is configured for this server - let mut gssapi_context = if addr.gssapi_keytab.is_some() && addr.gssapi_principal.is_some() + let mut gssapi_context = if let (Some(keytab), Some(principal)) = + (&addr.gssapi_keytab, &addr.gssapi_principal) { - let keytab = addr.gssapi_keytab.as_ref().unwrap(); - let principal = addr.gssapi_principal.as_ref().unwrap(); - // Use configured target principal if available, otherwise fallback to default format let target = if let Some(ref target_principal) = addr.gssapi_target_principal { target_principal.clone() @@ -180,10 +178,13 @@ impl Server { // Use TicketManager to set up a credential cache for this server // This ensures we use the correct principal for each backend connection let cache_key = format!("{}:{}", addr.host, addr.port); - match TicketManager::global().get_ticket(&cache_key, keytab, principal) { + match TicketManager::global() + .get_ticket(&cache_key, keytab, principal) + .await + { Ok(()) => { debug!( - "Acquired ticket for {} using principal {}", + "acquired ticket for {} using principal {}", cache_key, principal ); @@ -191,17 +192,17 @@ impl Server { // that TicketManager set up with KRB5CCNAME match GssapiContext::new_initiator(keytab, principal, &target) { Ok(ctx) => { - debug!("Initialized GSSAPI context for {} -> {}", principal, target); + debug!("initialized GSSAPI context for {} -> {}", principal, target); Some(ctx) } Err(e) => { - warn!("Failed to initialize GSSAPI context: {}", e); + warn!("failed to initialize GSSAPI context: {}", e); None } } } Err(e) => { - warn!("Failed to acquire ticket for {}: {}", cache_key, e); + warn!("failed to acquire ticket for {}: {}", cache_key, e); None } } @@ -263,10 +264,6 @@ impl Server { } } else { // No GSSAPI configured, server requires it - error!( - "Server requires GSSAPI but no keytab configured for {}", - addr.host - ); return Err(Error::ConnectionError(Box::new( ErrorResponse::auth( &addr.user, @@ -277,7 +274,6 @@ impl Server { } Authentication::Sspi => { // SSPI is Windows-specific GSSAPI variant - error!("SSPI authentication not supported"); return Err(Error::ConnectionError(Box::new(ErrorResponse::auth( &addr.user, "SSPI authentication is not supported", @@ -294,7 +290,6 @@ impl Server { Ok(None) => { // Authentication should be complete if !ctx.is_complete() { - error!("GSSAPI negotiation incomplete but no token to send"); return Err(Error::ConnectionError(Box::new( ErrorResponse::auth( &addr.user, @@ -305,14 +300,12 @@ impl Server { // Continue to wait for Authentication::Ok } Err(e) => { - error!("GSSAPI negotiation failed: {}", e); return Err(Error::ConnectionError(Box::new( ErrorResponse::from_err(&e), ))); } } } else { - error!("Received GSSAPI continue without context"); return Err(Error::ConnectionError(Box::new(ErrorResponse::auth( &addr.user, "Received GSSAPI continue without context", diff --git a/pgdog/tests/backend_gssapi_test.rs b/pgdog/tests/backend_gssapi_test.rs index 94253c076..1ca737ca2 100644 --- a/pgdog/tests/backend_gssapi_test.rs +++ b/pgdog/tests/backend_gssapi_test.rs @@ -70,23 +70,27 @@ fn test_backend_gssapi_target_principal() { assert_eq!(target, "postgres/192.168.1.1"); } -#[test] -fn test_ticket_manager_for_backend() { +#[tokio::test] +async fn test_ticket_manager_for_backend() { // Test that TicketManager can handle backend server tickets let manager = TicketManager::global(); // These will fail without real keytabs, but test the API - let ticket1 = manager.get_ticket( - "server1:5432", - "/etc/pgdog/server1.keytab", - "pgdog-server1@REALM", - ); - - let ticket2 = manager.get_ticket( - "server2:5432", - "/etc/pgdog/server2.keytab", - "pgdog-server2@REALM", - ); + let ticket1 = manager + .get_ticket( + "server1:5432", + "/etc/pgdog/server1.keytab", + "pgdog-server1@REALM", + ) + .await; + + let ticket2 = manager + .get_ticket( + "server2:5432", + "/etc/pgdog/server2.keytab", + "pgdog-server2@REALM", + ) + .await; // Both should fail without real keytabs assert!(ticket1.is_err()); diff --git a/pgdog/tests/gssapi_integration_test.rs b/pgdog/tests/gssapi_integration_test.rs index b7beca89e..acc662de3 100644 --- a/pgdog/tests/gssapi_integration_test.rs +++ b/pgdog/tests/gssapi_integration_test.rs @@ -7,8 +7,8 @@ use pgdog::auth::gssapi::{TicketCache, TicketManager}; use std::path::PathBuf; /// Test that TicketCache can acquire a credential from a keytab -#[test] -fn test_ticket_cache_acquires_credential() { +#[tokio::test] +async fn test_ticket_cache_acquires_credential() { // This test MUST FAIL initially because TicketCache doesn't exist yet let keytab_path = PathBuf::from("/etc/pgdog/test.keytab"); let principal = "test@EXAMPLE.COM"; @@ -24,24 +24,28 @@ fn test_ticket_cache_acquires_credential() { } /// Test that TicketManager maintains per-server caches -#[test] -fn test_ticket_manager_per_server_cache() { +#[tokio::test] +async fn test_ticket_manager_per_server_cache() { // This test MUST FAIL initially because TicketManager doesn't exist yet let manager = TicketManager::new(); // Get ticket for server1 - let ticket1 = manager.get_ticket( - "server1:5432", - "/etc/pgdog/keytab1.keytab", - "principal1@REALM", - ); + let ticket1 = manager + .get_ticket( + "server1:5432", + "/etc/pgdog/keytab1.keytab", + "principal1@REALM", + ) + .await; // Get ticket for server2 - let ticket2 = manager.get_ticket( - "server2:5432", - "/etc/pgdog/keytab2.keytab", - "principal2@REALM", - ); + let ticket2 = manager + .get_ticket( + "server2:5432", + "/etc/pgdog/keytab2.keytab", + "principal2@REALM", + ) + .await; assert!(ticket1.is_ok(), "Failed to get ticket for server1"); assert!(ticket2.is_ok(), "Failed to get ticket for server2"); @@ -84,15 +88,17 @@ async fn test_gssapi_frontend_authentication() { } /// Test ticket refresh mechanism -#[test] -fn test_ticket_refresh() { +#[tokio::test] +async fn test_ticket_refresh() { // This test demonstrates ticket refresh use std::time::Duration; let manager = TicketManager::new(); manager.set_refresh_interval(Duration::from_secs(1)); // Short interval for testing - let ticket = manager.get_ticket("server:5432", "/etc/pgdog/test.keytab", "test@REALM"); + let ticket = manager + .get_ticket("server:5432", "/etc/pgdog/test.keytab", "test@REALM") + .await; assert!(ticket.is_ok()); let initial_refresh_time = manager.get_last_refresh("server:5432"); From c079ec583a2631980e794297d52386237ba911e0 Mon Sep 17 00:00:00 2001 From: Justin George Date: Thu, 18 Sep 2025 11:19:40 -0700 Subject: [PATCH 09/19] minor test fixes --- CONTRIBUTING.md | 43 ++++++++++++++++++++++++++ pgdog/src/auth/gssapi/server.rs | 2 ++ pgdog/src/auth/gssapi/ticket_cache.rs | 4 ++- pgdog/src/backend/pool/test/replica.rs | 3 +- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 118ff0f9e..3bb3f5e75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,49 @@ Contributions are welcome. If you see a bug, feel free to submit a PR with a fix 5. Launch pgdog configured for integration: `bash integration/dev-server.sh`. 6. Run the tests `cargo nextest run --test-threads=1`. If a test fails, try running it directly. +## Building with GSSAPI/Kerberos Support + +PgDog supports GSSAPI/Kerberos authentication as an optional feature. To build and test with GSSAPI support: + +### macOS + +1. Install MIT Kerberos via Homebrew: + ```bash + brew install krb5 + ``` + +2. Set the required environment variables: + ```bash + export PKG_CONFIG_PATH="/opt/homebrew/opt/krb5/lib/pkgconfig" + export LIBGSSAPI_SYS_USE_PKG_CONFIG=1 + ``` + +3. Build with the GSSAPI feature: + ```bash + cargo build --features gssapi + ``` + +4. Run tests with GSSAPI enabled: + ```bash + cargo nextest run --test-threads=1 --features gssapi + ``` + +### Linux + +On most Linux distributions, the system GSSAPI libraries should work without additional configuration: +```bash +cargo build --features gssapi +cargo nextest run --test-threads=1 --features gssapi +``` + +### Testing Notes + +- The test suite is designed to work with or without the GSSAPI feature enabled +- Standard test users (`pgdog`, `pgdog-backend`) use password authentication +- GSSAPI test users have a `-gss` suffix (e.g., `alice-gss`, `bob-gss`, `pgdog-backend-gss`) +- GSSAPI-specific integration tests will only run when the feature is enabled +- If you need to test actual GSSAPI authentication, you'll need to configure PostgreSQL's `pg_hba.conf` and set up a Kerberos environment (KDC, keytabs, etc.) + ## Coding 1. Please format your code with `cargo fmt`. diff --git a/pgdog/src/auth/gssapi/server.rs b/pgdog/src/auth/gssapi/server.rs index 3d96d69a2..c9cc644c8 100644 --- a/pgdog/src/auth/gssapi/server.rs +++ b/pgdog/src/auth/gssapi/server.rs @@ -2,6 +2,8 @@ use super::error::{GssapiError, Result}; use std::path::Path; + +#[cfg(feature = "gssapi")] use std::sync::Arc; #[cfg(feature = "gssapi")] diff --git a/pgdog/src/auth/gssapi/ticket_cache.rs b/pgdog/src/auth/gssapi/ticket_cache.rs index 60b681a12..875190c3d 100644 --- a/pgdog/src/auth/gssapi/ticket_cache.rs +++ b/pgdog/src/auth/gssapi/ticket_cache.rs @@ -3,9 +3,11 @@ use super::error::{GssapiError, Result}; use parking_lot::RwLock; use std::path::PathBuf; -use std::sync::Arc; use std::time::{Duration, Instant}; +#[cfg(feature = "gssapi")] +use std::sync::Arc; + #[cfg(feature = "gssapi")] use libgssapi::{ credential::{Cred, CredUsage}, diff --git a/pgdog/src/backend/pool/test/replica.rs b/pgdog/src/backend/pool/test/replica.rs index 732a11193..a1487cd21 100644 --- a/pgdog/src/backend/pool/test/replica.rs +++ b/pgdog/src/backend/pool/test/replica.rs @@ -20,8 +20,7 @@ fn replicas() -> Replicas { ..Default::default() }, }; - let mut two = one.clone(); - two.address.host = "localhost".into(); + let two = one.clone(); // Keep replicas identical - they both point to same PostgreSQL let replicas = Replicas::new(&[one, two], LoadBalancingStrategy::Random); replicas.pools().iter().for_each(|p| p.launch()); replicas From 78a3c3c5e5b0eaa8be07f4287dd84a516fc7377f Mon Sep 17 00:00:00 2001 From: Justin George Date: Thu, 18 Sep 2025 11:43:03 -0700 Subject: [PATCH 10/19] feature flag gssapi tests, set up krb5 dependencies in ci --- .github/workflows/ci.yml | 20 ++++++++++++++------ pgdog/tests/backend_gssapi_test.rs | 2 ++ pgdog/tests/gssapi_integration_test.rs | 2 ++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e32debdf4..d63d160aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,10 +36,14 @@ jobs: sudo apt remove cmake sudo pip3 install cmake==3.31.6 cmake --version + - name: Install Kerberos dependencies + run: | + sudo apt update + sudo apt install -y libkrb5-dev krb5-user krb5-config libgssapi-krb5-2 - name: Build - run: cargo build + run: cargo build --features gssapi - name: Check release - run: cargo check --release + run: cargo check --release --features gssapi tests: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: @@ -51,6 +55,10 @@ jobs: - uses: useblacksmith/rust-cache@v3 with: prefix-key: "v1" # Change this when updating tooling + - name: Install Kerberos dependencies + run: | + sudo apt update + sudo apt install -y libkrb5-dev krb5-user krb5-config libgssapi-krb5-2 - name: Setup PostgreSQL run: | sudo service postgresql start @@ -59,7 +67,7 @@ jobs: sudo -u postgres psql -c 'ALTER SYSTEM SET max_prepared_transactions TO 1000;' sudo service postgresql restart bash integration/setup.sh - sudo apt update && sudo apt install -y python3-virtualenv mold + sudo apt install -y python3-virtualenv mold sudo gem install bundler sudo apt remove -y cmake sudo pip3 install cmake==3.31.6 @@ -68,10 +76,10 @@ jobs: bash integration/toxi/setup.sh - name: Install test dependencies run: cargo install cargo-nextest --version "0.9.78" --locked - - name: Run tests - run: cargo nextest run -E 'package(pgdog)' --no-fail-fast --test-threads=1 + - name: Run tests with GSSAPI + run: cargo nextest run -E 'package(pgdog)' --no-fail-fast --test-threads=1 --features gssapi - name: Run documentation tests - run: cargo test --doc + run: cargo test --doc --features gssapi integration: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: diff --git a/pgdog/tests/backend_gssapi_test.rs b/pgdog/tests/backend_gssapi_test.rs index 1ca737ca2..20ab918f5 100644 --- a/pgdog/tests/backend_gssapi_test.rs +++ b/pgdog/tests/backend_gssapi_test.rs @@ -2,6 +2,8 @@ //! //! These tests verify that PGDog can authenticate to PostgreSQL servers using GSSAPI/Kerberos. +#![cfg(feature = "gssapi")] + use pgdog::auth::gssapi::{GssapiContext, TicketManager}; use pgdog::backend::pool::Address; diff --git a/pgdog/tests/gssapi_integration_test.rs b/pgdog/tests/gssapi_integration_test.rs index acc662de3..b440e7ef2 100644 --- a/pgdog/tests/gssapi_integration_test.rs +++ b/pgdog/tests/gssapi_integration_test.rs @@ -3,6 +3,8 @@ //! These tests are designed to fail initially as we implement the GSSAPI functionality. //! They demonstrate the expected API and behavior for GSSAPI authentication. +#![cfg(feature = "gssapi")] + use pgdog::auth::gssapi::{TicketCache, TicketManager}; use std::path::PathBuf; From 9037e92426f696f7e0ae4e3a91e081b5a381b958 Mon Sep 17 00:00:00 2001 From: Justin George Date: Thu, 18 Sep 2025 12:17:37 -0700 Subject: [PATCH 11/19] try to create keytabs before integration tests --- .gitignore | 1 + integration/gssapi/keytabs/.keep | 0 integration/gssapi/setup_test_keytabs.sh | 85 ++++++++++++++++++++++++ integration/gssapi/test_users.toml | 21 ++++++ integration/setup.sh | 10 +++ pgdog/tests/backend_gssapi_test.rs | 49 +++++++++----- pgdog/tests/gssapi_integration_test.rs | 59 +++++++++++----- 7 files changed, 194 insertions(+), 31 deletions(-) create mode 100644 integration/gssapi/keytabs/.keep create mode 100755 integration/gssapi/setup_test_keytabs.sh create mode 100644 integration/gssapi/test_users.toml diff --git a/.gitignore b/.gitignore index 4445363c2..70a7e762d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ CLAUDE.local.md pgdog-plugin/src/bindings.rs local/ integration/log.txt +integration/gssapi/keytabs/*.keytab diff --git a/integration/gssapi/keytabs/.keep b/integration/gssapi/keytabs/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/integration/gssapi/setup_test_keytabs.sh b/integration/gssapi/setup_test_keytabs.sh new file mode 100755 index 000000000..50d72dd67 --- /dev/null +++ b/integration/gssapi/setup_test_keytabs.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +# Setup script for GSSAPI test keytabs +# This creates mock keytab files for testing purposes + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +KEYTAB_DIR="${SCRIPT_DIR}/keytabs" + +echo "Setting up GSSAPI test keytabs in ${KEYTAB_DIR}" + +# Create keytab directory +mkdir -p "${KEYTAB_DIR}" + +# Check if we have kadmin.local available (for real keytab creation) +if command -v kadmin.local &> /dev/null || command -v /opt/homebrew/opt/krb5/sbin/kadmin.local &> /dev/null; then + echo "Kerberos admin tools found, attempting to create real keytabs..." + + # Try to find kadmin.local + KADMIN_LOCAL="" + if command -v kadmin.local &> /dev/null; then + KADMIN_LOCAL="kadmin.local" + elif [ -x "/opt/homebrew/opt/krb5/sbin/kadmin.local" ]; then + KADMIN_LOCAL="/opt/homebrew/opt/krb5/sbin/kadmin.local" + elif [ -x "/usr/sbin/kadmin.local" ]; then + KADMIN_LOCAL="/usr/sbin/kadmin.local" + fi + + if [ -n "$KADMIN_LOCAL" ] && [ -f "/opt/homebrew/etc/krb5kdc/kdc.conf" -o -f "/etc/krb5kdc/kdc.conf" ]; then + # Try to create test principals and keytabs + echo "Creating test principals and keytabs (this may fail if KDC is not configured)..." + + # Export keytabs for test principals (these may fail if principals don't exist) + for principal in test pgdog-test server1 server2; do + echo "Attempting to create keytab for ${principal}@PGDOG.LOCAL..." + ${KADMIN_LOCAL} -q "ktadd -k ${KEYTAB_DIR}/${principal}.keytab ${principal}@PGDOG.LOCAL" 2>/dev/null || \ + echo " Could not create keytab for ${principal}@PGDOG.LOCAL (principal may not exist)" + done + fi +else + echo "Kerberos admin tools not found, creating mock keytab files..." +fi + +# Create mock keytab files for testing (even if real keytab creation failed) +# These files will exist but won't be valid Kerberos keytabs +for keytab in test.keytab pgdog-test.keytab server1.keytab server2.keytab keytab1.keytab keytab2.keytab backend.keytab; do + if [ ! -f "${KEYTAB_DIR}/${keytab}" ]; then + echo "Creating mock keytab: ${KEYTAB_DIR}/${keytab}" + # Create a file with mock keytab header (won't work for real auth but tests file existence) + printf '\x05\x02' > "${KEYTAB_DIR}/${keytab}" + fi +done + +# Create a users file for GSSAPI testing +cat > "${SCRIPT_DIR}/test_users.toml" << 'EOF' +# Test users for GSSAPI authentication testing + +[[user]] +name = "test" +principal = "test@PGDOG.LOCAL" +strip_realm = true + +[[user]] +name = "pgdog-test" +principal = "pgdog-test@PGDOG.LOCAL" +strip_realm = true + +[[user]] +name = "principal1" +principal = "principal1@REALM" +strip_realm = false + +[[user]] +name = "principal2" +principal = "principal2@REALM" +strip_realm = false +EOF + +echo "GSSAPI test setup complete!" +echo "Keytabs created in: ${KEYTAB_DIR}" +echo "Test users config: ${SCRIPT_DIR}/test_users.toml" +echo "" +echo "Note: Mock keytabs are not valid for real Kerberos authentication." +echo " They exist only to test file handling and error paths." \ No newline at end of file diff --git a/integration/gssapi/test_users.toml b/integration/gssapi/test_users.toml new file mode 100644 index 000000000..c943577ef --- /dev/null +++ b/integration/gssapi/test_users.toml @@ -0,0 +1,21 @@ +# Test users for GSSAPI authentication testing + +[[user]] +name = "test" +principal = "test@PGDOG.LOCAL" +strip_realm = true + +[[user]] +name = "pgdog-test" +principal = "pgdog-test@PGDOG.LOCAL" +strip_realm = true + +[[user]] +name = "principal1" +principal = "principal1@REALM" +strip_realm = false + +[[user]] +name = "principal2" +principal = "principal2@REALM" +strip_realm = false diff --git a/integration/setup.sh b/integration/setup.sh index 913732dd1..a76d067ee 100644 --- a/integration/setup.sh +++ b/integration/setup.sh @@ -62,4 +62,14 @@ for bin in toxiproxy-server toxiproxy-cli; do fi done +# Setup GSSAPI test keytabs if Kerberos is available or requested +if command -v kadmin.local &> /dev/null || [ -x "/opt/homebrew/opt/krb5/sbin/kadmin.local" ] || [ -n "$SETUP_GSSAPI" ]; then + echo "Setting up GSSAPI test environment..." + if [ -x "${SCRIPT_DIR}/gssapi/setup_test_keytabs.sh" ]; then + bash "${SCRIPT_DIR}/gssapi/setup_test_keytabs.sh" || echo "GSSAPI setup failed (non-critical)" + fi +else + echo "Skipping GSSAPI setup (Kerberos tools not found)" +fi + popd diff --git a/pgdog/tests/backend_gssapi_test.rs b/pgdog/tests/backend_gssapi_test.rs index 20ab918f5..1fb2cd655 100644 --- a/pgdog/tests/backend_gssapi_test.rs +++ b/pgdog/tests/backend_gssapi_test.rs @@ -6,6 +6,22 @@ use pgdog::auth::gssapi::{GssapiContext, TicketManager}; use pgdog::backend::pool::Address; +use std::path::PathBuf; + +/// Get the path to the test keytabs directory +fn test_keytab_path(filename: &str) -> PathBuf { + // Use the CARGO_MANIFEST_DIR to find the project root, or fallback to relative path + let base_path = std::env::var("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")); + base_path + .parent() // Go up from pgdog/ to project root + .unwrap_or(&base_path) + .join("integration") + .join("gssapi") + .join("keytabs") + .join(filename) +} #[test] fn test_address_has_gssapi() { @@ -23,7 +39,11 @@ fn test_address_has_gssapi() { assert!(!addr.has_gssapi()); - addr.gssapi_keytab = Some("/etc/test.keytab".to_string()); + addr.gssapi_keytab = Some( + test_keytab_path("test.keytab") + .to_string_lossy() + .to_string(), + ); assert!(!addr.has_gssapi()); // Still need principal addr.gssapi_principal = Some("test@REALM".to_string()); @@ -33,8 +53,8 @@ fn test_address_has_gssapi() { #[test] fn test_gssapi_context_for_backend() { // Test creating a GSSAPI context for backend connection - let keytab = "/etc/pgdog/backend.keytab"; - let principal = "pgdog@REALM"; + let keytab = test_keytab_path("backend.keytab"); + let principal = "pgdog-test@PGDOG.LOCAL"; let target = "postgres/db.example.com"; let context = GssapiContext::new_initiator(keytab, principal, target); @@ -81,16 +101,16 @@ async fn test_ticket_manager_for_backend() { let ticket1 = manager .get_ticket( "server1:5432", - "/etc/pgdog/server1.keytab", - "pgdog-server1@REALM", + test_keytab_path("server1.keytab"), + "server1@PGDOG.LOCAL", ) .await; let ticket2 = manager .get_ticket( "server2:5432", - "/etc/pgdog/server2.keytab", - "pgdog-server2@REALM", + test_keytab_path("server2.keytab"), + "server2@PGDOG.LOCAL", ) .await; @@ -113,8 +133,8 @@ async fn test_backend_gssapi_negotiation_mock() { // This test demonstrates the expected flow for backend GSSAPI // In a real scenario, this would connect to a PostgreSQL server with GSSAPI enabled - let keytab = "/etc/pgdog/backend.keytab"; - let principal = "pgdog@REALM"; + let keytab = test_keytab_path("backend.keytab"); + let principal = "pgdog-test@PGDOG.LOCAL"; let target = "postgres/localhost"; let _context = GssapiContext::new_initiator(keytab, principal, target); @@ -161,15 +181,14 @@ fn test_backend_gssapi_not_configured() { #[test] fn test_backend_gssapi_from_config() { use pgdog::config::GssapiConfig; - use std::path::PathBuf; let gssapi = GssapiConfig { enabled: true, - server_keytab: Some(PathBuf::from("/etc/pgdog/pgdog.keytab")), - server_principal: Some("pgdog@REALM".to_string()), - default_backend_keytab: Some(PathBuf::from("/etc/pgdog/backend.keytab")), - default_backend_principal: Some("pgdog-backend@REALM".to_string()), - default_backend_target_principal: Some("postgres/test@REALM".to_string()), + server_keytab: Some(test_keytab_path("test.keytab")), + server_principal: Some("pgdog-test@PGDOG.LOCAL".to_string()), + default_backend_keytab: Some(test_keytab_path("backend.keytab")), + default_backend_principal: Some("pgdog-backend@PGDOG.LOCAL".to_string()), + default_backend_target_principal: Some("postgres/test@PGDOG.LOCAL".to_string()), strip_realm: true, ticket_refresh_interval: 14400, fallback_enabled: false, diff --git a/pgdog/tests/gssapi_integration_test.rs b/pgdog/tests/gssapi_integration_test.rs index b440e7ef2..ac0a64c37 100644 --- a/pgdog/tests/gssapi_integration_test.rs +++ b/pgdog/tests/gssapi_integration_test.rs @@ -8,12 +8,27 @@ use pgdog::auth::gssapi::{TicketCache, TicketManager}; use std::path::PathBuf; +/// Get the path to the test keytabs directory +fn test_keytab_path(filename: &str) -> PathBuf { + // Use the CARGO_MANIFEST_DIR to find the project root, or fallback to relative path + let base_path = std::env::var("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")); + base_path + .parent() // Go up from pgdog/ to project root + .unwrap_or(&base_path) + .join("integration") + .join("gssapi") + .join("keytabs") + .join(filename) +} + /// Test that TicketCache can acquire a credential from a keytab #[tokio::test] async fn test_ticket_cache_acquires_credential() { - // This test MUST FAIL initially because TicketCache doesn't exist yet - let keytab_path = PathBuf::from("/etc/pgdog/test.keytab"); - let principal = "test@EXAMPLE.COM"; + // Use test keytab from integration directory + let keytab_path = test_keytab_path("test.keytab"); + let principal = "test@PGDOG.LOCAL"; let cache = TicketCache::new(principal, keytab_path); let ticket = cache.acquire_ticket(); @@ -35,8 +50,8 @@ async fn test_ticket_manager_per_server_cache() { let ticket1 = manager .get_ticket( "server1:5432", - "/etc/pgdog/keytab1.keytab", - "principal1@REALM", + test_keytab_path("server1.keytab"), + "server1@PGDOG.LOCAL", ) .await; @@ -44,8 +59,8 @@ async fn test_ticket_manager_per_server_cache() { let ticket2 = manager .get_ticket( "server2:5432", - "/etc/pgdog/keytab2.keytab", - "principal2@REALM", + test_keytab_path("server2.keytab"), + "server2@PGDOG.LOCAL", ) .await; @@ -69,7 +84,7 @@ async fn test_gssapi_frontend_authentication() { use tokio::sync::Mutex; // This will fail without a real keytab - let server = GssapiServer::new_acceptor("/test.keytab", None); + let server = GssapiServer::new_acceptor(test_keytab_path("test.keytab"), None); if server.is_err() { // Expected to fail without real keytab return; @@ -99,7 +114,11 @@ async fn test_ticket_refresh() { manager.set_refresh_interval(Duration::from_secs(1)); // Short interval for testing let ticket = manager - .get_ticket("server:5432", "/etc/pgdog/test.keytab", "test@REALM") + .get_ticket( + "server:5432", + test_keytab_path("test.keytab"), + "test@PGDOG.LOCAL", + ) .await; assert!(ticket.is_ok()); @@ -121,9 +140,9 @@ fn test_backend_gssapi_context() { // This test demonstrates GssapiContext API use pgdog::auth::gssapi::GssapiContext; - let keytab = "/etc/pgdog/backend.keytab"; - let principal = "pgdog@REALM"; - let target = "postgres/db.example.com@REALM"; + let keytab = test_keytab_path("backend.keytab"); + let principal = "pgdog-test@PGDOG.LOCAL"; + let target = "postgres/db.example.com@PGDOG.LOCAL"; let context = GssapiContext::new_initiator(keytab, principal, target); @@ -147,8 +166,8 @@ fn test_backend_gssapi_context() { /// Test error handling for missing keytab #[test] fn test_missing_keytab_error() { - // This test MUST FAIL initially (but in a controlled way) - let cache = TicketCache::new("test@REALM", PathBuf::from("/nonexistent/keytab")); + // Test with a truly non-existent keytab + let cache = TicketCache::new("test@PGDOG.LOCAL", PathBuf::from("/nonexistent/keytab")); let ticket = cache.acquire_ticket(); assert!(ticket.is_err()); @@ -163,8 +182,16 @@ fn test_ticket_manager_cleanup() { let manager = TicketManager::new(); // Add some tickets - let _ = manager.get_ticket("server1:5432", "/etc/keytab1", "principal1@REALM"); - let _ = manager.get_ticket("server2:5432", "/etc/keytab2", "principal2@REALM"); + let _ = manager.get_ticket( + "server1:5432", + test_keytab_path("keytab1.keytab"), + "principal1@REALM", + ); + let _ = manager.get_ticket( + "server2:5432", + test_keytab_path("keytab2.keytab"), + "principal2@REALM", + ); assert_eq!(manager.cache_count(), 2); From afea619ff4d23c3a4f82abfcd4ce52fc573d381c Mon Sep 17 00:00:00 2001 From: Justin George Date: Thu, 18 Sep 2025 17:43:26 -0700 Subject: [PATCH 12/19] progress towards tests --- .github/workflows/ci.yml | 18 +- integration/gssapi/setup_ci_kdc.sh | 111 +++++++ integration/gssapi/setup_test_keytabs.sh | 371 ++++++++++++++++++++--- integration/setup.sh | 17 +- pgdog/tests/backend_gssapi_test.rs | 21 ++ pgdog/tests/gssapi_integration_test.rs | 42 +++ 6 files changed, 526 insertions(+), 54 deletions(-) create mode 100644 integration/gssapi/setup_ci_kdc.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d63d160aa..ba1aabd68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: override: true - name: Install CMake 3.31 run: | - sudo apt update && sudo apt install mold -y + sudo apt update && sudo apt install -y mold clang pkg-config protobuf-compiler sudo apt remove cmake sudo pip3 install cmake==3.31.6 cmake --version @@ -32,14 +32,14 @@ jobs: override: true - name: Install CMake 3.31 run: | - sudo apt update && sudo apt install mold -y + sudo apt update && sudo apt install -y mold clang pkg-config protobuf-compiler sudo apt remove cmake sudo pip3 install cmake==3.31.6 cmake --version - name: Install Kerberos dependencies run: | sudo apt update - sudo apt install -y libkrb5-dev krb5-user krb5-config libgssapi-krb5-2 + sudo DEBIAN_FRONTEND=noninteractive apt install -y libkrb5-dev krb5-user krb5-config libgssapi-krb5-2 - name: Build run: cargo build --features gssapi - name: Check release @@ -58,7 +58,13 @@ jobs: - name: Install Kerberos dependencies run: | sudo apt update - sudo apt install -y libkrb5-dev krb5-user krb5-config libgssapi-krb5-2 + sudo DEBIAN_FRONTEND=noninteractive apt install -y libkrb5-dev krb5-user krb5-config libgssapi-krb5-2 + + # Setup KDC for testing (skip in act - systemctl doesn't work in containers) + if [ -f "integration/gssapi/setup_ci_kdc.sh" ] && [ ! -f /.dockerenv ]; then + chmod +x integration/gssapi/setup_ci_kdc.sh + sudo bash integration/gssapi/setup_ci_kdc.sh || echo "KDC setup failed, will use mock keytabs" + fi - name: Setup PostgreSQL run: | sudo service postgresql start @@ -67,7 +73,7 @@ jobs: sudo -u postgres psql -c 'ALTER SYSTEM SET max_prepared_transactions TO 1000;' sudo service postgresql restart bash integration/setup.sh - sudo apt install -y python3-virtualenv mold + sudo apt install -y python3-virtualenv mold clang pkg-config protobuf-compiler sudo gem install bundler sudo apt remove -y cmake sudo pip3 install cmake==3.31.6 @@ -100,7 +106,7 @@ jobs: sudo -u postgres psql -c 'ALTER SYSTEM SET max_prepared_transactions TO 1000;' sudo service postgresql restart bash integration/setup.sh - sudo apt update && sudo apt install -y python3-virtualenv mold + sudo apt update && sudo apt install -y python3-virtualenv mold clang pkg-config protobuf-compiler sudo gem install bundler sudo apt remove -y cmake sudo pip3 install cmake==3.31.6 diff --git a/integration/gssapi/setup_ci_kdc.sh b/integration/gssapi/setup_ci_kdc.sh new file mode 100644 index 000000000..a46c9d613 --- /dev/null +++ b/integration/gssapi/setup_ci_kdc.sh @@ -0,0 +1,111 @@ +#!/bin/bash + +# Setup a minimal KDC for CI testing +# This script is designed to run in GitHub Actions Ubuntu environment + +set -e + +echo "=========================================" +echo "Setting up Kerberos KDC for CI" +echo "=========================================" + +# Install Kerberos KDC packages +echo "Installing Kerberos KDC packages..." +sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \ + krb5-kdc \ + krb5-admin-server \ + krb5-config \ + krb5-user \ + libkrb5-dev + +# Define realm +REALM="PGDOG.LOCAL" +DOMAIN="pgdog.local" +KDC_PASSWORD="admin123" + +# Create krb5.conf +echo "Creating /etc/krb5.conf..." +sudo tee /etc/krb5.conf > /dev/null << EOF +[libdefaults] + default_realm = $REALM + dns_lookup_realm = false + dns_lookup_kdc = false + ticket_lifetime = 24h + renew_lifetime = 7d + forwardable = true + +[realms] + $REALM = { + kdc = localhost + admin_server = localhost + default_domain = $DOMAIN + } + +[domain_realm] + .$DOMAIN = $REALM + $DOMAIN = $REALM + localhost = $REALM + +[logging] + kdc = FILE:/var/log/krb5kdc.log + admin_server = FILE:/var/log/kadmin.log + default = FILE:/var/log/krb5lib.log +EOF + +# Create kdc.conf +echo "Creating /etc/krb5kdc/kdc.conf..." +sudo mkdir -p /etc/krb5kdc +sudo tee /etc/krb5kdc/kdc.conf > /dev/null << EOF +[kdcdefaults] + kdc_ports = 88 + kdc_tcp_ports = 88 + +[realms] + $REALM = { + acl_file = /etc/krb5kdc/kadm5.acl + database_name = /var/lib/krb5kdc/principal + key_stash_file = /etc/krb5kdc/.k5.$REALM + max_renewable_life = 7d 0h 0m 0s + max_life = 1d 0h 0m 0s + master_key_type = aes256-cts-hmac-sha1-96 + supported_enctypes = aes256-cts-hmac-sha1-96:normal aes128-cts-hmac-sha1-96:normal + default_principal_flags = +renewable, +forwardable + } + +[logging] + kdc = FILE:/var/log/krb5kdc.log + admin_server = FILE:/var/log/kadmin.log +EOF + +# Create ACL file +echo "Creating /etc/krb5kdc/kadm5.acl..." +sudo tee /etc/krb5kdc/kadm5.acl > /dev/null << EOF +*/admin@$REALM * +EOF + +# Initialize the database +echo "Initializing Kerberos database..." +sudo kdb5_util create -s -r $REALM -P $KDC_PASSWORD + +# Start the KDC +echo "Starting KDC..." +sudo systemctl start krb5-kdc || sudo krb5kdc +sudo systemctl start krb5-admin-server || true + +# Create admin principal +echo "Creating admin principal..." +echo -e "$KDC_PASSWORD\n$KDC_PASSWORD" | sudo kadmin.local -q "addprinc admin/admin" + +# Verify KDC is running +echo "Verifying KDC..." +if sudo kadmin.local -q "listprincs" | grep -q "krbtgt/$REALM@$REALM"; then + echo "✓ KDC is running and accessible" +else + echo "✗ KDC setup failed" + exit 1 +fi + +echo "=========================================" +echo "KDC setup complete!" +echo "Realm: $REALM" +echo "=========================================" \ No newline at end of file diff --git a/integration/gssapi/setup_test_keytabs.sh b/integration/gssapi/setup_test_keytabs.sh index 50d72dd67..fa3b4aff2 100755 --- a/integration/gssapi/setup_test_keytabs.sh +++ b/integration/gssapi/setup_test_keytabs.sh @@ -1,85 +1,370 @@ #!/bin/bash # Setup script for GSSAPI test keytabs -# This creates mock keytab files for testing purposes +# This creates real Kerberos keytabs for testing set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" KEYTAB_DIR="${SCRIPT_DIR}/keytabs" +# Test password for all principals +TEST_PASSWORD="password" + +# Check if we're in CI environment +if [ -z "$CI" ]; then + # GitHub Actions sets CI=true, but check for other indicators too + if [ -n "$GITHUB_ACTIONS" ] || [ -n "$JENKINS_HOME" ] || [ -n "$GITLAB_CI" ]; then + export CI=true + else + export CI=false + fi +fi + +# Detect OS +OS=$(uname -s) + echo "Setting up GSSAPI test keytabs in ${KEYTAB_DIR}" # Create keytab directory mkdir -p "${KEYTAB_DIR}" -# Check if we have kadmin.local available (for real keytab creation) -if command -v kadmin.local &> /dev/null || command -v /opt/homebrew/opt/krb5/sbin/kadmin.local &> /dev/null; then - echo "Kerberos admin tools found, attempting to create real keytabs..." +# Create mock keytabs for testing when KDC is not available +create_mock_keytabs() { + echo "Creating mock keytabs for testing..." + for keytab in test pgdog-test server1 server2 principal1 principal2 backend keytab1 keytab2; do + keytab_file="${KEYTAB_DIR}/${keytab}.keytab" + echo " Creating mock keytab: $keytab_file" + # Create a minimal valid keytab file structure + # Keytab format version (0x0502) + printf '\x05\x02' > "$keytab_file" + chmod 600 "$keytab_file" + done + echo "Mock keytabs created for CI testing" +} - # Try to find kadmin.local - KADMIN_LOCAL="" - if command -v kadmin.local &> /dev/null; then - KADMIN_LOCAL="kadmin.local" - elif [ -x "/opt/homebrew/opt/krb5/sbin/kadmin.local" ]; then - KADMIN_LOCAL="/opt/homebrew/opt/krb5/sbin/kadmin.local" +# Find kadmin.local +find_kadmin_local() { + if [ -x "/opt/homebrew/opt/krb5/sbin/kadmin.local" ]; then + echo "/opt/homebrew/opt/krb5/sbin/kadmin.local" elif [ -x "/usr/sbin/kadmin.local" ]; then - KADMIN_LOCAL="/usr/sbin/kadmin.local" + echo "/usr/sbin/kadmin.local" + elif command -v kadmin.local &> /dev/null; then + command -v kadmin.local + else + echo "" + fi +} + +# Setup environment based on OS +setup_kerberos_env() { + if [ "$OS" = "Darwin" ]; then + # macOS with Homebrew + if [ -f "/opt/homebrew/etc/krb5.conf" ]; then + export KRB5_CONFIG=/opt/homebrew/etc/krb5.conf + export KRB5_KDC_PROFILE=/opt/homebrew/etc/krb5kdc/kdc.conf + echo "Using Homebrew Kerberos configuration" + return 0 + fi + elif [ "$OS" = "Linux" ]; then + # Linux - standard locations + if [ -f "/etc/krb5.conf" ]; then + export KRB5_CONFIG=/etc/krb5.conf + export KRB5_KDC_PROFILE=/etc/krb5kdc/kdc.conf + echo "Using system Kerberos configuration" + return 0 + fi fi - if [ -n "$KADMIN_LOCAL" ] && [ -f "/opt/homebrew/etc/krb5kdc/kdc.conf" -o -f "/etc/krb5kdc/kdc.conf" ]; then - # Try to create test principals and keytabs - echo "Creating test principals and keytabs (this may fail if KDC is not configured)..." + # No config found + echo "Warning: No Kerberos configuration found" + return 1 +} - # Export keytabs for test principals (these may fail if principals don't exist) - for principal in test pgdog-test server1 server2; do - echo "Attempting to create keytab for ${principal}@PGDOG.LOCAL..." - ${KADMIN_LOCAL} -q "ktadd -k ${KEYTAB_DIR}/${principal}.keytab ${principal}@PGDOG.LOCAL" 2>/dev/null || \ - echo " Could not create keytab for ${principal}@PGDOG.LOCAL (principal may not exist)" - done +# Check if KDC is running +check_kdc_running() { + local kadmin_local="$1" + + echo "Checking if KDC is accessible..." + if $kadmin_local -q "listprincs" &>/dev/null; then + echo "✓ KDC is accessible" + return 0 + else + echo "✗ KDC is not accessible" + return 1 fi -else - echo "Kerberos admin tools not found, creating mock keytab files..." -fi +} + +# Get the realm from krb5.conf +get_realm() { + if [ -n "$KRB5_CONFIG" ] && [ -f "$KRB5_CONFIG" ]; then + grep "default_realm" "$KRB5_CONFIG" | awk '{print $3}' | head -1 + else + echo "PGDOG.LOCAL" # Default fallback + fi +} + +# Create a test principal +create_principal() { + local kadmin_local="$1" + local principal="$2" + local password="$3" + + # Check if principal exists + if $kadmin_local -q "getprinc $principal" 2>&1 | grep -q "does not exist"; then + # Principal doesn't exist, create it + echo " Creating principal $principal..." + if $kadmin_local -q "addprinc -pw $password $principal" 2>&1 | grep -q "created"; then + echo " ✓ Created principal $principal" + else + echo " ✗ Failed to create principal $principal" + return 1 + fi + else + echo " Principal $principal already exists" + fi + return 0 +} -# Create mock keytab files for testing (even if real keytab creation failed) -# These files will exist but won't be valid Kerberos keytabs -for keytab in test.keytab pgdog-test.keytab server1.keytab server2.keytab keytab1.keytab keytab2.keytab backend.keytab; do - if [ ! -f "${KEYTAB_DIR}/${keytab}" ]; then - echo "Creating mock keytab: ${KEYTAB_DIR}/${keytab}" - # Create a file with mock keytab header (won't work for real auth but tests file existence) - printf '\x05\x02' > "${KEYTAB_DIR}/${keytab}" +# Export principal to keytab +export_to_keytab() { + local kadmin_local="$1" + local principal="$2" + local keytab_file="$3" + + echo " Exporting $principal to $keytab_file..." + + # Remove old keytab if exists + rm -f "$keytab_file" + + # Export to keytab (this changes the key) + local output=$($kadmin_local -q "ktadd -k $keytab_file $principal" 2>&1) + if echo "$output" | grep -q "added to keytab"; then + echo " ✓ Exported to keytab" + chmod 600 "$keytab_file" + return 0 + else + echo " ✗ Failed to export to keytab" + echo " Error: $output" + return 1 + fi +} + +# Verify keytab works +verify_keytab() { + local keytab_file="$1" + local principal="$2" + + echo " Verifying keytab for $principal..." + + # Try to authenticate with keytab + if KRB5CCNAME=/tmp/krb5cc_test_$$ kinit -kt "$keytab_file" "$principal" &>/dev/null; then + echo " ✓ Keytab verification successful" + # Clean up test ticket + KRB5CCNAME=/tmp/krb5cc_test_$$ kdestroy &>/dev/null || true + rm -f /tmp/krb5cc_test_$$ + return 0 + else + echo " ✗ Keytab verification failed" + return 1 fi -done +} + +# Main setup +main() { + echo "=========================================" + echo "GSSAPI Test Keytab Setup" + echo "=========================================" + + # Setup environment + if ! setup_kerberos_env; then + echo "ERROR: Cannot find Kerberos configuration" + echo "For macOS: Install with 'brew install krb5'" + echo "For Linux: Install krb5-kdc and krb5-admin-server" + exit 1 + fi + + # Find kadmin.local + KADMIN_LOCAL=$(find_kadmin_local) + if [ -z "$KADMIN_LOCAL" ]; then + echo "ERROR: kadmin.local not found" + echo "This script requires local access to the KDC" + exit 1 + fi + echo "Using kadmin.local: $KADMIN_LOCAL" + + # Check KDC is running + if ! check_kdc_running "$KADMIN_LOCAL"; then + echo "ERROR: Cannot connect to KDC" + echo "Please ensure the KDC is running and accessible" + + if [ "$CI" = "true" ]; then + echo "In CI environment - attempting to setup minimal KDC..." -# Create a users file for GSSAPI testing -cat > "${SCRIPT_DIR}/test_users.toml" << 'EOF' + # Try to run the CI KDC setup script if it exists + CI_KDC_SCRIPT="${SCRIPT_DIR}/setup_ci_kdc.sh" + if [ -f "$CI_KDC_SCRIPT" ]; then + echo "Running CI KDC setup script..." + # CI KDC setup requires sudo + if sudo bash "$CI_KDC_SCRIPT"; then + echo "CI KDC setup successful, retrying..." + # Re-setup environment and retry + setup_kerberos_env + KADMIN_LOCAL=$(find_kadmin_local) + if [ -n "$KADMIN_LOCAL" ] && check_kdc_running "$KADMIN_LOCAL"; then + echo "KDC is now accessible, continuing with keytab creation..." + # Don't exit, continue with the main flow + else + echo "KDC still not accessible after setup" + echo "Creating mock keytabs as fallback..." + create_mock_keytabs + exit 0 + fi + else + echo "CI KDC setup failed" + echo "Creating mock keytabs as fallback..." + create_mock_keytabs + exit 0 + fi + else + echo "No CI KDC setup script found" + echo "Creating mock keytabs as fallback..." + create_mock_keytabs + exit 0 + fi + else + echo "Not in CI environment. Please ensure KDC is running locally." + echo "For macOS: brew services start krb5" + echo "For Linux: sudo systemctl start krb5-kdc" + exit 1 + fi + fi + + # Get realm + REALM=$(get_realm) + echo "Using Kerberos realm: $REALM" + + echo "" + echo "Creating test principals and keytabs..." + echo "-----------------------------------------" + + # Define principals to create + # Using REALM for consistency with existing setup + declare -a principals=( + "test@$REALM" + "pgdog-test@$REALM" + "server1@$REALM" + "server2@$REALM" + ) + + # Also create generic test principals + principals+=("principal1@$REALM" "principal2@$REALM") + + # Create principals and keytabs + for principal in "${principals[@]}"; do + # Extract base name for keytab file + base_name=$(echo "$principal" | cut -d'@' -f1) + keytab_file="${KEYTAB_DIR}/${base_name}.keytab" + + echo "" + echo "Processing $principal:" + + # Create principal + if ! create_principal "$KADMIN_LOCAL" "$principal" "$TEST_PASSWORD"; then + echo "WARNING: Failed to create principal $principal" + continue + fi + + # Export to keytab + if ! export_to_keytab "$KADMIN_LOCAL" "$principal" "$keytab_file"; then + echo "WARNING: Failed to export $principal to keytab" + continue + fi + + # Verify keytab + if ! verify_keytab "$keytab_file" "$principal"; then + echo "WARNING: Keytab verification failed for $principal" + fi + done + + # Create additional keytabs that tests expect + echo "" + echo "Creating additional test keytabs..." + + # backend.keytab - copy from pgdog-test + if [ -f "${KEYTAB_DIR}/pgdog-test.keytab" ]; then + cp "${KEYTAB_DIR}/pgdog-test.keytab" "${KEYTAB_DIR}/backend.keytab" + echo " Created backend.keytab (copy of pgdog-test.keytab)" + fi + + # keytab1.keytab and keytab2.keytab - copies for generic testing + if [ -f "${KEYTAB_DIR}/principal1.keytab" ]; then + cp "${KEYTAB_DIR}/principal1.keytab" "${KEYTAB_DIR}/keytab1.keytab" + echo " Created keytab1.keytab" + elif [ -f "${KEYTAB_DIR}/server1.keytab" ]; then + cp "${KEYTAB_DIR}/server1.keytab" "${KEYTAB_DIR}/keytab1.keytab" + echo " Created keytab1.keytab (copy of server1.keytab)" + fi + + if [ -f "${KEYTAB_DIR}/principal2.keytab" ]; then + cp "${KEYTAB_DIR}/principal2.keytab" "${KEYTAB_DIR}/keytab2.keytab" + echo " Created keytab2.keytab" + elif [ -f "${KEYTAB_DIR}/server2.keytab" ]; then + cp "${KEYTAB_DIR}/server2.keytab" "${KEYTAB_DIR}/keytab2.keytab" + echo " Created keytab2.keytab (copy of server2.keytab)" + fi + + # Create test users configuration file + cat > "${SCRIPT_DIR}/test_users.toml" << EOF # Test users for GSSAPI authentication testing [[user]] name = "test" -principal = "test@PGDOG.LOCAL" +principal = "test@$REALM" strip_realm = true [[user]] name = "pgdog-test" -principal = "pgdog-test@PGDOG.LOCAL" +principal = "pgdog-test@$REALM" +strip_realm = true + +[[user]] +name = "server1" +principal = "server1@$REALM" +strip_realm = true + +[[user]] +name = "server2" +principal = "server2@$REALM" strip_realm = true [[user]] name = "principal1" -principal = "principal1@REALM" +principal = "principal1@$REALM" strip_realm = false [[user]] name = "principal2" -principal = "principal2@REALM" +principal = "principal2@$REALM" strip_realm = false EOF -echo "GSSAPI test setup complete!" -echo "Keytabs created in: ${KEYTAB_DIR}" -echo "Test users config: ${SCRIPT_DIR}/test_users.toml" -echo "" -echo "Note: Mock keytabs are not valid for real Kerberos authentication." -echo " They exist only to test file handling and error paths." \ No newline at end of file + echo "" + echo "=========================================" + echo "GSSAPI test setup complete!" + echo "=========================================" + echo "Keytabs created in: ${KEYTAB_DIR}" + echo "Test users config: ${SCRIPT_DIR}/test_users.toml" + echo "" + + # List created keytabs + echo "Created keytabs:" + ls -la "${KEYTAB_DIR}/"*.keytab 2>/dev/null || echo "No keytabs found" + + echo "" + echo "Note: These keytabs use real Kerberos credentials." + echo " They can be used for actual GSSAPI authentication testing." +} + +# Run main function +main "$@" \ No newline at end of file diff --git a/integration/setup.sh b/integration/setup.sh index a76d067ee..30c4ad624 100644 --- a/integration/setup.sh +++ b/integration/setup.sh @@ -62,14 +62,21 @@ for bin in toxiproxy-server toxiproxy-cli; do fi done -# Setup GSSAPI test keytabs if Kerberos is available or requested -if command -v kadmin.local &> /dev/null || [ -x "/opt/homebrew/opt/krb5/sbin/kadmin.local" ] || [ -n "$SETUP_GSSAPI" ]; then +# Setup GSSAPI test keytabs - always run in CI or when requested +if [ "$CI" = "true" ] || [ -n "$SETUP_GSSAPI" ] || command -v kadmin.local &> /dev/null || [ -x "/opt/homebrew/opt/krb5/sbin/kadmin.local" ]; then echo "Setting up GSSAPI test environment..." - if [ -x "${SCRIPT_DIR}/gssapi/setup_test_keytabs.sh" ]; then - bash "${SCRIPT_DIR}/gssapi/setup_test_keytabs.sh" || echo "GSSAPI setup failed (non-critical)" + # Make the script executable if it exists + if [ -f "${SCRIPT_DIR}/gssapi/setup_test_keytabs.sh" ]; then + chmod +x "${SCRIPT_DIR}/gssapi/setup_test_keytabs.sh" + bash "${SCRIPT_DIR}/gssapi/setup_test_keytabs.sh" || echo "GSSAPI setup completed (some errors expected)" + else + echo "Warning: GSSAPI setup script not found at ${SCRIPT_DIR}/gssapi/setup_test_keytabs.sh" + # Create the directory structure at least + mkdir -p "${SCRIPT_DIR}/gssapi/keytabs" + echo "Created ${SCRIPT_DIR}/gssapi/keytabs directory" fi else - echo "Skipping GSSAPI setup (Kerberos tools not found)" + echo "Skipping GSSAPI setup (not in CI and Kerberos tools not found)" fi popd diff --git a/pgdog/tests/backend_gssapi_test.rs b/pgdog/tests/backend_gssapi_test.rs index 1fb2cd655..1e8e4f56b 100644 --- a/pgdog/tests/backend_gssapi_test.rs +++ b/pgdog/tests/backend_gssapi_test.rs @@ -52,6 +52,13 @@ fn test_address_has_gssapi() { #[test] fn test_gssapi_context_for_backend() { + // Check if keytab exists + let keytab = test_keytab_path("backend.keytab"); + assert!( + keytab.exists(), + "Test keytab not found at {:?}. Please run: bash integration/gssapi/setup_test_keytabs.sh", + keytab + ); // Test creating a GSSAPI context for backend connection let keytab = test_keytab_path("backend.keytab"); let principal = "pgdog-test@PGDOG.LOCAL"; @@ -94,6 +101,13 @@ fn test_backend_gssapi_target_principal() { #[tokio::test] async fn test_ticket_manager_for_backend() { + // Check if keytabs exist + let keytab1 = test_keytab_path("server1.keytab"); + let keytab2 = test_keytab_path("server2.keytab"); + assert!( + keytab1.exists() && keytab2.exists(), + "Test keytabs not found. Please run: bash integration/gssapi/setup_test_keytabs.sh" + ); // Test that TicketManager can handle backend server tickets let manager = TicketManager::global(); @@ -130,6 +144,13 @@ async fn test_ticket_manager_for_backend() { /// Mock test for GSSAPI negotiation flow #[tokio::test] async fn test_backend_gssapi_negotiation_mock() { + // Check if keytab exists + let keytab = test_keytab_path("backend.keytab"); + assert!( + keytab.exists(), + "Test keytab not found at {:?}. Please run: bash integration/gssapi/setup_test_keytabs.sh", + keytab + ); // This test demonstrates the expected flow for backend GSSAPI // In a real scenario, this would connect to a PostgreSQL server with GSSAPI enabled diff --git a/pgdog/tests/gssapi_integration_test.rs b/pgdog/tests/gssapi_integration_test.rs index ac0a64c37..130fd5be5 100644 --- a/pgdog/tests/gssapi_integration_test.rs +++ b/pgdog/tests/gssapi_integration_test.rs @@ -28,6 +28,13 @@ fn test_keytab_path(filename: &str) -> PathBuf { async fn test_ticket_cache_acquires_credential() { // Use test keytab from integration directory let keytab_path = test_keytab_path("test.keytab"); + + // Fail with helpful message if keytab doesn't exist + assert!( + keytab_path.exists(), + "Test keytab not found at {:?}. Please run: bash integration/gssapi/setup_test_keytabs.sh", + keytab_path + ); let principal = "test@PGDOG.LOCAL"; let cache = TicketCache::new(principal, keytab_path); @@ -43,6 +50,13 @@ async fn test_ticket_cache_acquires_credential() { /// Test that TicketManager maintains per-server caches #[tokio::test] async fn test_ticket_manager_per_server_cache() { + // Check if keytabs exist + let keytab1 = test_keytab_path("server1.keytab"); + let keytab2 = test_keytab_path("server2.keytab"); + assert!( + keytab1.exists() && keytab2.exists(), + "Test keytabs not found. Please run: bash integration/gssapi/setup_test_keytabs.sh" + ); // This test MUST FAIL initially because TicketManager doesn't exist yet let manager = TicketManager::new(); @@ -78,6 +92,13 @@ async fn test_ticket_manager_per_server_cache() { /// Test GSSAPI frontend authentication flow #[tokio::test] async fn test_gssapi_frontend_authentication() { + // Check if keytab exists + let keytab = test_keytab_path("test.keytab"); + assert!( + keytab.exists(), + "Test keytab not found at {:?}. Please run: bash integration/gssapi/setup_test_keytabs.sh", + keytab + ); // This test demonstrates the async API use pgdog::auth::gssapi::{handle_gssapi_auth, GssapiServer}; use std::sync::Arc; @@ -107,6 +128,13 @@ async fn test_gssapi_frontend_authentication() { /// Test ticket refresh mechanism #[tokio::test] async fn test_ticket_refresh() { + // Check if keytab exists + let keytab = test_keytab_path("test.keytab"); + assert!( + keytab.exists(), + "Test keytab not found at {:?}. Please run: bash integration/gssapi/setup_test_keytabs.sh", + keytab + ); // This test demonstrates ticket refresh use std::time::Duration; @@ -137,6 +165,13 @@ async fn test_ticket_refresh() { /// Test GSSAPI context creation for backend connection #[test] fn test_backend_gssapi_context() { + // Check if keytab exists + let keytab = test_keytab_path("backend.keytab"); + assert!( + keytab.exists(), + "Test keytab not found at {:?}. Please run: bash integration/gssapi/setup_test_keytabs.sh", + keytab + ); // This test demonstrates GssapiContext API use pgdog::auth::gssapi::GssapiContext; @@ -178,6 +213,13 @@ fn test_missing_keytab_error() { /// Test cleanup of ticket caches on shutdown #[test] fn test_ticket_manager_cleanup() { + // Check if keytabs exist + let keytab1 = test_keytab_path("keytab1.keytab"); + let keytab2 = test_keytab_path("keytab2.keytab"); + assert!( + keytab1.exists() && keytab2.exists(), + "Test keytabs not found. Please run: bash integration/gssapi/setup_test_keytabs.sh" + ); // This test MUST FAIL initially let manager = TicketManager::new(); From e04b9487c56e50d6256241c49099eb735b02bbea Mon Sep 17 00:00:00 2001 From: Justin George Date: Fri, 19 Sep 2025 13:09:06 -0700 Subject: [PATCH 13/19] ci updates --- .github/workflows/ci.yml | 14 +- integration/gssapi/setup_ci_kdc.sh | 111 ----- integration/gssapi/setup_test_keytabs.sh | 597 ++++++++++++++--------- integration/gssapi/test_users.toml | 14 +- pgdog/src/auth/gssapi/ticket_manager.rs | 43 +- pgdog/tests/backend_gssapi_test.rs | 221 --------- pgdog/tests/gssapi_integration_test.rs | 245 ---------- pgdog/tests/gssapi_test.rs | 215 ++++++++ 8 files changed, 637 insertions(+), 823 deletions(-) delete mode 100644 integration/gssapi/setup_ci_kdc.sh delete mode 100644 pgdog/tests/backend_gssapi_test.rs delete mode 100644 pgdog/tests/gssapi_integration_test.rs create mode 100644 pgdog/tests/gssapi_test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba1aabd68..4acaef42e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,13 +58,15 @@ jobs: - name: Install Kerberos dependencies run: | sudo apt update - sudo DEBIAN_FRONTEND=noninteractive apt install -y libkrb5-dev krb5-user krb5-config libgssapi-krb5-2 + sudo DEBIAN_FRONTEND=noninteractive apt install -y libkrb5-dev krb5-user krb5-config libgssapi-krb5-2 krb5-kdc krb5-admin-server + - name: Setup Kerberos and create test keytabs + run: | + # The unified script handles everything: KDC setup, service start, keytab creation + chmod +x integration/gssapi/setup_test_keytabs.sh + CI=true sudo -E bash integration/gssapi/setup_test_keytabs.sh - # Setup KDC for testing (skip in act - systemctl doesn't work in containers) - if [ -f "integration/gssapi/setup_ci_kdc.sh" ] && [ ! -f /.dockerenv ]; then - chmod +x integration/gssapi/setup_ci_kdc.sh - sudo bash integration/gssapi/setup_ci_kdc.sh || echo "KDC setup failed, will use mock keytabs" - fi + # Make keytabs readable by the test user + sudo chown -R $USER:$USER integration/gssapi/keytabs/ - name: Setup PostgreSQL run: | sudo service postgresql start diff --git a/integration/gssapi/setup_ci_kdc.sh b/integration/gssapi/setup_ci_kdc.sh deleted file mode 100644 index a46c9d613..000000000 --- a/integration/gssapi/setup_ci_kdc.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/bin/bash - -# Setup a minimal KDC for CI testing -# This script is designed to run in GitHub Actions Ubuntu environment - -set -e - -echo "=========================================" -echo "Setting up Kerberos KDC for CI" -echo "=========================================" - -# Install Kerberos KDC packages -echo "Installing Kerberos KDC packages..." -sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \ - krb5-kdc \ - krb5-admin-server \ - krb5-config \ - krb5-user \ - libkrb5-dev - -# Define realm -REALM="PGDOG.LOCAL" -DOMAIN="pgdog.local" -KDC_PASSWORD="admin123" - -# Create krb5.conf -echo "Creating /etc/krb5.conf..." -sudo tee /etc/krb5.conf > /dev/null << EOF -[libdefaults] - default_realm = $REALM - dns_lookup_realm = false - dns_lookup_kdc = false - ticket_lifetime = 24h - renew_lifetime = 7d - forwardable = true - -[realms] - $REALM = { - kdc = localhost - admin_server = localhost - default_domain = $DOMAIN - } - -[domain_realm] - .$DOMAIN = $REALM - $DOMAIN = $REALM - localhost = $REALM - -[logging] - kdc = FILE:/var/log/krb5kdc.log - admin_server = FILE:/var/log/kadmin.log - default = FILE:/var/log/krb5lib.log -EOF - -# Create kdc.conf -echo "Creating /etc/krb5kdc/kdc.conf..." -sudo mkdir -p /etc/krb5kdc -sudo tee /etc/krb5kdc/kdc.conf > /dev/null << EOF -[kdcdefaults] - kdc_ports = 88 - kdc_tcp_ports = 88 - -[realms] - $REALM = { - acl_file = /etc/krb5kdc/kadm5.acl - database_name = /var/lib/krb5kdc/principal - key_stash_file = /etc/krb5kdc/.k5.$REALM - max_renewable_life = 7d 0h 0m 0s - max_life = 1d 0h 0m 0s - master_key_type = aes256-cts-hmac-sha1-96 - supported_enctypes = aes256-cts-hmac-sha1-96:normal aes128-cts-hmac-sha1-96:normal - default_principal_flags = +renewable, +forwardable - } - -[logging] - kdc = FILE:/var/log/krb5kdc.log - admin_server = FILE:/var/log/kadmin.log -EOF - -# Create ACL file -echo "Creating /etc/krb5kdc/kadm5.acl..." -sudo tee /etc/krb5kdc/kadm5.acl > /dev/null << EOF -*/admin@$REALM * -EOF - -# Initialize the database -echo "Initializing Kerberos database..." -sudo kdb5_util create -s -r $REALM -P $KDC_PASSWORD - -# Start the KDC -echo "Starting KDC..." -sudo systemctl start krb5-kdc || sudo krb5kdc -sudo systemctl start krb5-admin-server || true - -# Create admin principal -echo "Creating admin principal..." -echo -e "$KDC_PASSWORD\n$KDC_PASSWORD" | sudo kadmin.local -q "addprinc admin/admin" - -# Verify KDC is running -echo "Verifying KDC..." -if sudo kadmin.local -q "listprincs" | grep -q "krbtgt/$REALM@$REALM"; then - echo "✓ KDC is running and accessible" -else - echo "✗ KDC setup failed" - exit 1 -fi - -echo "=========================================" -echo "KDC setup complete!" -echo "Realm: $REALM" -echo "=========================================" \ No newline at end of file diff --git a/integration/gssapi/setup_test_keytabs.sh b/integration/gssapi/setup_test_keytabs.sh index fa3b4aff2..95f7cc0fd 100755 --- a/integration/gssapi/setup_test_keytabs.sh +++ b/integration/gssapi/setup_test_keytabs.sh @@ -1,58 +1,63 @@ #!/bin/bash -# Setup script for GSSAPI test keytabs -# This creates real Kerberos keytabs for testing +# Unified GSSAPI setup script - fully automated, headless, unattended +# Works on both Linux and macOS without manual intervention set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" KEYTAB_DIR="${SCRIPT_DIR}/keytabs" - -# Test password for all principals TEST_PASSWORD="password" +OS=$(uname -s) +REALM="PGDOG.LOCAL" +DOMAIN="pgdog.local" +KDC_PASSWORD="admin123" +SUCCESS_COUNT=0 +WARNING_COUNT=0 -# Check if we're in CI environment -if [ -z "$CI" ]; then - # GitHub Actions sets CI=true, but check for other indicators too - if [ -n "$GITHUB_ACTIONS" ] || [ -n "$JENKINS_HOME" ] || [ -n "$GITLAB_CI" ]; then - export CI=true - else - export CI=false - fi -fi +# Silent mode by default, verbose with DEBUG +[ -n "$DEBUG" ] && set -x -# Detect OS -OS=$(uname -s) +log() { + [ -n "$VERBOSE" ] && echo "$@" || true +} + +error() { + echo "ERROR: $@" >&2 +} -echo "Setting up GSSAPI test keytabs in ${KEYTAB_DIR}" - -# Create keytab directory -mkdir -p "${KEYTAB_DIR}" - -# Create mock keytabs for testing when KDC is not available -create_mock_keytabs() { - echo "Creating mock keytabs for testing..." - for keytab in test pgdog-test server1 server2 principal1 principal2 backend keytab1 keytab2; do - keytab_file="${KEYTAB_DIR}/${keytab}.keytab" - echo " Creating mock keytab: $keytab_file" - # Create a minimal valid keytab file structure - # Keytab format version (0x0502) - printf '\x05\x02' > "$keytab_file" - chmod 600 "$keytab_file" +# Find tools based on OS +find_tool() { + local tool=$1 + if [ "$OS" = "Darwin" ]; then + # macOS paths + for path in "/opt/homebrew/opt/krb5/sbin/$tool" "/opt/homebrew/opt/krb5/bin/$tool" "/usr/local/opt/krb5/sbin/$tool" "/usr/local/opt/krb5/bin/$tool"; do + [ -x "$path" ] && echo "$path" && return 0 + done + fi + # Standard paths + for path in "/usr/sbin/$tool" "/usr/bin/$tool" "/sbin/$tool" "/bin/$tool"; do + [ -x "$path" ] && echo "$path" && return 0 done - echo "Mock keytabs created for CI testing" + command -v "$tool" 2>/dev/null || echo "" } -# Find kadmin.local -find_kadmin_local() { - if [ -x "/opt/homebrew/opt/krb5/sbin/kadmin.local" ]; then - echo "/opt/homebrew/opt/krb5/sbin/kadmin.local" - elif [ -x "/usr/sbin/kadmin.local" ]; then - echo "/usr/sbin/kadmin.local" - elif command -v kadmin.local &> /dev/null; then - command -v kadmin.local - else - echo "" +# Auto-install dependencies if missing +ensure_dependencies() { + if [ "$OS" = "Darwin" ]; then + if ! command -v kinit &>/dev/null && ! [ -x "/opt/homebrew/opt/krb5/bin/kinit" ]; then + log "Installing Kerberos via Homebrew..." + brew install krb5 2>/dev/null || true + fi + elif [ "$OS" = "Linux" ]; then + if ! command -v kinit &>/dev/null; then + log "Installing Kerberos packages..." + if [ "$CI" = "true" ] || [ "$EUID" = "0" ]; then + export DEBIAN_FRONTEND=noninteractive + apt-get update &>/dev/null || true + apt-get install -y krb5-kdc krb5-admin-server krb5-user libkrb5-dev 2>/dev/null || true + fi + fi fi } @@ -60,261 +65,392 @@ find_kadmin_local() { setup_kerberos_env() { if [ "$OS" = "Darwin" ]; then # macOS with Homebrew - if [ -f "/opt/homebrew/etc/krb5.conf" ]; then - export KRB5_CONFIG=/opt/homebrew/etc/krb5.conf - export KRB5_KDC_PROFILE=/opt/homebrew/etc/krb5kdc/kdc.conf - echo "Using Homebrew Kerberos configuration" - return 0 - fi - elif [ "$OS" = "Linux" ]; then - # Linux - standard locations - if [ -f "/etc/krb5.conf" ]; then - export KRB5_CONFIG=/etc/krb5.conf - export KRB5_KDC_PROFILE=/etc/krb5kdc/kdc.conf - echo "Using system Kerberos configuration" + for dir in "/opt/homebrew/etc" "/usr/local/etc"; do + if [ -f "$dir/krb5.conf" ]; then + export KRB5_CONFIG="$dir/krb5.conf" + export KRB5_KDC_PROFILE="$dir/krb5kdc/kdc.conf" + return 0 + fi + done + # Create default location if missing + export KRB5_CONFIG="/opt/homebrew/etc/krb5.conf" + export KRB5_KDC_PROFILE="/opt/homebrew/etc/krb5kdc/kdc.conf" + else + # Linux standard locations + export KRB5_CONFIG="/etc/krb5.conf" + export KRB5_KDC_PROFILE="/etc/krb5kdc/kdc.conf" + fi +} + +# Create krb5.conf if missing +create_krb5_conf() { + local config_file="$1" + local config_dir=$(dirname "$config_file") + + [ -f "$config_file" ] && return 0 + + log "Creating Kerberos configuration at $config_file..." + + # Create directory if needed + if [ "$EUID" = "0" ] || [ "$CI" = "true" ]; then + mkdir -p "$config_dir" 2>/dev/null || sudo mkdir -p "$config_dir" + else + sudo mkdir -p "$config_dir" 2>/dev/null || mkdir -p "$config_dir" + fi + + # Write config + local config_content="[libdefaults] + default_realm = $REALM + dns_lookup_realm = false + dns_lookup_kdc = false + ticket_lifetime = 24h + renew_lifetime = 7d + forwardable = true + +[realms] + $REALM = { + kdc = localhost:88 + admin_server = localhost:749 + default_domain = $DOMAIN + } + +[domain_realm] + .$DOMAIN = $REALM + $DOMAIN = $REALM + localhost = $REALM + +[logging] + kdc = FILE:/var/log/krb5kdc.log + admin_server = FILE:/var/log/kadmin.log + default = FILE:/var/log/krb5lib.log" + + if [ "$EUID" = "0" ] || [ "$CI" = "true" ]; then + echo "$config_content" > "$config_file" 2>/dev/null || echo "$config_content" | sudo tee "$config_file" > /dev/null + else + echo "$config_content" | sudo tee "$config_file" > /dev/null 2>/dev/null || echo "$config_content" > "$config_file" + fi +} + +# Create kdc.conf if missing +create_kdc_conf() { + local kdc_conf="$1" + local kdc_dir=$(dirname "$kdc_conf") + + [ -f "$kdc_conf" ] && return 0 + + log "Creating KDC configuration at $kdc_conf..." + + # Create directory + if [ "$EUID" = "0" ] || [ "$CI" = "true" ]; then + mkdir -p "$kdc_dir" 2>/dev/null || sudo mkdir -p "$kdc_dir" + else + sudo mkdir -p "$kdc_dir" 2>/dev/null || mkdir -p "$kdc_dir" + fi + + # Write config + local kdc_content="[kdcdefaults] + kdc_ports = 88 + kdc_tcp_ports = 88 + +[realms] + $REALM = { + acl_file = $kdc_dir/kadm5.acl + database_name = /var/lib/krb5kdc/principal + key_stash_file = $kdc_dir/.k5.$REALM + max_renewable_life = 7d 0h 0m 0s + max_life = 1d 0h 0m 0s + master_key_type = aes256-cts-hmac-sha1-96 + supported_enctypes = aes256-cts-hmac-sha1-96:normal aes128-cts-hmac-sha1-96:normal + default_principal_flags = +renewable, +forwardable + } + +[logging] + kdc = FILE:/var/log/krb5kdc.log + admin_server = FILE:/var/log/kadmin.log" + + if [ "$EUID" = "0" ] || [ "$CI" = "true" ]; then + echo "$kdc_content" > "$kdc_conf" 2>/dev/null || echo "$kdc_content" | sudo tee "$kdc_conf" > /dev/null + else + echo "$kdc_content" | sudo tee "$kdc_conf" > /dev/null 2>/dev/null || echo "$kdc_content" > "$kdc_conf" + fi + + # Create ACL file + local acl_file="$kdc_dir/kadm5.acl" + local acl_content="*/admin@$REALM *" + + if [ "$EUID" = "0" ] || [ "$CI" = "true" ]; then + echo "$acl_content" > "$acl_file" 2>/dev/null || echo "$acl_content" | sudo tee "$acl_file" > /dev/null + else + echo "$acl_content" | sudo tee "$acl_file" > /dev/null 2>/dev/null || echo "$acl_content" > "$acl_file" + fi +} + +# Initialize KDC database if needed +initialize_kdc() { + local kdb5_util=$(find_tool "kdb5_util") + [ -z "$kdb5_util" ] && return 1 + + log "Initializing KDC database..." + + # Check if database exists + local kadmin_local=$(find_tool "kadmin.local") + if [ -n "$kadmin_local" ]; then + if $kadmin_local -q "listprincs" 2>/dev/null | grep -q "krbtgt/$REALM@$REALM"; then + log "KDC database already exists" return 0 fi fi - # No config found - echo "Warning: No Kerberos configuration found" - return 1 + # Create database + if [ "$EUID" = "0" ] || [ "$CI" = "true" ]; then + $kdb5_util create -s -r $REALM -P $KDC_PASSWORD 2>/dev/null || sudo $kdb5_util create -s -r $REALM -P $KDC_PASSWORD 2>/dev/null + else + sudo $kdb5_util create -s -r $REALM -P $KDC_PASSWORD 2>/dev/null || $kdb5_util create -s -r $REALM -P $KDC_PASSWORD + fi + + # Create admin principal + if [ -n "$kadmin_local" ]; then + $kadmin_local -q "addprinc -pw $KDC_PASSWORD admin/admin" 2>/dev/null || \ + sudo $kadmin_local -q "addprinc -pw $KDC_PASSWORD admin/admin" 2>/dev/null || true + fi + + return 0 } -# Check if KDC is running -check_kdc_running() { - local kadmin_local="$1" +# Start KDC services +start_kdc_services() { + local krb5kdc=$(find_tool "krb5kdc") - echo "Checking if KDC is accessible..." - if $kadmin_local -q "listprincs" &>/dev/null; then - echo "✓ KDC is accessible" - return 0 + if [ "$OS" = "Darwin" ]; then + # macOS: try brew services first + if command -v brew &>/dev/null; then + brew services restart krb5 2>/dev/null || true + fi + # Try direct launch if brew services failed + if [ -n "$krb5kdc" ] && ! pgrep -f krb5kdc >/dev/null; then + $krb5kdc 2>/dev/null & + sleep 1 + fi else - echo "✗ KDC is not accessible" - return 1 + # Linux: try systemctl first + if command -v systemctl &>/dev/null; then + sudo systemctl restart krb5-kdc 2>/dev/null || true + sudo systemctl restart krb5-admin-server 2>/dev/null || true + elif command -v service &>/dev/null; then + sudo service krb5-kdc restart 2>/dev/null || true + sudo service krb5-admin-server restart 2>/dev/null || true + fi + # Try direct launch if services failed + if [ -n "$krb5kdc" ] && ! pgrep -f krb5kdc >/dev/null; then + if [ "$EUID" = "0" ] || [ "$CI" = "true" ]; then + $krb5kdc 2>/dev/null & + else + sudo $krb5kdc 2>/dev/null & + fi + sleep 1 + fi fi } -# Get the realm from krb5.conf -get_realm() { - if [ -n "$KRB5_CONFIG" ] && [ -f "$KRB5_CONFIG" ]; then - grep "default_realm" "$KRB5_CONFIG" | awk '{print $3}' | head -1 - else - echo "PGDOG.LOCAL" # Default fallback +# Check if KDC is running +check_kdc_running() { + local kadmin_local="$1" + [ -z "$kadmin_local" ] && return 1 + + if $kadmin_local -q "listprincs" 2>/dev/null | grep -q "krbtgt"; then + return 0 fi + return 1 } -# Create a test principal +# Create a principal with recovery create_principal() { local kadmin_local="$1" local principal="$2" local password="$3" # Check if principal exists - if $kadmin_local -q "getprinc $principal" 2>&1 | grep -q "does not exist"; then - # Principal doesn't exist, create it - echo " Creating principal $principal..." - if $kadmin_local -q "addprinc -pw $password $principal" 2>&1 | grep -q "created"; then - echo " ✓ Created principal $principal" - else - echo " ✗ Failed to create principal $principal" - return 1 - fi - else - echo " Principal $principal already exists" + if ! $kadmin_local -q "getprinc $principal" 2>&1 | grep -q "does not exist"; then + log " Principal $principal already exists" + return 0 fi - return 0 + + # Create principal + log " Creating principal $principal..." + if $kadmin_local -q "addprinc -pw $password $principal" 2>&1 | grep -q "created\|added"; then + log " ✓ Created principal $principal" + return 0 + fi + + # Retry with sudo if needed + if sudo $kadmin_local -q "addprinc -pw $password $principal" 2>&1 | grep -q "created\|added"; then + log " ✓ Created principal $principal" + return 0 + fi + + log " Warning: Could not create principal $principal" + return 1 } -# Export principal to keytab +# Export to keytab with recovery export_to_keytab() { local kadmin_local="$1" local principal="$2" local keytab_file="$3" - echo " Exporting $principal to $keytab_file..." + log " Exporting $principal to keytab..." + + rm -f "$keytab_file" 2>/dev/null || true - # Remove old keytab if exists - rm -f "$keytab_file" + # Try export + local output=$($kadmin_local -q "ktadd -k $keytab_file $principal" 2>&1 || sudo $kadmin_local -q "ktadd -k $keytab_file $principal" 2>&1) - # Export to keytab (this changes the key) - local output=$($kadmin_local -q "ktadd -k $keytab_file $principal" 2>&1) if echo "$output" | grep -q "added to keytab"; then - echo " ✓ Exported to keytab" - chmod 600 "$keytab_file" + chmod 600 "$keytab_file" 2>/dev/null || true + log " ✓ Exported to keytab" return 0 - else - echo " ✗ Failed to export to keytab" - echo " Error: $output" - return 1 fi + + log " Warning: Could not export $principal to keytab" + return 1 } -# Verify keytab works +# Verify keytab verify_keytab() { local keytab_file="$1" local principal="$2" - echo " Verifying keytab for $principal..." + local kinit=$(find_tool "kinit") + local kdestroy=$(find_tool "kdestroy") + + [ -z "$kinit" ] && return 1 + + log " Verifying keytab for $principal..." - # Try to authenticate with keytab - if KRB5CCNAME=/tmp/krb5cc_test_$$ kinit -kt "$keytab_file" "$principal" &>/dev/null; then - echo " ✓ Keytab verification successful" - # Clean up test ticket - KRB5CCNAME=/tmp/krb5cc_test_$$ kdestroy &>/dev/null || true - rm -f /tmp/krb5cc_test_$$ + if KRB5_CONFIG="$KRB5_CONFIG" KRB5CCNAME=/tmp/krb5cc_test_$$ $kinit -kt "$keytab_file" "$principal" 2>/dev/null; then + log " ✓ Keytab verification successful" + [ -n "$kdestroy" ] && KRB5_CONFIG="$KRB5_CONFIG" KRB5CCNAME=/tmp/krb5cc_test_$$ $kdestroy 2>/dev/null || true + rm -f /tmp/krb5cc_test_$$ 2>/dev/null || true return 0 - else - echo " ✗ Keytab verification failed" - return 1 fi + + log " Warning: Keytab verification failed for $principal" + return 1 } -# Main setup +# Main setup function main() { - echo "=========================================" - echo "GSSAPI Test Keytab Setup" - echo "=========================================" - - # Setup environment - if ! setup_kerberos_env; then - echo "ERROR: Cannot find Kerberos configuration" - echo "For macOS: Install with 'brew install krb5'" - echo "For Linux: Install krb5-kdc and krb5-admin-server" - exit 1 - fi + log "=========================================" + log "GSSAPI Automated Setup" + log "=========================================" + + # Step 1: Install dependencies if missing + ensure_dependencies + + # Step 2: Setup environment + setup_kerberos_env + + # Step 3: Create configs if missing + create_krb5_conf "$KRB5_CONFIG" + create_kdc_conf "$KRB5_KDC_PROFILE" - # Find kadmin.local - KADMIN_LOCAL=$(find_kadmin_local) + # Step 4: Find required tools + KADMIN_LOCAL=$(find_tool "kadmin.local") if [ -z "$KADMIN_LOCAL" ]; then - echo "ERROR: kadmin.local not found" - echo "This script requires local access to the KDC" + error "kadmin.local not found - cannot manage Kerberos principals" exit 1 fi - echo "Using kadmin.local: $KADMIN_LOCAL" - - # Check KDC is running - if ! check_kdc_running "$KADMIN_LOCAL"; then - echo "ERROR: Cannot connect to KDC" - echo "Please ensure the KDC is running and accessible" - - if [ "$CI" = "true" ]; then - echo "In CI environment - attempting to setup minimal KDC..." - - # Try to run the CI KDC setup script if it exists - CI_KDC_SCRIPT="${SCRIPT_DIR}/setup_ci_kdc.sh" - if [ -f "$CI_KDC_SCRIPT" ]; then - echo "Running CI KDC setup script..." - # CI KDC setup requires sudo - if sudo bash "$CI_KDC_SCRIPT"; then - echo "CI KDC setup successful, retrying..." - # Re-setup environment and retry - setup_kerberos_env - KADMIN_LOCAL=$(find_kadmin_local) - if [ -n "$KADMIN_LOCAL" ] && check_kdc_running "$KADMIN_LOCAL"; then - echo "KDC is now accessible, continuing with keytab creation..." - # Don't exit, continue with the main flow - else - echo "KDC still not accessible after setup" - echo "Creating mock keytabs as fallback..." - create_mock_keytabs - exit 0 - fi - else - echo "CI KDC setup failed" - echo "Creating mock keytabs as fallback..." - create_mock_keytabs - exit 0 + + # Step 5: Initialize KDC if needed + initialize_kdc + + # Step 6: Start KDC services + start_kdc_services + sleep 2 + + # Step 7: Verify KDC is running, retry if needed + if [ -n "$KADMIN_LOCAL" ]; then + if ! check_kdc_running "$KADMIN_LOCAL"; then + log "KDC not responding, attempting restart..." + start_kdc_services + sleep 3 + + if ! check_kdc_running "$KADMIN_LOCAL"; then + log "KDC still not responding, reinitializing..." + initialize_kdc + start_kdc_services + sleep 3 + + if ! check_kdc_running "$KADMIN_LOCAL"; then + error "KDC failed to start after multiple attempts" + exit 1 fi - else - echo "No CI KDC setup script found" - echo "Creating mock keytabs as fallback..." - create_mock_keytabs - exit 0 fi - else - echo "Not in CI environment. Please ensure KDC is running locally." - echo "For macOS: brew services start krb5" - echo "For Linux: sudo systemctl start krb5-kdc" - exit 1 fi fi - # Get realm - REALM=$(get_realm) - echo "Using Kerberos realm: $REALM" + # Step 8: Create keytab directory + mkdir -p "${KEYTAB_DIR}" - echo "" - echo "Creating test principals and keytabs..." - echo "-----------------------------------------" - - # Define principals to create - # Using REALM for consistency with existing setup + # Step 9: Define principals declare -a principals=( "test@$REALM" "pgdog-test@$REALM" "server1@$REALM" "server2@$REALM" + "principal1@$REALM" + "principal2@$REALM" ) - # Also create generic test principals - principals+=("principal1@$REALM" "principal2@$REALM") - - # Create principals and keytabs - for principal in "${principals[@]}"; do - # Extract base name for keytab file - base_name=$(echo "$principal" | cut -d'@' -f1) - keytab_file="${KEYTAB_DIR}/${base_name}.keytab" + # Step 10: Create principals and keytabs + if [ -n "$KADMIN_LOCAL" ]; then + for principal in "${principals[@]}"; do + base_name=$(echo "$principal" | cut -d'@' -f1) + keytab_file="${KEYTAB_DIR}/${base_name}.keytab" - echo "" - echo "Processing $principal:" - - # Create principal - if ! create_principal "$KADMIN_LOCAL" "$principal" "$TEST_PASSWORD"; then - echo "WARNING: Failed to create principal $principal" - continue - fi + log "" + log "Processing $principal:" - # Export to keytab - if ! export_to_keytab "$KADMIN_LOCAL" "$principal" "$keytab_file"; then - echo "WARNING: Failed to export $principal to keytab" - continue - fi - - # Verify keytab - if ! verify_keytab "$keytab_file" "$principal"; then - echo "WARNING: Keytab verification failed for $principal" - fi - done + if create_principal "$KADMIN_LOCAL" "$principal" "$TEST_PASSWORD"; then + if export_to_keytab "$KADMIN_LOCAL" "$principal" "$keytab_file"; then + SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) + # Verification is optional - tests will verify keytabs work + verify_keytab "$keytab_file" "$principal" || true + else + WARNING_COUNT=$((WARNING_COUNT + 1)) + fi + else + WARNING_COUNT=$((WARNING_COUNT + 1)) + fi + done + fi - # Create additional keytabs that tests expect - echo "" - echo "Creating additional test keytabs..." + # Step 11: Create additional keytabs + log "" + log "Creating additional test keytabs..." - # backend.keytab - copy from pgdog-test if [ -f "${KEYTAB_DIR}/pgdog-test.keytab" ]; then cp "${KEYTAB_DIR}/pgdog-test.keytab" "${KEYTAB_DIR}/backend.keytab" - echo " Created backend.keytab (copy of pgdog-test.keytab)" + log " Created backend.keytab" fi - # keytab1.keytab and keytab2.keytab - copies for generic testing if [ -f "${KEYTAB_DIR}/principal1.keytab" ]; then cp "${KEYTAB_DIR}/principal1.keytab" "${KEYTAB_DIR}/keytab1.keytab" - echo " Created keytab1.keytab" + log " Created keytab1.keytab" elif [ -f "${KEYTAB_DIR}/server1.keytab" ]; then cp "${KEYTAB_DIR}/server1.keytab" "${KEYTAB_DIR}/keytab1.keytab" - echo " Created keytab1.keytab (copy of server1.keytab)" + log " Created keytab1.keytab" fi if [ -f "${KEYTAB_DIR}/principal2.keytab" ]; then cp "${KEYTAB_DIR}/principal2.keytab" "${KEYTAB_DIR}/keytab2.keytab" - echo " Created keytab2.keytab" + log " Created keytab2.keytab" elif [ -f "${KEYTAB_DIR}/server2.keytab" ]; then cp "${KEYTAB_DIR}/server2.keytab" "${KEYTAB_DIR}/keytab2.keytab" - echo " Created keytab2.keytab (copy of server2.keytab)" + log " Created keytab2.keytab" fi - # Create test users configuration file + # Step 12: Create test users configuration cat > "${SCRIPT_DIR}/test_users.toml" << EOF # Test users for GSSAPI authentication testing @@ -349,22 +485,23 @@ principal = "principal2@$REALM" strip_realm = false EOF - echo "" - echo "=========================================" - echo "GSSAPI test setup complete!" - echo "=========================================" - echo "Keytabs created in: ${KEYTAB_DIR}" - echo "Test users config: ${SCRIPT_DIR}/test_users.toml" - echo "" - - # List created keytabs - echo "Created keytabs:" - ls -la "${KEYTAB_DIR}/"*.keytab 2>/dev/null || echo "No keytabs found" - - echo "" - echo "Note: These keytabs use real Kerberos credentials." - echo " They can be used for actual GSSAPI authentication testing." + # Count total keytabs created + KEYTAB_COUNT=$(ls "${KEYTAB_DIR}/"*.keytab 2>/dev/null | wc -l) + + # Output final status + if [ "$KEYTAB_COUNT" -gt 0 ]; then + if [ "$WARNING_COUNT" -eq 0 ]; then + echo "✓ GSSAPI setup successful: $KEYTAB_COUNT keytabs created in ${KEYTAB_DIR}" + exit 0 + else + echo "⚠ GSSAPI setup completed with warnings: $KEYTAB_COUNT keytabs created, $WARNING_COUNT operations failed" + exit 0 + fi + else + error "GSSAPI setup failed: No keytabs created" + exit 1 + fi } -# Run main function +# Run main main "$@" \ No newline at end of file diff --git a/integration/gssapi/test_users.toml b/integration/gssapi/test_users.toml index c943577ef..3543de21b 100644 --- a/integration/gssapi/test_users.toml +++ b/integration/gssapi/test_users.toml @@ -10,12 +10,22 @@ name = "pgdog-test" principal = "pgdog-test@PGDOG.LOCAL" strip_realm = true +[[user]] +name = "server1" +principal = "server1@PGDOG.LOCAL" +strip_realm = true + +[[user]] +name = "server2" +principal = "server2@PGDOG.LOCAL" +strip_realm = true + [[user]] name = "principal1" -principal = "principal1@REALM" +principal = "principal1@PGDOG.LOCAL" strip_realm = false [[user]] name = "principal2" -principal = "principal2@REALM" +principal = "principal2@PGDOG.LOCAL" strip_realm = false diff --git a/pgdog/src/auth/gssapi/ticket_manager.rs b/pgdog/src/auth/gssapi/ticket_manager.rs index 098f027f3..73a938297 100644 --- a/pgdog/src/auth/gssapi/ticket_manager.rs +++ b/pgdog/src/auth/gssapi/ticket_manager.rs @@ -36,6 +36,29 @@ impl TicketManager { INSTANCE.clone() } + /// Get the appropriate krb5.conf path for the current system + fn get_krb5_config() -> Option { + // First check if KRB5_CONFIG environment variable is set + if let Ok(config) = std::env::var("KRB5_CONFIG") { + return Some(config); + } + + // Check common locations + let paths = vec![ + "/etc/krb5.conf", // Linux standard location + "/opt/homebrew/etc/krb5.conf", // macOS Homebrew location + "/usr/local/etc/krb5.conf", // Alternative location + ]; + + for path in paths { + if std::path::Path::new(path).exists() { + return Some(path.to_string()); + } + } + + None + } + /// Get or acquire a ticket for a server /// Returns Ok(()) when the credential cache is ready to use #[cfg(feature = "gssapi")] @@ -63,17 +86,21 @@ impl TicketManager { std::env::set_var("KRB5CCNAME", &cache_path); // Use kinit to get a ticket from the keytab into the unique cache - let output = tokio::process::Command::new("kinit") + let mut command = tokio::process::Command::new("kinit"); + command .arg("-kt") .arg(&keytab_path) .arg(&principal) - .env("KRB5CCNAME", &cache_path) - .env("KRB5_CONFIG", "/opt/homebrew/etc/krb5.conf") - .output() - .await - .map_err(|e| { - super::error::GssapiError::LibGssapi(format!("failed to run kinit: {}", e)) - })?; + .env("KRB5CCNAME", &cache_path); + + // Set KRB5_CONFIG if we can find it + if let Some(krb5_config) = Self::get_krb5_config() { + command.env("KRB5_CONFIG", krb5_config); + } + + let output = command.output().await.map_err(|e| { + super::error::GssapiError::LibGssapi(format!("failed to run kinit: {}", e)) + })?; if !output.status.success() { return Err(super::error::GssapiError::CredentialAcquisitionFailed( diff --git a/pgdog/tests/backend_gssapi_test.rs b/pgdog/tests/backend_gssapi_test.rs deleted file mode 100644 index 1e8e4f56b..000000000 --- a/pgdog/tests/backend_gssapi_test.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! Backend GSSAPI authentication tests -//! -//! These tests verify that PGDog can authenticate to PostgreSQL servers using GSSAPI/Kerberos. - -#![cfg(feature = "gssapi")] - -use pgdog::auth::gssapi::{GssapiContext, TicketManager}; -use pgdog::backend::pool::Address; -use std::path::PathBuf; - -/// Get the path to the test keytabs directory -fn test_keytab_path(filename: &str) -> PathBuf { - // Use the CARGO_MANIFEST_DIR to find the project root, or fallback to relative path - let base_path = std::env::var("CARGO_MANIFEST_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(".")); - base_path - .parent() // Go up from pgdog/ to project root - .unwrap_or(&base_path) - .join("integration") - .join("gssapi") - .join("keytabs") - .join(filename) -} - -#[test] -fn test_address_has_gssapi() { - // Test that Address correctly detects GSSAPI configuration - let mut addr = Address { - host: "test.example.com".to_string(), - port: 5432, - database_name: "testdb".to_string(), - user: "testuser".to_string(), - password: "testpass".to_string(), - gssapi_keytab: None, - gssapi_principal: None, - gssapi_target_principal: None, - }; - - assert!(!addr.has_gssapi()); - - addr.gssapi_keytab = Some( - test_keytab_path("test.keytab") - .to_string_lossy() - .to_string(), - ); - assert!(!addr.has_gssapi()); // Still need principal - - addr.gssapi_principal = Some("test@REALM".to_string()); - assert!(addr.has_gssapi()); // Now both are set -} - -#[test] -fn test_gssapi_context_for_backend() { - // Check if keytab exists - let keytab = test_keytab_path("backend.keytab"); - assert!( - keytab.exists(), - "Test keytab not found at {:?}. Please run: bash integration/gssapi/setup_test_keytabs.sh", - keytab - ); - // Test creating a GSSAPI context for backend connection - let keytab = test_keytab_path("backend.keytab"); - let principal = "pgdog-test@PGDOG.LOCAL"; - let target = "postgres/db.example.com"; - - let context = GssapiContext::new_initiator(keytab, principal, target); - - #[cfg(feature = "gssapi")] - { - // With real GSSAPI, this will fail without a keytab - assert!(context.is_err()); - } - - #[cfg(not(feature = "gssapi"))] - { - // Mock version should succeed in creation - assert!(context.is_ok()); - let mut ctx = context.unwrap(); - assert_eq!(ctx.target_principal(), target); - assert!(!ctx.is_complete()); - - // But operations will fail - let result = ctx.initiate(); - assert!(result.is_err()); - } -} - -#[test] -fn test_backend_gssapi_target_principal() { - // Test that we construct the correct target principal - let host = "db.example.com"; - let target = format!("postgres/{}", host); - assert_eq!(target, "postgres/db.example.com"); - - // Test with IP address - let host = "192.168.1.1"; - let target = format!("postgres/{}", host); - assert_eq!(target, "postgres/192.168.1.1"); -} - -#[tokio::test] -async fn test_ticket_manager_for_backend() { - // Check if keytabs exist - let keytab1 = test_keytab_path("server1.keytab"); - let keytab2 = test_keytab_path("server2.keytab"); - assert!( - keytab1.exists() && keytab2.exists(), - "Test keytabs not found. Please run: bash integration/gssapi/setup_test_keytabs.sh" - ); - // Test that TicketManager can handle backend server tickets - let manager = TicketManager::global(); - - // These will fail without real keytabs, but test the API - let ticket1 = manager - .get_ticket( - "server1:5432", - test_keytab_path("server1.keytab"), - "server1@PGDOG.LOCAL", - ) - .await; - - let ticket2 = manager - .get_ticket( - "server2:5432", - test_keytab_path("server2.keytab"), - "server2@PGDOG.LOCAL", - ) - .await; - - // Both should fail without real keytabs - assert!(ticket1.is_err()); - assert!(ticket2.is_err()); - - // But the caches should be separate - let cache1 = manager.get_cache("server1:5432"); - let cache2 = manager.get_cache("server2:5432"); - - // Caches won't exist because tickets failed to acquire - assert!(cache1.is_none()); - assert!(cache2.is_none()); -} - -/// Mock test for GSSAPI negotiation flow -#[tokio::test] -async fn test_backend_gssapi_negotiation_mock() { - // Check if keytab exists - let keytab = test_keytab_path("backend.keytab"); - assert!( - keytab.exists(), - "Test keytab not found at {:?}. Please run: bash integration/gssapi/setup_test_keytabs.sh", - keytab - ); - // This test demonstrates the expected flow for backend GSSAPI - // In a real scenario, this would connect to a PostgreSQL server with GSSAPI enabled - - let keytab = test_keytab_path("backend.keytab"); - let principal = "pgdog-test@PGDOG.LOCAL"; - let target = "postgres/localhost"; - - let _context = GssapiContext::new_initiator(keytab, principal, target); - - #[cfg(not(feature = "gssapi"))] - { - if let Ok(mut ctx) = _context { - // Mock flow - assert!(!ctx.is_complete()); - - // Initial token would be sent to server - let initial = ctx.initiate(); - assert!(initial.is_err()); // Mock fails - - // In real flow, we'd receive server token and process it - // let server_token = receive_from_postgres(); - // let response = ctx.process_response(&server_token); - // send_to_postgres(response); - // ... repeat until ctx.is_complete() - } - } -} - -/// Test error handling when server requires GSSAPI but we don't have it configured -#[test] -fn test_backend_gssapi_not_configured() { - let addr = Address { - host: "test.example.com".to_string(), - port: 5432, - database_name: "testdb".to_string(), - user: "testuser".to_string(), - password: "testpass".to_string(), - gssapi_keytab: None, // Not configured - gssapi_principal: None, - gssapi_target_principal: None, - }; - - // When server requests GSSAPI and we don't have it, we should get an error - assert!(!addr.has_gssapi()); - // In the real connect() function, this would return an appropriate error -} - -/// Test that GSSAPI configuration is properly read from config -#[test] -fn test_backend_gssapi_from_config() { - use pgdog::config::GssapiConfig; - - let gssapi = GssapiConfig { - enabled: true, - server_keytab: Some(test_keytab_path("test.keytab")), - server_principal: Some("pgdog-test@PGDOG.LOCAL".to_string()), - default_backend_keytab: Some(test_keytab_path("backend.keytab")), - default_backend_principal: Some("pgdog-backend@PGDOG.LOCAL".to_string()), - default_backend_target_principal: Some("postgres/test@PGDOG.LOCAL".to_string()), - strip_realm: true, - ticket_refresh_interval: 14400, - fallback_enabled: false, - require_encryption: false, - }; - - assert!(gssapi.is_configured()); - assert!(gssapi.has_backend_config()); -} diff --git a/pgdog/tests/gssapi_integration_test.rs b/pgdog/tests/gssapi_integration_test.rs deleted file mode 100644 index 130fd5be5..000000000 --- a/pgdog/tests/gssapi_integration_test.rs +++ /dev/null @@ -1,245 +0,0 @@ -//! GSSAPI authentication integration tests -//! -//! These tests are designed to fail initially as we implement the GSSAPI functionality. -//! They demonstrate the expected API and behavior for GSSAPI authentication. - -#![cfg(feature = "gssapi")] - -use pgdog::auth::gssapi::{TicketCache, TicketManager}; -use std::path::PathBuf; - -/// Get the path to the test keytabs directory -fn test_keytab_path(filename: &str) -> PathBuf { - // Use the CARGO_MANIFEST_DIR to find the project root, or fallback to relative path - let base_path = std::env::var("CARGO_MANIFEST_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(".")); - base_path - .parent() // Go up from pgdog/ to project root - .unwrap_or(&base_path) - .join("integration") - .join("gssapi") - .join("keytabs") - .join(filename) -} - -/// Test that TicketCache can acquire a credential from a keytab -#[tokio::test] -async fn test_ticket_cache_acquires_credential() { - // Use test keytab from integration directory - let keytab_path = test_keytab_path("test.keytab"); - - // Fail with helpful message if keytab doesn't exist - assert!( - keytab_path.exists(), - "Test keytab not found at {:?}. Please run: bash integration/gssapi/setup_test_keytabs.sh", - keytab_path - ); - let principal = "test@PGDOG.LOCAL"; - - let cache = TicketCache::new(principal, keytab_path); - let ticket = cache.acquire_ticket(); - - assert!( - ticket.is_ok(), - "Failed to acquire ticket: {:?}", - ticket.err() - ); -} - -/// Test that TicketManager maintains per-server caches -#[tokio::test] -async fn test_ticket_manager_per_server_cache() { - // Check if keytabs exist - let keytab1 = test_keytab_path("server1.keytab"); - let keytab2 = test_keytab_path("server2.keytab"); - assert!( - keytab1.exists() && keytab2.exists(), - "Test keytabs not found. Please run: bash integration/gssapi/setup_test_keytabs.sh" - ); - // This test MUST FAIL initially because TicketManager doesn't exist yet - let manager = TicketManager::new(); - - // Get ticket for server1 - let ticket1 = manager - .get_ticket( - "server1:5432", - test_keytab_path("server1.keytab"), - "server1@PGDOG.LOCAL", - ) - .await; - - // Get ticket for server2 - let ticket2 = manager - .get_ticket( - "server2:5432", - test_keytab_path("server2.keytab"), - "server2@PGDOG.LOCAL", - ) - .await; - - assert!(ticket1.is_ok(), "Failed to get ticket for server1"); - assert!(ticket2.is_ok(), "Failed to get ticket for server2"); - - // Verify they are different tickets - let cache1 = manager.get_cache("server1:5432"); - let cache2 = manager.get_cache("server2:5432"); - assert!(cache1.is_some()); - assert!(cache2.is_some()); - assert_ne!(cache1.unwrap().principal(), cache2.unwrap().principal()); -} - -/// Test GSSAPI frontend authentication flow -#[tokio::test] -async fn test_gssapi_frontend_authentication() { - // Check if keytab exists - let keytab = test_keytab_path("test.keytab"); - assert!( - keytab.exists(), - "Test keytab not found at {:?}. Please run: bash integration/gssapi/setup_test_keytabs.sh", - keytab - ); - // This test demonstrates the async API - use pgdog::auth::gssapi::{handle_gssapi_auth, GssapiServer}; - use std::sync::Arc; - use tokio::sync::Mutex; - - // This will fail without a real keytab - let server = GssapiServer::new_acceptor(test_keytab_path("test.keytab"), None); - if server.is_err() { - // Expected to fail without real keytab - return; - } - - let server = Arc::new(Mutex::new(server.unwrap())); - let client_token = vec![0x60, 0x81]; // Mock GSSAPI token header - let result = handle_gssapi_auth(server, client_token).await; - - assert!( - result.is_ok(), - "Failed to handle GSSAPI auth: {:?}", - result.err() - ); - - let response = result.unwrap(); - assert!(response.is_complete || response.token.is_some()); -} - -/// Test ticket refresh mechanism -#[tokio::test] -async fn test_ticket_refresh() { - // Check if keytab exists - let keytab = test_keytab_path("test.keytab"); - assert!( - keytab.exists(), - "Test keytab not found at {:?}. Please run: bash integration/gssapi/setup_test_keytabs.sh", - keytab - ); - // This test demonstrates ticket refresh - use std::time::Duration; - - let manager = TicketManager::new(); - manager.set_refresh_interval(Duration::from_secs(1)); // Short interval for testing - - let ticket = manager - .get_ticket( - "server:5432", - test_keytab_path("test.keytab"), - "test@PGDOG.LOCAL", - ) - .await; - assert!(ticket.is_ok()); - - let initial_refresh_time = manager.get_last_refresh("server:5432"); - - // Wait for refresh - std::thread::sleep(Duration::from_secs(2)); - - let new_refresh_time = manager.get_last_refresh("server:5432"); - assert!( - new_refresh_time > initial_refresh_time, - "Ticket was not refreshed" - ); -} - -/// Test GSSAPI context creation for backend connection -#[test] -fn test_backend_gssapi_context() { - // Check if keytab exists - let keytab = test_keytab_path("backend.keytab"); - assert!( - keytab.exists(), - "Test keytab not found at {:?}. Please run: bash integration/gssapi/setup_test_keytabs.sh", - keytab - ); - // This test demonstrates GssapiContext API - use pgdog::auth::gssapi::GssapiContext; - - let keytab = test_keytab_path("backend.keytab"); - let principal = "pgdog-test@PGDOG.LOCAL"; - let target = "postgres/db.example.com@PGDOG.LOCAL"; - - let context = GssapiContext::new_initiator(keytab, principal, target); - - #[cfg(feature = "gssapi")] - { - // With real GSSAPI, this will fail without keytab - assert!(context.is_err()); - } - - #[cfg(not(feature = "gssapi"))] - { - // Mock version should succeed in creation - assert!(context.is_ok()); - let mut ctx = context.unwrap(); - // But operations will fail - let initial_token = ctx.initiate(); - assert!(initial_token.is_err()); - } -} - -/// Test error handling for missing keytab -#[test] -fn test_missing_keytab_error() { - // Test with a truly non-existent keytab - let cache = TicketCache::new("test@PGDOG.LOCAL", PathBuf::from("/nonexistent/keytab")); - let ticket = cache.acquire_ticket(); - - assert!(ticket.is_err()); - let err = ticket.unwrap_err(); - assert!(err.to_string().contains("keytab")); -} - -/// Test cleanup of ticket caches on shutdown -#[test] -fn test_ticket_manager_cleanup() { - // Check if keytabs exist - let keytab1 = test_keytab_path("keytab1.keytab"); - let keytab2 = test_keytab_path("keytab2.keytab"); - assert!( - keytab1.exists() && keytab2.exists(), - "Test keytabs not found. Please run: bash integration/gssapi/setup_test_keytabs.sh" - ); - // This test MUST FAIL initially - let manager = TicketManager::new(); - - // Add some tickets - let _ = manager.get_ticket( - "server1:5432", - test_keytab_path("keytab1.keytab"), - "principal1@REALM", - ); - let _ = manager.get_ticket( - "server2:5432", - test_keytab_path("keytab2.keytab"), - "principal2@REALM", - ); - - assert_eq!(manager.cache_count(), 2); - - // Cleanup - manager.shutdown(); - - assert_eq!(manager.cache_count(), 0); - assert!(manager.get_cache("server1:5432").is_none()); -} diff --git a/pgdog/tests/gssapi_test.rs b/pgdog/tests/gssapi_test.rs new file mode 100644 index 000000000..1ffb4b6bf --- /dev/null +++ b/pgdog/tests/gssapi_test.rs @@ -0,0 +1,215 @@ +//! GSSAPI authentication tests + +#![cfg(feature = "gssapi")] + +use pgdog::auth::gssapi::{ + handle_gssapi_auth, GssapiContext, GssapiServer, TicketCache, TicketManager, +}; +use pgdog::backend::pool::Address; +use pgdog::config::GssapiConfig; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; + +fn test_keytab_path(filename: &str) -> PathBuf { + let base_path = std::env::var("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")); + base_path + .parent() + .unwrap_or(&base_path) + .join("integration") + .join("gssapi") + .join("keytabs") + .join(filename) +} + +#[test] +fn test_address_has_gssapi() { + let mut addr = Address { + host: "test.example.com".to_string(), + port: 5432, + database_name: "testdb".to_string(), + user: "testuser".to_string(), + password: "testpass".to_string(), + gssapi_keytab: None, + gssapi_principal: None, + gssapi_target_principal: None, + }; + + assert!(!addr.has_gssapi()); + + addr.gssapi_keytab = Some( + test_keytab_path("test.keytab") + .to_string_lossy() + .to_string(), + ); + assert!(!addr.has_gssapi()); + + addr.gssapi_principal = Some("test@REALM".to_string()); + assert!(addr.has_gssapi()); +} + +#[test] +fn test_gssapi_context_creation() { + let keytab = test_keytab_path("backend.keytab"); + assert!(keytab.exists(), "Test keytab not found at {:?}", keytab); + + let principal = "pgdog-test@PGDOG.LOCAL"; + let target = "postgres/db.example.com"; + let context = GssapiContext::new_initiator(keytab, principal, target); + + assert!( + context.is_ok(), + "Failed to create GSSAPI context: {:?}", + context.err() + ); + let mut ctx = context.unwrap(); + assert_eq!(ctx.target_principal(), target); + assert!(!ctx.is_complete()); + + let result = ctx.initiate(); + assert!( + result.is_err(), + "Initiate should fail without a real server" + ); +} + +#[test] +fn test_backend_target_principal() { + let host = "db.example.com"; + let target = format!("postgres/{}", host); + assert_eq!(target, "postgres/db.example.com"); + + let host = "192.168.1.1"; + let target = format!("postgres/{}", host); + assert_eq!(target, "postgres/192.168.1.1"); +} + +#[tokio::test] +async fn test_ticket_cache_acquires_credential() { + let keytab_path = test_keytab_path("test.keytab"); + assert!( + keytab_path.exists(), + "Test keytab not found at {:?}", + keytab_path + ); + + let principal = "test@PGDOG.LOCAL"; + let cache = TicketCache::new(principal, keytab_path); + let ticket = cache.acquire_ticket(); + + assert!( + ticket.is_ok(), + "Failed to acquire ticket: {:?}", + ticket.err() + ); +} + +#[tokio::test] +async fn test_ticket_manager_per_server() { + let keytab1 = test_keytab_path("server1.keytab"); + let keytab2 = test_keytab_path("server2.keytab"); + assert!( + keytab1.exists() && keytab2.exists(), + "Test keytabs not found" + ); + + let manager = TicketManager::new(); + + let ticket1 = manager + .get_ticket("server1:5432", keytab1.clone(), "server1@PGDOG.LOCAL") + .await; + let ticket2 = manager + .get_ticket("server2:5432", keytab2.clone(), "server2@PGDOG.LOCAL") + .await; + + assert!( + ticket1.is_ok(), + "Failed to get ticket for server1: {:?}", + ticket1.err() + ); + assert!( + ticket2.is_ok(), + "Failed to get ticket for server2: {:?}", + ticket2.err() + ); + + let cache1 = manager.get_cache("server1:5432"); + let cache2 = manager.get_cache("server2:5432"); + assert!(cache1.is_some(), "Cache for server1 should exist"); + assert!(cache2.is_some(), "Cache for server2 should exist"); + assert_ne!(cache1.unwrap().principal(), cache2.unwrap().principal()); +} + +#[tokio::test] +async fn test_frontend_authentication() { + let keytab = test_keytab_path("test.keytab"); + assert!(keytab.exists(), "Test keytab not found at {:?}", keytab); + + let server = GssapiServer::new_acceptor(keytab, None); + if server.is_err() { + return; + } + + let server = Arc::new(Mutex::new(server.unwrap())); + let client_token = vec![0x60, 0x81]; + let result = handle_gssapi_auth(server, client_token).await; + + assert!(result.is_err(), "Should fail with invalid token"); +} + +#[test] +fn test_missing_keytab_error() { + let cache = TicketCache::new("test@PGDOG.LOCAL", PathBuf::from("/nonexistent/keytab")); + let ticket = cache.acquire_ticket(); + + assert!(ticket.is_err()); + let err = ticket.unwrap_err(); + assert!(err.to_string().contains("keytab")); +} + +#[tokio::test] +async fn test_ticket_manager_cleanup() { + let keytab1 = test_keytab_path("keytab1.keytab"); + let keytab2 = test_keytab_path("keytab2.keytab"); + assert!( + keytab1.exists() && keytab2.exists(), + "Test keytabs not found" + ); + + let manager = TicketManager::new(); + + let _ = manager + .get_ticket("server1:5432", keytab1, "principal1@PGDOG.LOCAL") + .await; + let _ = manager + .get_ticket("server2:5432", keytab2, "principal2@PGDOG.LOCAL") + .await; + + assert_eq!(manager.cache_count(), 2); + + manager.shutdown(); + + assert_eq!(manager.cache_count(), 0); + assert!(manager.get_cache("server1:5432").is_none()); +} + +#[test] +fn test_gssapi_config() { + let gssapi = GssapiConfig { + enabled: true, + server_keytab: Some(test_keytab_path("test.keytab")), + server_principal: Some("pgdog-test@PGDOG.LOCAL".to_string()), + default_backend_keytab: Some(test_keytab_path("backend.keytab")), + default_backend_principal: Some("pgdog-backend@PGDOG.LOCAL".to_string()), + default_backend_target_principal: Some("postgres/test@PGDOG.LOCAL".to_string()), + strip_realm: true, + ticket_refresh_interval: 14400, + fallback_enabled: false, + require_encryption: false, + }; + + assert!(gssapi.is_configured()); + assert!(gssapi.has_backend_config()); +} From 0fe12a4a28d476f8845ae9564cd7124cc8ab03d6 Mon Sep 17 00:00:00 2001 From: Justin George Date: Fri, 19 Sep 2025 13:14:57 -0700 Subject: [PATCH 14/19] try the kdc setup verbosely --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4acaef42e..799bbdaf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: run: | # The unified script handles everything: KDC setup, service start, keytab creation chmod +x integration/gssapi/setup_test_keytabs.sh - CI=true sudo -E bash integration/gssapi/setup_test_keytabs.sh + CI=true VERBOSE=1 sudo -E bash integration/gssapi/setup_test_keytabs.sh # Make keytabs readable by the test user sudo chown -R $USER:$USER integration/gssapi/keytabs/ From 61baaf2064994921585c545ab8faa756c7ac4e1d Mon Sep 17 00:00:00 2001 From: Justin George Date: Fri, 19 Sep 2025 13:33:14 -0700 Subject: [PATCH 15/19] keytab script revamp --- integration/gssapi/setup_test_keytabs.sh | 121 ++++++++++++++++++++--- 1 file changed, 105 insertions(+), 16 deletions(-) diff --git a/integration/gssapi/setup_test_keytabs.sh b/integration/gssapi/setup_test_keytabs.sh index 95f7cc0fd..c4a03495c 100755 --- a/integration/gssapi/setup_test_keytabs.sh +++ b/integration/gssapi/setup_test_keytabs.sh @@ -69,17 +69,24 @@ setup_kerberos_env() { if [ -f "$dir/krb5.conf" ]; then export KRB5_CONFIG="$dir/krb5.conf" export KRB5_KDC_PROFILE="$dir/krb5kdc/kdc.conf" + log "macOS: Found existing config at $dir/krb5.conf" return 0 fi done # Create default location if missing export KRB5_CONFIG="/opt/homebrew/etc/krb5.conf" export KRB5_KDC_PROFILE="/opt/homebrew/etc/krb5kdc/kdc.conf" + log "macOS: Using default config location $KRB5_CONFIG" else # Linux standard locations export KRB5_CONFIG="/etc/krb5.conf" export KRB5_KDC_PROFILE="/etc/krb5kdc/kdc.conf" + log "Linux: Using standard config location $KRB5_CONFIG" fi + + # Export globally for all commands + log "Environment: KRB5_CONFIG=$KRB5_CONFIG" + log "Environment: KRB5_KDC_PROFILE=$KRB5_KDC_PROFILE" } # Create krb5.conf if missing @@ -87,7 +94,14 @@ create_krb5_conf() { local config_file="$1" local config_dir=$(dirname "$config_file") - [ -f "$config_file" ] && return 0 + # In CI mode, always recreate the config to avoid conflicts + if [ "$CI" = "true" ] && [ -f "$config_file" ]; then + log "CI mode: Backing up existing $config_file to ${config_file}.bak" + sudo mv "$config_file" "${config_file}.bak" 2>/dev/null || true + elif [ -f "$config_file" ] && [ "$CI" != "true" ]; then + log "Using existing Kerberos configuration at $config_file" + return 0 + fi log "Creating Kerberos configuration at $config_file..." @@ -136,7 +150,14 @@ create_kdc_conf() { local kdc_conf="$1" local kdc_dir=$(dirname "$kdc_conf") - [ -f "$kdc_conf" ] && return 0 + # In CI mode, always recreate the config + if [ "$CI" = "true" ] && [ -f "$kdc_conf" ]; then + log "CI mode: Backing up existing $kdc_conf to ${kdc_conf}.bak" + sudo mv "$kdc_conf" "${kdc_conf}.bak" 2>/dev/null || true + elif [ -f "$kdc_conf" ] && [ "$CI" != "true" ]; then + log "Using existing KDC configuration at $kdc_conf" + return 0 + fi log "Creating KDC configuration at $kdc_conf..." @@ -195,23 +216,53 @@ initialize_kdc() { # Check if database exists local kadmin_local=$(find_tool "kadmin.local") if [ -n "$kadmin_local" ]; then - if $kadmin_local -q "listprincs" 2>/dev/null | grep -q "krbtgt/$REALM@$REALM"; then - log "KDC database already exists" - return 0 + if [ -n "$VERBOSE" ]; then + local check_output=$(KRB5_CONFIG="$KRB5_CONFIG" $kadmin_local -q "listprincs" 2>&1) + if echo "$check_output" | grep -q "krbtgt/$REALM@$REALM"; then + log "KDC database already exists" + return 0 + else + log "KDC database check output: $check_output" + fi + else + if KRB5_CONFIG="$KRB5_CONFIG" $kadmin_local -q "listprincs" 2>/dev/null | grep -q "krbtgt/$REALM@$REALM"; then + log "KDC database already exists" + return 0 + fi fi fi # Create database - if [ "$EUID" = "0" ] || [ "$CI" = "true" ]; then - $kdb5_util create -s -r $REALM -P $KDC_PASSWORD 2>/dev/null || sudo $kdb5_util create -s -r $REALM -P $KDC_PASSWORD 2>/dev/null + log "Creating KDC database for realm $REALM..." + local create_cmd="KRB5_CONFIG=\"$KRB5_CONFIG\" $kdb5_util create -s -r $REALM -P $KDC_PASSWORD" + + if [ -n "$VERBOSE" ]; then + local output + if [ "$EUID" = "0" ] || [ "$CI" = "true" ]; then + output=$(eval $create_cmd 2>&1) || output=$(sudo -E sh -c "$create_cmd" 2>&1) + else + output=$(sudo -E sh -c "$create_cmd" 2>&1) || output=$(eval $create_cmd 2>&1) + fi + log "kdb5_util output: $output" else - sudo $kdb5_util create -s -r $REALM -P $KDC_PASSWORD 2>/dev/null || $kdb5_util create -s -r $REALM -P $KDC_PASSWORD + if [ "$EUID" = "0" ] || [ "$CI" = "true" ]; then + eval $create_cmd 2>/dev/null || sudo -E sh -c "$create_cmd" 2>/dev/null + else + sudo -E sh -c "$create_cmd" 2>/dev/null || eval $create_cmd 2>/dev/null + fi fi # Create admin principal if [ -n "$kadmin_local" ]; then - $kadmin_local -q "addprinc -pw $KDC_PASSWORD admin/admin" 2>/dev/null || \ - sudo $kadmin_local -q "addprinc -pw $KDC_PASSWORD admin/admin" 2>/dev/null || true + log "Creating admin principal..." + if [ -n "$VERBOSE" ]; then + local admin_output=$(KRB5_CONFIG="$KRB5_CONFIG" $kadmin_local -q "addprinc -pw $KDC_PASSWORD admin/admin" 2>&1) || \ + admin_output=$(sudo -E sh -c "KRB5_CONFIG=\"$KRB5_CONFIG\" $kadmin_local -q 'addprinc -pw $KDC_PASSWORD admin/admin'" 2>&1) + log "Admin principal creation: $admin_output" + else + KRB5_CONFIG="$KRB5_CONFIG" $kadmin_local -q "addprinc -pw $KDC_PASSWORD admin/admin" 2>/dev/null || \ + sudo -E sh -c "KRB5_CONFIG=\"$KRB5_CONFIG\" $kadmin_local -q 'addprinc -pw $KDC_PASSWORD admin/admin'" 2>/dev/null || true + fi fi return 0 @@ -221,6 +272,9 @@ initialize_kdc() { start_kdc_services() { local krb5kdc=$(find_tool "krb5kdc") + log "Starting KDC services..." + log "Current KDC processes: $(pgrep -f krb5kdc 2>/dev/null | wc -l) running" + if [ "$OS" = "Darwin" ]; then # macOS: try brew services first if command -v brew &>/dev/null; then @@ -250,6 +304,16 @@ start_kdc_services() { sleep 1 fi fi + + # Check if services started + sleep 1 + log "KDC processes after start: $(pgrep -f krb5kdc 2>/dev/null | wc -l) running" + + if command -v netstat &>/dev/null; then + log "KDC listening on port 88: $(netstat -ln 2>/dev/null | grep ':88 ' | wc -l) listeners" + elif command -v ss &>/dev/null; then + log "KDC listening on port 88: $(ss -ln 2>/dev/null | grep ':88 ' | wc -l) listeners" + fi } # Check if KDC is running @@ -257,8 +321,16 @@ check_kdc_running() { local kadmin_local="$1" [ -z "$kadmin_local" ] && return 1 - if $kadmin_local -q "listprincs" 2>/dev/null | grep -q "krbtgt"; then - return 0 + if [ -n "$VERBOSE" ]; then + local check_output=$(KRB5_CONFIG="$KRB5_CONFIG" $kadmin_local -q "listprincs" 2>&1) + log "KDC check output: $check_output" + if echo "$check_output" | grep -q "krbtgt"; then + return 0 + fi + else + if KRB5_CONFIG="$KRB5_CONFIG" $kadmin_local -q "listprincs" 2>/dev/null | grep -q "krbtgt"; then + return 0 + fi fi return 1 } @@ -270,20 +342,20 @@ create_principal() { local password="$3" # Check if principal exists - if ! $kadmin_local -q "getprinc $principal" 2>&1 | grep -q "does not exist"; then + if ! KRB5_CONFIG="$KRB5_CONFIG" $kadmin_local -q "getprinc $principal" 2>&1 | grep -q "does not exist"; then log " Principal $principal already exists" return 0 fi # Create principal log " Creating principal $principal..." - if $kadmin_local -q "addprinc -pw $password $principal" 2>&1 | grep -q "created\|added"; then + if KRB5_CONFIG="$KRB5_CONFIG" $kadmin_local -q "addprinc -pw $password $principal" 2>&1 | grep -q "created\|added"; then log " ✓ Created principal $principal" return 0 fi # Retry with sudo if needed - if sudo $kadmin_local -q "addprinc -pw $password $principal" 2>&1 | grep -q "created\|added"; then + if sudo -E sh -c "KRB5_CONFIG=\"$KRB5_CONFIG\" $kadmin_local -q 'addprinc -pw $password $principal'" 2>&1 | grep -q "created\|added"; then log " ✓ Created principal $principal" return 0 fi @@ -303,7 +375,8 @@ export_to_keytab() { rm -f "$keytab_file" 2>/dev/null || true # Try export - local output=$($kadmin_local -q "ktadd -k $keytab_file $principal" 2>&1 || sudo $kadmin_local -q "ktadd -k $keytab_file $principal" 2>&1) + local output=$(KRB5_CONFIG="$KRB5_CONFIG" $kadmin_local -q "ktadd -k $keytab_file $principal" 2>&1 || \ + sudo -E sh -c "KRB5_CONFIG=\"$KRB5_CONFIG\" $kadmin_local -q 'ktadd -k $keytab_file $principal'" 2>&1) if echo "$output" | grep -q "added to keytab"; then chmod 600 "$keytab_file" 2>/dev/null || true @@ -312,6 +385,9 @@ export_to_keytab() { fi log " Warning: Could not export $principal to keytab" + if [ -n "$VERBOSE" ]; then + log " Export output: $output" + fi return 1 } @@ -354,12 +430,25 @@ main() { create_krb5_conf "$KRB5_CONFIG" create_kdc_conf "$KRB5_KDC_PROFILE" + # Show config content in verbose mode for debugging + if [ -n "$VERBOSE" ] && [ -f "$KRB5_CONFIG" ]; then + log "Content of $KRB5_CONFIG:" + log "$(head -20 "$KRB5_CONFIG" | sed 's/^/ /')" + fi + # Step 4: Find required tools KADMIN_LOCAL=$(find_tool "kadmin.local") if [ -z "$KADMIN_LOCAL" ]; then error "kadmin.local not found - cannot manage Kerberos principals" exit 1 fi + log "Found kadmin.local at: $KADMIN_LOCAL" + + KDB5_UTIL=$(find_tool "kdb5_util") + log "Found kdb5_util at: ${KDB5_UTIL:-not found}" + + KINIT=$(find_tool "kinit") + log "Found kinit at: ${KINIT:-not found}" # Step 5: Initialize KDC if needed initialize_kdc From 4b51890fc8b106823526c12df467493f266dc654 Mon Sep 17 00:00:00 2001 From: Justin George Date: Fri, 19 Sep 2025 14:34:50 -0700 Subject: [PATCH 16/19] try getting creds from keytab if cache fails --- pgdog/src/auth/gssapi/context.rs | 43 ++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/pgdog/src/auth/gssapi/context.rs b/pgdog/src/auth/gssapi/context.rs index 39478194a..e791c789b 100644 --- a/pgdog/src/auth/gssapi/context.rs +++ b/pgdog/src/auth/gssapi/context.rs @@ -48,9 +48,6 @@ impl GssapiContext { return Err(GssapiError::KeytabNotFound(keytab.to_path_buf())); } - // TicketManager has already set up the credential cache with KRB5CCNAME - // We just need to acquire credentials from that cache - // Create the desired mechanisms set let mut desired_mechs = OidSet::new() .map_err(|e| GssapiError::LibGssapi(format!("failed to create OidSet: {}", e)))?; @@ -58,17 +55,41 @@ impl GssapiContext { .add(&GSS_MECH_KRB5) .map_err(|e| GssapiError::LibGssapi(format!("failed to add mechanism: {}", e)))?; - // Acquire credentials from the cache that TicketManager populated - // Pass None to use the default principal from the cache - let credential = Cred::acquire( - None, // Use the principal from the cache that TicketManager set up + // Try to acquire credentials from cache first, then fall back to keytab + let credential = match Cred::acquire( + None, // Try default principal from cache None, CredUsage::Initiate, Some(&desired_mechs), - ) - .map_err(|e| { - GssapiError::CredentialAcquisitionFailed(format!("failed for {}: {}", principal, e)) - })?; + ) { + Ok(cred) => cred, + Err(cache_err) => { + // Cache failed, try acquiring from keytab + // Set the keytab path for client use + std::env::set_var("KRB5_CLIENT_KTNAME", keytab.to_string_lossy().as_ref()); + + // Parse the principal name + let principal_name = Name::new(principal.as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL)) + .map_err(|e| GssapiError::InvalidPrincipal(format!("{}: {}", principal, e)))?; + + // Acquire credentials using the keytab + Cred::acquire( + Some(&principal_name), + None, + CredUsage::Initiate, + Some(&desired_mechs), + ) + .map_err(|e| { + GssapiError::CredentialAcquisitionFailed(format!( + "failed for {} using keytab {}: {} (cache error: {})", + principal, + keytab.display(), + e, + cache_err + )) + })? + } + }; // Parse target service principal (use KRB5_PRINCIPAL to avoid hostname canonicalization) let target_name = Name::new(target_principal.as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL)) From 407f4fd2169b1f12ad22c817bc493d27f0ea5ade Mon Sep 17 00:00:00 2001 From: Justin George Date: Fri, 19 Sep 2025 15:18:36 -0700 Subject: [PATCH 17/19] try again with the gssapi test setup --- pgdog/src/auth/gssapi/context.rs | 24 ++- pgdog/src/auth/gssapi/tests.rs | 6 +- pgdog/src/auth/gssapi/ticket_cache.rs | 190 ++++++++++++++++++------ pgdog/src/auth/gssapi/ticket_manager.rs | 2 +- pgdog/tests/gssapi_test.rs | 10 +- 5 files changed, 169 insertions(+), 63 deletions(-) diff --git a/pgdog/src/auth/gssapi/context.rs b/pgdog/src/auth/gssapi/context.rs index e791c789b..bc003da81 100644 --- a/pgdog/src/auth/gssapi/context.rs +++ b/pgdog/src/auth/gssapi/context.rs @@ -55,22 +55,32 @@ impl GssapiContext { .add(&GSS_MECH_KRB5) .map_err(|e| GssapiError::LibGssapi(format!("failed to add mechanism: {}", e)))?; - // Try to acquire credentials from cache first, then fall back to keytab + // Parse the principal name first so we can try to acquire for the specific principal + let principal_name = Name::new(principal.as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL)) + .map_err(|e| GssapiError::InvalidPrincipal(format!("{}: {}", principal, e)))?; + + // Try to acquire credentials for the specific principal + // This helps avoid "Principal in credential cache does not match desired name" errors let credential = match Cred::acquire( - None, // Try default principal from cache + Some(&principal_name), // Try specific principal first None, CredUsage::Initiate, Some(&desired_mechs), ) { Ok(cred) => cred, Err(cache_err) => { - // Cache failed, try acquiring from keytab + // If cache acquisition fails (including principal mismatch), + // try acquiring from keytab with a fresh cache // Set the keytab path for client use std::env::set_var("KRB5_CLIENT_KTNAME", keytab.to_string_lossy().as_ref()); - // Parse the principal name - let principal_name = Name::new(principal.as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL)) - .map_err(|e| GssapiError::InvalidPrincipal(format!("{}: {}", principal, e)))?; + // Set a unique cache to avoid conflicts + let cache_file = format!( + "/tmp/krb5cc_pgdog_context_{}_{}", + principal.replace(['@', '.', '/'], "_"), + std::process::id() + ); + std::env::set_var("KRB5CCNAME", format!("FILE:{}", cache_file)); // Acquire credentials using the keytab Cred::acquire( @@ -81,7 +91,7 @@ impl GssapiContext { ) .map_err(|e| { GssapiError::CredentialAcquisitionFailed(format!( - "failed for {} using keytab {}: {} (cache error: {})", + "failed for {} using keytab {}: {} (initial error: {})", principal, keytab.display(), e, diff --git a/pgdog/src/auth/gssapi/tests.rs b/pgdog/src/auth/gssapi/tests.rs index c5bece979..d7484bac0 100644 --- a/pgdog/src/auth/gssapi/tests.rs +++ b/pgdog/src/auth/gssapi/tests.rs @@ -95,14 +95,14 @@ mod tests { } #[cfg(not(feature = "gssapi"))] - #[test] - fn test_mock_ticket_cache() { + #[tokio::test] + async fn test_mock_ticket_cache() { let cache = TicketCache::new("test@REALM", PathBuf::from("/etc/test.keytab")); assert_eq!(cache.principal(), "test@REALM"); assert_eq!(cache.keytab_path(), &PathBuf::from("/etc/test.keytab")); assert!(!cache.needs_refresh()); - let result = cache.acquire_ticket(); + let result = cache.acquire_ticket().await; assert!(result.is_err()); } diff --git a/pgdog/src/auth/gssapi/ticket_cache.rs b/pgdog/src/auth/gssapi/ticket_cache.rs index 758b0bda4..08e13e24c 100644 --- a/pgdog/src/auth/gssapi/ticket_cache.rs +++ b/pgdog/src/auth/gssapi/ticket_cache.rs @@ -4,6 +4,8 @@ use super::error::{GssapiError, Result}; use parking_lot::RwLock; use std::path::PathBuf; use std::time::{Duration, Instant}; +use tokio::task::spawn_blocking; +use tokio::time::timeout; #[cfg(feature = "gssapi")] use std::sync::Arc; @@ -72,51 +74,145 @@ impl TicketCache { } /// Acquire a ticket from the keytab - pub fn acquire_ticket(&self) -> Result> { + pub async fn acquire_ticket(&self) -> Result> { // Check if keytab exists if !self.keytab_path.exists() { return Err(GssapiError::KeytabNotFound(self.keytab_path.clone())); } - // Set the KRB5_CLIENT_KTNAME environment variable to point to our keytab - std::env::set_var("KRB5_CLIENT_KTNAME", &self.keytab_path); - - // Parse the principal name - let name = Name::new(self.principal.as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL)) - .map_err(|e| GssapiError::InvalidPrincipal(format!("{}: {}", self.principal, e)))?; - - // Create the desired mechanisms set - let mut desired_mechs = OidSet::new() - .map_err(|e| GssapiError::LibGssapi(format!("failed to create OidSet: {}", e)))?; - desired_mechs - .add(&GSS_MECH_KRB5) - .map_err(|e| GssapiError::LibGssapi(format!("failed to add mechanism: {}", e)))?; - - // Acquire credentials from the keytab - let credential = Cred::acquire( - Some(&name), - None, // No specific time requirement - CredUsage::Initiate, - Some(&desired_mechs), - ) - .map_err(|e| { - GssapiError::CredentialAcquisitionFailed(format!( - "failed for {}: {}", - self.principal, e - )) - })?; - - let credential = Arc::new(credential); - - // Store the credential - *self.credential.write() = Some(credential.clone()); - *self.last_refresh.write() = Instant::now(); - - Ok(credential) + // Create a unique credential cache for this principal to avoid conflicts + // This prevents "Principal in credential cache does not match desired name" errors + // when the environment has a cache with a different principal + let cache_file = format!( + "/tmp/krb5cc_pgdog_{}_{}", + self.principal.replace(['@', '.', '/'], "_"), + std::process::id() + ); + let cache_path = format!("FILE:{}", cache_file); + + // Clone values needed in the async task + let principal = self.principal.clone(); + let keytab_path = self.keytab_path.clone(); + + // Use kinit to acquire the ticket with proper timeout + // This avoids the blocking issue with libgssapi's Cred::acquire + let kinit_result = timeout(Duration::from_secs(5), async { + let mut command = tokio::process::Command::new("kinit"); + command + .arg("-kt") + .arg(&keytab_path) + .arg(&principal) + .env("KRB5CCNAME", &cache_path) + .kill_on_drop(true); // Important: kill the process if we drop the handle + + // Try to find and set KRB5_CONFIG if available + for path in &["/opt/homebrew/etc/krb5.conf", "/etc/krb5.conf"] { + if tokio::fs::metadata(path).await.is_ok() { + command.env("KRB5_CONFIG", path); + break; + } + } + + command.output().await + }) + .await; + + let credential = match kinit_result { + Ok(Ok(output)) if output.status.success() => { + // kinit succeeded, now acquire the credential from the cache + // Set up the environment + std::env::set_var("KRB5CCNAME", &cache_path); + std::env::set_var("KRB5_CLIENT_KTNAME", &keytab_path); + + // Use spawn_blocking but with the credential already in cache + // This should be fast and not block + timeout( + Duration::from_millis(500), // Much shorter timeout since creds should be in cache + spawn_blocking(move || -> std::result::Result { + // Parse the principal name + let name = Name::new(principal.as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL)) + .map_err(|e| { + GssapiError::InvalidPrincipal(format!("{}: {}", principal, e)) + })?; + + // Create the desired mechanisms set + let mut desired_mechs = OidSet::new().map_err(|e| { + GssapiError::LibGssapi(format!("failed to create OidSet: {}", e)) + })?; + desired_mechs.add(&GSS_MECH_KRB5).map_err(|e| { + GssapiError::LibGssapi(format!("failed to add mechanism: {}", e)) + })?; + + // Acquire credentials from the cache that kinit populated + Cred::acquire(Some(&name), None, CredUsage::Initiate, Some(&desired_mechs)) + .map_err(|e| { + GssapiError::CredentialAcquisitionFailed(format!( + "failed to acquire from cache for {}: {}", + principal, e + )) + }) + }), + ) + .await + } + Ok(Ok(output)) => { + // kinit failed - return error immediately without trying libgssapi + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GssapiError::CredentialAcquisitionFailed(format!( + "kinit failed for {}: {}", + self.principal, stderr + ))); + } + Ok(Err(e)) => { + // Failed to run kinit + return Err(GssapiError::CredentialAcquisitionFailed(format!( + "failed to run kinit for {}: {}", + self.principal, e + ))); + } + Err(_) => { + // Timeout expired + return Err(GssapiError::CredentialAcquisitionFailed(format!( + "kinit timed out after 5 seconds for {}", + self.principal + ))); + } + }; + + match credential { + Ok(Ok(Ok(cred))) => { + // Successfully acquired credential from cache + let cred_arc: Arc = Arc::new(cred); + + // Store the credential + *self.credential.write() = Some(cred_arc.clone()); + *self.last_refresh.write() = Instant::now(); + + Ok(cred_arc) + } + Ok(Ok(Err(e))) => { + // Failed to acquire from cache + Err(e) + } + Ok(Err(_)) => { + // spawn_blocking task panicked + Err(GssapiError::CredentialAcquisitionFailed(format!( + "credential acquisition task panicked for {}", + self.principal + ))) + } + Err(_) => { + // Timeout on credential acquisition from cache + Err(GssapiError::CredentialAcquisitionFailed(format!( + "failed to acquire credentials from cache (timed out) for {}", + self.principal + ))) + } + } } /// Get the cached credential, acquiring it if necessary - pub fn get_credential(&self) -> Result> { + pub async fn get_credential(&self) -> Result> { // Check if we have a cached credential if let Some(cred) = self.credential.read().as_ref() { // Check if it needs refresh @@ -126,7 +222,7 @@ impl TicketCache { } // Need to acquire or refresh - self.acquire_ticket() + self.acquire_ticket().await } /// Check if the ticket needs refresh @@ -140,8 +236,8 @@ impl TicketCache { } /// Refresh the ticket - pub fn refresh(&self) -> Result<()> { - self.acquire_ticket()?; + pub async fn refresh(&self) -> Result<()> { + self.acquire_ticket().await?; Ok(()) } @@ -179,14 +275,14 @@ impl TicketCache { } /// Acquire a ticket from the keytab (mock) - pub fn acquire_ticket(&self) -> Result<()> { + pub async fn acquire_ticket(&self) -> Result<()> { Err(GssapiError::LibGssapi( "GSSAPI support not compiled in".to_string(), )) } /// Get the cached credential (mock) - pub fn get_credential(&self) -> Result<()> { + pub async fn get_credential(&self) -> Result<()> { Err(GssapiError::LibGssapi( "GSSAPI support not compiled in".to_string(), )) @@ -203,7 +299,7 @@ impl TicketCache { } /// Refresh the ticket (mock) - pub fn refresh(&self) -> Result<()> { + pub async fn refresh(&self) -> Result<()> { Err(GssapiError::LibGssapi( "GSSAPI support not compiled in".to_string(), )) @@ -224,12 +320,12 @@ mod tests { assert_eq!(cache.keytab_path(), &PathBuf::from("/etc/test.keytab")); } - #[test] - fn test_missing_keytab_error() { + #[tokio::test] + async fn test_missing_keytab_error() { let cache = TicketCache::new("test@REALM", "/nonexistent/keytab"); #[cfg(feature = "gssapi")] { - let result = cache.acquire_ticket(); + let result = cache.acquire_ticket().await; assert!(result.is_err()); match result.unwrap_err() { GssapiError::KeytabNotFound(path) => { @@ -240,7 +336,7 @@ mod tests { } #[cfg(not(feature = "gssapi"))] { - let result = cache.acquire_ticket(); + let result = cache.acquire_ticket().await; assert!(result.is_err()); match result.unwrap_err() { GssapiError::LibGssapi(msg) => { diff --git a/pgdog/src/auth/gssapi/ticket_manager.rs b/pgdog/src/auth/gssapi/ticket_manager.rs index 73a938297..c856c62ce 100644 --- a/pgdog/src/auth/gssapi/ticket_manager.rs +++ b/pgdog/src/auth/gssapi/ticket_manager.rs @@ -152,7 +152,7 @@ impl TicketManager { interval.tick().await; if cache.needs_refresh() { - match cache.refresh() { + match cache.refresh().await { Ok(()) => { tracing::info!("[gssapi] refreshed ticket for \"{}\"", server_clone); } diff --git a/pgdog/tests/gssapi_test.rs b/pgdog/tests/gssapi_test.rs index 1ffb4b6bf..afaeab19e 100644 --- a/pgdog/tests/gssapi_test.rs +++ b/pgdog/tests/gssapi_test.rs @@ -97,12 +97,12 @@ async fn test_ticket_cache_acquires_credential() { let principal = "test@PGDOG.LOCAL"; let cache = TicketCache::new(principal, keytab_path); - let ticket = cache.acquire_ticket(); + let ticket = cache.acquire_ticket().await; assert!( ticket.is_ok(), "Failed to acquire ticket: {:?}", - ticket.err() + ticket.as_ref().err() ); } @@ -159,10 +159,10 @@ async fn test_frontend_authentication() { assert!(result.is_err(), "Should fail with invalid token"); } -#[test] -fn test_missing_keytab_error() { +#[tokio::test] +async fn test_missing_keytab_error() { let cache = TicketCache::new("test@PGDOG.LOCAL", PathBuf::from("/nonexistent/keytab")); - let ticket = cache.acquire_ticket(); + let ticket = cache.acquire_ticket().await; assert!(ticket.is_err()); let err = ticket.unwrap_err(); From 4e53717cdf13fd68c89a7472f2fc8ae25080b1dd Mon Sep 17 00:00:00 2001 From: Justin George Date: Fri, 19 Sep 2025 15:29:27 -0700 Subject: [PATCH 18/19] ignore AGENTS.md (codex setup) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 70a7e762d..60a854ce2 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ perf.data.old /pgdog.toml /users.toml CLAUDE.local.md +AGENTS.md .claude/plans/ .claude/completed_plans/ From ce870b3252d70ae8834508bf0aaf85b42a0ac6b0 Mon Sep 17 00:00:00 2001 From: Justin George Date: Fri, 19 Sep 2025 16:42:14 -0700 Subject: [PATCH 19/19] let Codex have a crack at it --- .gitignore | 1 + pgdog/src/auth/gssapi/context.rs | 26 +- pgdog/src/auth/gssapi/credential_provider.rs | 121 ++++++++ pgdog/src/auth/gssapi/mod.rs | 4 + pgdog/src/auth/gssapi/scoped_env.rs | 114 +++++++ pgdog/src/auth/gssapi/server.rs | 108 ++++--- pgdog/src/auth/gssapi/tests.rs | 2 +- pgdog/src/auth/gssapi/ticket_cache.rs | 306 +++++++++++-------- pgdog/src/auth/gssapi/ticket_manager.rs | 117 ++++--- pgdog/tests/gssapi_test.rs | 8 +- 10 files changed, 572 insertions(+), 235 deletions(-) create mode 100644 pgdog/src/auth/gssapi/credential_provider.rs create mode 100644 pgdog/src/auth/gssapi/scoped_env.rs diff --git a/.gitignore b/.gitignore index 60a854ce2..cc18ecd72 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ pgdog-plugin/src/bindings.rs local/ integration/log.txt integration/gssapi/keytabs/*.keytab +pgdog/docs/*.md diff --git a/pgdog/src/auth/gssapi/context.rs b/pgdog/src/auth/gssapi/context.rs index bc003da81..cdf608da0 100644 --- a/pgdog/src/auth/gssapi/context.rs +++ b/pgdog/src/auth/gssapi/context.rs @@ -71,19 +71,21 @@ impl GssapiContext { Err(cache_err) => { // If cache acquisition fails (including principal mismatch), // try acquiring from keytab with a fresh cache - // Set the keytab path for client use - std::env::set_var("KRB5_CLIENT_KTNAME", keytab.to_string_lossy().as_ref()); - - // Set a unique cache to avoid conflicts - let cache_file = format!( - "/tmp/krb5cc_pgdog_context_{}_{}", + // Set scoped environment so we don't leak global state. + let cache_uri = format!( + "FILE:/tmp/krb5cc_pgdog_context_{}_{}", principal.replace(['@', '.', '/'], "_"), std::process::id() ); - std::env::set_var("KRB5CCNAME", format!("FILE:{}", cache_file)); - - // Acquire credentials using the keytab - Cred::acquire( + let guard = crate::auth::gssapi::ScopedEnv::set([ + ( + "KRB5_CLIENT_KTNAME", + Some(keytab.to_string_lossy().into_owned()), + ), + ("KRB5CCNAME", Some(cache_uri)), + ]); + + let result = Cred::acquire( Some(&principal_name), None, CredUsage::Initiate, @@ -97,7 +99,9 @@ impl GssapiContext { e, cache_err )) - })? + }); + drop(guard); + result? } }; diff --git a/pgdog/src/auth/gssapi/credential_provider.rs b/pgdog/src/auth/gssapi/credential_provider.rs new file mode 100644 index 000000000..7956117ab --- /dev/null +++ b/pgdog/src/auth/gssapi/credential_provider.rs @@ -0,0 +1,121 @@ +use dashmap::DashMap; +use once_cell::sync::Lazy; +use parking_lot::Mutex as ParkingMutex; +use std::future::Future; +use std::sync::Arc; +use tokio::sync::Mutex; + +static GLOBAL_ACCEPTOR_LOCK: Lazy> = Lazy::new(|| ParkingMutex::new(())); + +/// Map of per-principal mutexes ensuring only one credential acquisition runs at once. +#[derive(Default)] +pub struct PrincipalLocks { + locks: Arc>>>, +} + +impl PrincipalLocks { + pub fn new() -> Self { + Self { + locks: Arc::new(DashMap::new()), + } + } + + fn get_or_insert(&self, principal: &str) -> Arc> { + self.locks + .entry(principal.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + + pub async fn with_principal(&self, principal: &str, f: F) -> T + where + F: FnOnce() -> Fut, + Fut: Future, + { + let lock = self.get_or_insert(principal); + let _guard = lock.lock().await; + f().await + } +} + +impl Clone for PrincipalLocks { + fn clone(&self) -> Self { + Self { + locks: Arc::clone(&self.locks), + } + } +} + +/// Serialize acceptor acquisitions globally to avoid fighting over KRB5_KTNAME. +pub fn with_acceptor_lock(f: F) -> T +where + F: FnOnce() -> T, +{ + let _guard = GLOBAL_ACCEPTOR_LOCK.lock(); + f() +} + +#[cfg(test)] +mod tests { + use super::{with_acceptor_lock, PrincipalLocks}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + use tokio::task; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn principal_lock_serializes() { + let locks = PrincipalLocks::new(); + let running = Arc::new(AtomicUsize::new(0)); + let max = Arc::new(AtomicUsize::new(0)); + + let mut handles = Vec::new(); + for _ in 0..4 { + let locks = locks.clone(); + let running = Arc::clone(&running); + let max = Arc::clone(&max); + handles.push(task::spawn(async move { + locks + .with_principal("user@REALM", || async { + let current = running.fetch_add(1, Ordering::SeqCst) + 1; + max.fetch_max(current, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(10)).await; + running.fetch_sub(1, Ordering::SeqCst); + }) + .await; + })); + } + + for handle in handles { + handle.await.unwrap(); + } + + assert_eq!(max.load(Ordering::SeqCst), 1); + } + + #[test] + fn acceptor_lock_serializes() { + let running = Arc::new(AtomicUsize::new(0)); + let max = Arc::new(AtomicUsize::new(0)); + + let mut handles = Vec::new(); + for _ in 0..4 { + let running = Arc::clone(&running); + let max = Arc::clone(&max); + handles.push(std::thread::spawn(move || { + with_acceptor_lock(|| { + let current = running.fetch_add(1, Ordering::SeqCst) + 1; + max.fetch_max(current, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(10)); + running.fetch_sub(1, Ordering::SeqCst); + }); + })); + } + + for handle in handles { + handle.join().unwrap(); + } + + assert_eq!(max.load(Ordering::SeqCst), 1); + } +} diff --git a/pgdog/src/auth/gssapi/mod.rs b/pgdog/src/auth/gssapi/mod.rs index 6c946a069..ee2c7dacb 100644 --- a/pgdog/src/auth/gssapi/mod.rs +++ b/pgdog/src/auth/gssapi/mod.rs @@ -4,7 +4,9 @@ //! frontend (client to PGDog) and backend (PGDog to PostgreSQL) connections. pub mod context; +mod credential_provider; pub mod error; +mod scoped_env; pub mod server; pub mod ticket_cache; pub mod ticket_manager; @@ -13,7 +15,9 @@ pub mod ticket_manager; mod tests; pub use context::GssapiContext; +pub use credential_provider::{with_acceptor_lock, PrincipalLocks}; pub use error::{GssapiError, Result}; +pub use scoped_env::ScopedEnv; pub use server::GssapiServer; pub use ticket_cache::TicketCache; pub use ticket_manager::TicketManager; diff --git a/pgdog/src/auth/gssapi/scoped_env.rs b/pgdog/src/auth/gssapi/scoped_env.rs new file mode 100644 index 000000000..11b90d1bf --- /dev/null +++ b/pgdog/src/auth/gssapi/scoped_env.rs @@ -0,0 +1,114 @@ +use once_cell::sync::Lazy; +use parking_lot::{Mutex, MutexGuard}; +use std::collections::HashMap; +use std::env; + +static ENV_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + +/// Scoped guard that applies environment overrides while holding a global lock. +/// Restores previous values (or absence) when dropped. +pub struct ScopedEnv { + _lock: MutexGuard<'static, ()>, + previous: HashMap<&'static str, Option>, +} + +impl ScopedEnv { + /// Applies the provided environment overrides while taking the global lock. + /// Values set to `None` remove the variable for the duration of the guard. + pub fn set(overrides: I) -> Self + where + I: IntoIterator)>, + S: Into, + { + let lock = ENV_LOCK.lock(); + let mut previous = HashMap::new(); + + for (key, maybe_value) in overrides { + let prior = env::var(key).ok(); + previous.insert(key, prior); + + match maybe_value { + Some(value) => env::set_var(key, value.into()), + None => env::remove_var(key), + } + } + + Self { + _lock: lock, + previous, + } + } +} + +impl Drop for ScopedEnv { + fn drop(&mut self) { + for (key, prior) in self.previous.drain() { + match prior { + Some(value) => env::set_var(key, value), + None => env::remove_var(key), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::ScopedEnv; + use std::env; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::thread; + use std::time::Duration; + + #[test] + fn restores_original_value() { + env::set_var("PGDOG_TEST_VAR", "initial"); + + { + let _guard = ScopedEnv::set([("PGDOG_TEST_VAR", Some("override"))]); + assert_eq!(env::var("PGDOG_TEST_VAR").unwrap(), "override"); + } + + assert_eq!(env::var("PGDOG_TEST_VAR").unwrap(), "initial"); + env::remove_var("PGDOG_TEST_VAR"); + } + + #[test] + fn restores_absence() { + env::remove_var("PGDOG_TEST_VAR"); + { + let _guard = ScopedEnv::set([("PGDOG_TEST_VAR", Some("override"))]); + assert_eq!(env::var("PGDOG_TEST_VAR").unwrap(), "override"); + } + assert!(env::var("PGDOG_TEST_VAR").is_err()); + } + + #[test] + fn serializes_access() { + static KEY: &str = "PGDOG_TEST_VAR"; + env::set_var(KEY, "start"); + + let active = Arc::new(AtomicUsize::new(0)); + let max_seen = Arc::new(AtomicUsize::new(0)); + + let mut handles = Vec::new(); + for _ in 0..4 { + let active = Arc::clone(&active); + let max_seen = Arc::clone(&max_seen); + handles.push(thread::spawn(move || { + let _guard = ScopedEnv::set([(KEY, Some("busy"))]); + let now = active.fetch_add(1, Ordering::SeqCst) + 1; + max_seen.fetch_max(now, Ordering::SeqCst); + thread::sleep(Duration::from_millis(10)); + active.fetch_sub(1, Ordering::SeqCst); + })); + } + + for handle in handles { + handle.join().unwrap(); + } + + assert_eq!(max_seen.load(Ordering::SeqCst), 1); + assert_eq!(env::var(KEY).unwrap(), "start"); + } +} diff --git a/pgdog/src/auth/gssapi/server.rs b/pgdog/src/auth/gssapi/server.rs index 64afff9c1..3b39b7708 100644 --- a/pgdog/src/auth/gssapi/server.rs +++ b/pgdog/src/auth/gssapi/server.rs @@ -40,46 +40,49 @@ pub struct GssapiServer { impl GssapiServer { /// Create a new acceptor context. pub fn new_acceptor(keytab: impl AsRef, principal: Option<&str>) -> Result { - let keytab = keytab.as_ref(); - - // Set the keytab for the server - std::env::set_var("KRB5_KTNAME", keytab); - - // Create credentials for accepting - let credential = if let Some(principal) = principal { - // Parse the service principal (use KRB5_PRINCIPAL to avoid canonicalization) - let service_name = Name::new(principal.as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL)) - .map_err(|e| GssapiError::InvalidPrincipal(format!("{}: {}", principal, e)))?; - - // Create the desired mechanisms set - let mut desired_mechs = OidSet::new() - .map_err(|e| GssapiError::LibGssapi(format!("failed to create OidSet: {}", e)))?; - desired_mechs - .add(&GSS_MECH_KRB5) - .map_err(|e| GssapiError::LibGssapi(format!("failed to add mechanism: {}", e)))?; - - // Acquire credentials for the specified principal - Cred::acquire( - Some(&service_name), - None, - CredUsage::Accept, - Some(&desired_mechs), - ) - .map_err(|e| { - GssapiError::CredentialAcquisitionFailed(format!( - "failed to acquire credentials for {}: {}", - principal, e - )) - })? - } else { - // Use default service principal - Cred::acquire(None, None, CredUsage::Accept, None).map_err(|e| { - GssapiError::CredentialAcquisitionFailed(format!( - "failed to acquire default credentials: {}", - e - )) - })? - }; + let keytab = keytab.as_ref().to_path_buf(); + + let credential = super::with_acceptor_lock(|| { + let guard = super::ScopedEnv::set([( + "KRB5_KTNAME", + Some(keytab.to_string_lossy().into_owned()), + )]); + + let result = if let Some(principal) = principal { + let service_name = Name::new(principal.as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL)) + .map_err(|e| GssapiError::InvalidPrincipal(format!("{}: {}", principal, e)))?; + + let mut desired_mechs = OidSet::new().map_err(|e| { + GssapiError::LibGssapi(format!("failed to create OidSet: {}", e)) + })?; + desired_mechs.add(&GSS_MECH_KRB5).map_err(|e| { + GssapiError::LibGssapi(format!("failed to add mechanism: {}", e)) + })?; + + Cred::acquire( + Some(&service_name), + None, + CredUsage::Accept, + Some(&desired_mechs), + ) + .map_err(|e| { + GssapiError::CredentialAcquisitionFailed(format!( + "failed to acquire credentials for {}: {}", + principal, e + )) + }) + } else { + Cred::acquire(None, None, CredUsage::Accept, None).map_err(|e| { + GssapiError::CredentialAcquisitionFailed(format!( + "failed to acquire default credentials: {}", + e + )) + }) + }; + + drop(guard); + result + })?; Ok(Self { inner: None, @@ -95,10 +98,6 @@ impl GssapiServer { "GssapiServer::accept called with token of {} bytes", client_token.len() ); - tracing::trace!( - "Token first 20 bytes: {:?}", - &client_token[..client_token.len().min(20)] - ); if self.is_complete { tracing::warn!("GssapiServer::accept called but context already complete"); @@ -128,10 +127,6 @@ impl GssapiServer { "ctx.step returned response token of {} bytes - negotiation continues", response.len() ); - tracing::trace!( - "Response token first 20 bytes: {:?}", - &response[..response.len().min(20)] - ); // Check if context is actually established despite returning a token if ctx.is_complete() { @@ -240,4 +235,21 @@ mod tests { // We expect this to fail without a real keytab or when feature is disabled assert!(result.is_err()); } + + #[test] + fn test_server_env_not_leaked_on_error() { + const KEY: &str = "KRB5_KTNAME"; + let original = std::env::var(KEY).ok(); + std::env::remove_var(KEY); + + let _ = GssapiServer::new_acceptor("/missing/keytab", Some("postgres/test@REALM")); + + let after = std::env::var(KEY); + assert!(after.is_err(), "{} should not remain set", KEY); + + match original { + Some(value) => std::env::set_var(KEY, value), + None => std::env::remove_var(KEY), + } + } } diff --git a/pgdog/src/auth/gssapi/tests.rs b/pgdog/src/auth/gssapi/tests.rs index d7484bac0..2c969a4fe 100644 --- a/pgdog/src/auth/gssapi/tests.rs +++ b/pgdog/src/auth/gssapi/tests.rs @@ -97,7 +97,7 @@ mod tests { #[cfg(not(feature = "gssapi"))] #[tokio::test] async fn test_mock_ticket_cache() { - let cache = TicketCache::new("test@REALM", PathBuf::from("/etc/test.keytab")); + let cache = TicketCache::new("test@REALM", PathBuf::from("/etc/test.keytab"), None); assert_eq!(cache.principal(), "test@REALM"); assert_eq!(cache.keytab_path(), &PathBuf::from("/etc/test.keytab")); assert!(!cache.needs_refresh()); diff --git a/pgdog/src/auth/gssapi/ticket_cache.rs b/pgdog/src/auth/gssapi/ticket_cache.rs index 08e13e24c..1299807a5 100644 --- a/pgdog/src/auth/gssapi/ticket_cache.rs +++ b/pgdog/src/auth/gssapi/ticket_cache.rs @@ -4,17 +4,23 @@ use super::error::{GssapiError, Result}; use parking_lot::RwLock; use std::path::PathBuf; use std::time::{Duration, Instant}; -use tokio::task::spawn_blocking; -use tokio::time::timeout; #[cfg(feature = "gssapi")] -use std::sync::Arc; +use crate::auth::gssapi::ScopedEnv; #[cfg(feature = "gssapi")] -use libgssapi::{ - credential::{Cred, CredUsage}, - name::Name, - oid::{OidSet, GSS_MECH_KRB5, GSS_NT_KRB5_PRINCIPAL}, +use std::fs; + +#[cfg(feature = "gssapi")] +use { + libgssapi::{ + credential::{Cred, CredUsage}, + name::Name, + oid::{OidSet, GSS_MECH_KRB5, GSS_NT_KRB5_PRINCIPAL}, + }, + std::sync::Arc, + tokio::task::spawn_blocking, + uuid::Uuid, }; /// Cache for a single server's Kerberos ticket @@ -24,6 +30,10 @@ pub struct TicketCache { principal: String, /// Path to the keytab file keytab_path: PathBuf, + /// Credential cache backing file + cache_file: parking_lot::Mutex>>, + /// Optional krb5 configuration path to set while acquiring + krb5_config: Option, /// The acquired credential (if any) credential: RwLock>>, /// When the ticket was last refreshed @@ -48,10 +58,16 @@ pub struct TicketCache { #[cfg(feature = "gssapi")] impl TicketCache { /// Create a new ticket cache - pub fn new(principal: impl Into, keytab_path: impl Into) -> Self { + pub fn new( + principal: impl Into, + keytab_path: impl Into, + krb5_config: Option, + ) -> Self { Self { principal: principal.into(), keytab_path: keytab_path.into(), + cache_file: parking_lot::Mutex::new(None), + krb5_config, credential: RwLock::new(None), last_refresh: RwLock::new(Instant::now()), refresh_interval: Duration::from_secs(14400), // 4 hours default @@ -80,134 +96,83 @@ impl TicketCache { return Err(GssapiError::KeytabNotFound(self.keytab_path.clone())); } - // Create a unique credential cache for this principal to avoid conflicts - // This prevents "Principal in credential cache does not match desired name" errors - // when the environment has a cache with a different principal - let cache_file = format!( - "/tmp/krb5cc_pgdog_{}_{}", - self.principal.replace(['@', '.', '/'], "_"), - std::process::id() - ); - let cache_path = format!("FILE:{}", cache_file); - - // Clone values needed in the async task + let cache_file = { + let mut guard = self.cache_file.lock(); + if guard.is_none() { + match CacheFile::new(&self.principal) { + Ok(file) => { + guard.replace(Arc::new(file)); + } + Err(e) => { + return Err(GssapiError::CredentialAcquisitionFailed(format!( + "failed to prepare credential cache for {}: {}", + self.principal, e + ))); + } + } + } + guard.as_ref().unwrap().clone() + }; + let principal = self.principal.clone(); let keytab_path = self.keytab_path.clone(); + let cache_uri = cache_file.uri(); - // Use kinit to acquire the ticket with proper timeout - // This avoids the blocking issue with libgssapi's Cred::acquire - let kinit_result = timeout(Duration::from_secs(5), async { - let mut command = tokio::process::Command::new("kinit"); - command - .arg("-kt") - .arg(&keytab_path) - .arg(&principal) - .env("KRB5CCNAME", &cache_path) - .kill_on_drop(true); // Important: kill the process if we drop the handle - - // Try to find and set KRB5_CONFIG if available - for path in &["/opt/homebrew/etc/krb5.conf", "/etc/krb5.conf"] { - if tokio::fs::metadata(path).await.is_ok() { - command.env("KRB5_CONFIG", path); - break; - } - } + let krb5_config = self.krb5_config.clone(); - command.output().await - }) - .await; - - let credential = match kinit_result { - Ok(Ok(output)) if output.status.success() => { - // kinit succeeded, now acquire the credential from the cache - // Set up the environment - std::env::set_var("KRB5CCNAME", &cache_path); - std::env::set_var("KRB5_CLIENT_KTNAME", &keytab_path); - - // Use spawn_blocking but with the credential already in cache - // This should be fast and not block - timeout( - Duration::from_millis(500), // Much shorter timeout since creds should be in cache - spawn_blocking(move || -> std::result::Result { - // Parse the principal name - let name = Name::new(principal.as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL)) - .map_err(|e| { - GssapiError::InvalidPrincipal(format!("{}: {}", principal, e)) - })?; - - // Create the desired mechanisms set - let mut desired_mechs = OidSet::new().map_err(|e| { - GssapiError::LibGssapi(format!("failed to create OidSet: {}", e)) - })?; - desired_mechs.add(&GSS_MECH_KRB5).map_err(|e| { - GssapiError::LibGssapi(format!("failed to add mechanism: {}", e)) - })?; - - // Acquire credentials from the cache that kinit populated - Cred::acquire(Some(&name), None, CredUsage::Initiate, Some(&desired_mechs)) - .map_err(|e| { - GssapiError::CredentialAcquisitionFailed(format!( - "failed to acquire from cache for {}: {}", - principal, e - )) - }) - }), - ) - .await - } - Ok(Ok(output)) => { - // kinit failed - return error immediately without trying libgssapi - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(GssapiError::CredentialAcquisitionFailed(format!( - "kinit failed for {}: {}", - self.principal, stderr - ))); - } - Ok(Err(e)) => { - // Failed to run kinit - return Err(GssapiError::CredentialAcquisitionFailed(format!( - "failed to run kinit for {}: {}", - self.principal, e - ))); - } - Err(_) => { - // Timeout expired - return Err(GssapiError::CredentialAcquisitionFailed(format!( - "kinit timed out after 5 seconds for {}", - self.principal - ))); + let acquire = spawn_blocking(move || -> Result { + let mut overrides: Vec<(&'static str, Option)> = vec![ + ("KRB5CCNAME", Some(cache_uri.clone())), + ( + "KRB5_CLIENT_KTNAME", + Some(keytab_path.to_string_lossy().into_owned()), + ), + ]; + + if let Some(config) = &krb5_config { + overrides.push(("KRB5_CONFIG", Some(config.clone()))); } - }; - match credential { - Ok(Ok(Ok(cred))) => { - // Successfully acquired credential from cache + let guard = ScopedEnv::set(overrides); + + let name = Name::new(principal.as_bytes(), Some(&GSS_NT_KRB5_PRINCIPAL)) + .map_err(|e| GssapiError::InvalidPrincipal(format!("{}: {}", principal, e)))?; + + let mut desired_mechs = OidSet::new() + .map_err(|e| GssapiError::LibGssapi(format!("failed to create OidSet: {}", e)))?; + + desired_mechs + .add(&GSS_MECH_KRB5) + .map_err(|e| GssapiError::LibGssapi(format!("failed to add mechanism: {}", e)))?; + + Cred::acquire(Some(&name), None, CredUsage::Initiate, Some(&desired_mechs)) + .map_err(|e| { + GssapiError::CredentialAcquisitionFailed(format!( + "credential acquisition failed for {}: {}", + principal, e + )) + }) + .map(|cred| { + drop(guard); + cred + }) + }) + .await + .map_err(|_| { + GssapiError::CredentialAcquisitionFailed(format!( + "credential acquisition task panicked for {}", + self.principal + )) + })?; + + match acquire { + Ok(cred) => { let cred_arc: Arc = Arc::new(cred); - - // Store the credential *self.credential.write() = Some(cred_arc.clone()); *self.last_refresh.write() = Instant::now(); - Ok(cred_arc) } - Ok(Ok(Err(e))) => { - // Failed to acquire from cache - Err(e) - } - Ok(Err(_)) => { - // spawn_blocking task panicked - Err(GssapiError::CredentialAcquisitionFailed(format!( - "credential acquisition task panicked for {}", - self.principal - ))) - } - Err(_) => { - // Timeout on credential acquisition from cache - Err(GssapiError::CredentialAcquisitionFailed(format!( - "failed to acquire credentials from cache (timed out) for {}", - self.principal - ))) - } + Err(err) => Err(err), } } @@ -244,13 +209,96 @@ impl TicketCache { /// Clear the cached credential pub fn clear(&self) { *self.credential.write() = None; + self.ensure_cache_removed(); + } + + fn ensure_cache_removed(&self) { + if let Some(cache) = self.cache_file.lock().take() { + cache.cleanup(); + } + } +} + +#[cfg(feature = "gssapi")] +impl Drop for TicketCache { + fn drop(&mut self) { + self.ensure_cache_removed(); + } +} + +#[cfg(feature = "gssapi")] +#[derive(Debug)] +struct CacheFile { + path: PathBuf, +} + +#[cfg(feature = "gssapi")] +impl CacheFile { + fn new(principal: &str) -> std::io::Result { + let sanitized: String = principal + .chars() + .map(|ch| match ch { + 'A'..='Z' | 'a'..='z' | '0'..='9' => ch, + _ => '_', + }) + .collect(); + + let filename = format!("krb5cc_pgdog_{}_{}", sanitized, Uuid::new_v4()); + let path = std::env::temp_dir().join(filename); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let _ = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&path)?; + } + + #[cfg(not(unix))] + { + let _ = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&path)?; + } + + Ok(Self { path }) + } + + fn uri(&self) -> String { + format!("FILE:{}", self.path.display()) + } + + fn cleanup(&self) { + let _ = fs::remove_file(&self.path); + } +} + +#[cfg(feature = "gssapi")] +impl Drop for CacheFile { + fn drop(&mut self) { + self.cleanup(); } } #[cfg(not(feature = "gssapi"))] impl TicketCache { /// Create a new ticket cache - pub fn new(principal: impl Into, keytab_path: impl Into) -> Self { + pub fn new( + principal: impl Into, + keytab_path: impl Into, + _krb5_config: Option, + ) -> Self { Self { principal: principal.into(), keytab_path: keytab_path.into(), @@ -315,14 +363,14 @@ mod tests { #[test] fn test_ticket_cache_creation() { - let cache = TicketCache::new("test@EXAMPLE.COM", "/etc/test.keytab"); + let cache = TicketCache::new("test@EXAMPLE.COM", "/etc/test.keytab", None); assert_eq!(cache.principal(), "test@EXAMPLE.COM"); assert_eq!(cache.keytab_path(), &PathBuf::from("/etc/test.keytab")); } #[tokio::test] async fn test_missing_keytab_error() { - let cache = TicketCache::new("test@REALM", "/nonexistent/keytab"); + let cache = TicketCache::new("test@REALM", "/nonexistent/keytab", None); #[cfg(feature = "gssapi")] { let result = cache.acquire_ticket().await; @@ -349,7 +397,7 @@ mod tests { #[test] fn test_refresh_interval() { - let mut cache = TicketCache::new("test@REALM", "/etc/test.keytab"); + let mut cache = TicketCache::new("test@REALM", "/etc/test.keytab", None); cache.set_refresh_interval(Duration::from_secs(3600)); assert!(!cache.needs_refresh()); // Just created, doesn't need refresh } diff --git a/pgdog/src/auth/gssapi/ticket_manager.rs b/pgdog/src/auth/gssapi/ticket_manager.rs index c856c62ce..410c3e5d3 100644 --- a/pgdog/src/auth/gssapi/ticket_manager.rs +++ b/pgdog/src/auth/gssapi/ticket_manager.rs @@ -9,6 +9,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::task::JoinHandle; +#[cfg(feature = "gssapi")] +use super::credential_provider::PrincipalLocks; + lazy_static! { /// Global ticket manager instance static ref INSTANCE: Arc = Arc::new(TicketManager::new()); @@ -20,6 +23,9 @@ pub struct TicketManager { caches: Arc>>, /// Background refresh tasks refresh_tasks: Arc>>, + #[cfg(feature = "gssapi")] + // Per-principal acquisition locks + principal_locks: PrincipalLocks, } impl TicketManager { @@ -28,6 +34,8 @@ impl TicketManager { Self { caches: Arc::new(DashMap::new()), refresh_tasks: Arc::new(DashMap::new()), + #[cfg(feature = "gssapi")] + principal_locks: PrincipalLocks::new(), } } @@ -37,6 +45,7 @@ impl TicketManager { } /// Get the appropriate krb5.conf path for the current system + #[cfg(feature = "gssapi")] fn get_krb5_config() -> Option { // First check if KRB5_CONFIG environment variable is set if let Ok(config) = std::env::var("KRB5_CONFIG") { @@ -72,57 +81,35 @@ impl TicketManager { let keytab_path = keytab.as_ref().to_path_buf(); let principal = principal.into(); - // Check if we already have a cache for this server if self.caches.contains_key(&server) { - // Cache already exists and environment is set return Ok(()); } - // Create a unique credential cache file for this server connection - let cache_file = format!("/tmp/krb5cc_pgdog_{}", server.replace(":", "_")); - let cache_path = format!("FILE:{}", cache_file); - - // Set the environment variable for this thread - std::env::set_var("KRB5CCNAME", &cache_path); - - // Use kinit to get a ticket from the keytab into the unique cache - let mut command = tokio::process::Command::new("kinit"); - command - .arg("-kt") - .arg(&keytab_path) - .arg(&principal) - .env("KRB5CCNAME", &cache_path); - - // Set KRB5_CONFIG if we can find it - if let Some(krb5_config) = Self::get_krb5_config() { - command.env("KRB5_CONFIG", krb5_config); - } - - let output = command.output().await.map_err(|e| { - super::error::GssapiError::LibGssapi(format!("failed to run kinit: {}", e)) - })?; - - if !output.status.success() { - return Err(super::error::GssapiError::CredentialAcquisitionFailed( - format!( - "kinit failed for {}: {}", - principal, - String::from_utf8_lossy(&output.stderr) - ), - )); - } + let caches = Arc::clone(&self.caches); + let manager = self; + let server_clone = server.clone(); + let principal_clone = principal.clone(); + let krb5_config = Self::get_krb5_config(); - // Create a new cache object for tracking (but don't acquire credentials - kinit already did that) - let cache = Arc::new(TicketCache::new(principal, keytab_path)); + self.principal_locks + .with_principal(&principal, || async move { + if caches.contains_key(&server_clone) { + return Ok(()); + } - // Store the cache - self.caches.insert(server.clone(), cache.clone()); + let cache = Arc::new(TicketCache::new( + principal_clone.clone(), + keytab_path.clone(), + krb5_config.clone(), + )); - // Start background refresh task - self.start_refresh_task(server, cache); + cache.get_credential().await.map(|_| ())?; - // Return success - the credential cache is now populated and KRB5CCNAME is set - Ok(()) + caches.insert(server_clone.clone(), cache.clone()); + manager.start_refresh_task(server_clone, cache); + Ok(()) + }) + .await } /// Get or acquire a ticket for a server (mock version) @@ -141,7 +128,6 @@ impl TicketManager { /// Start a background refresh task for a cache #[allow(dead_code)] fn start_refresh_task(&self, server: String, cache: Arc) { - let _refresh_tasks = self.refresh_tasks.clone(); let server_clone = server.clone(); let task = tokio::spawn(async move { @@ -261,6 +247,49 @@ mod tests { assert_eq!(manager.cache_count(), 0); } + #[cfg(feature = "gssapi")] + #[tokio::test] + async fn test_get_ticket_restores_env_on_error() { + struct EnvGuard { + key: &'static str, + original: Option, + } + + impl EnvGuard { + fn new(key: &'static str, replacement: &str) -> Self { + let original = std::env::var(key).ok(); + std::env::set_var(key, replacement); + Self { key, original } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.original.as_ref() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + + let guard = EnvGuard::new("KRB5CCNAME", "FILE:pgdog-test-original"); + + let manager = TicketManager::new(); + let result = manager + .get_ticket( + "testhost:5432", + "/definitely/missing.keytab", + "missing@REALM", + ) + .await; + assert!(result.is_err()); + + let current = std::env::var("KRB5CCNAME").expect("env var should exist"); + assert_eq!(current, "FILE:pgdog-test-original"); + + drop(guard); + } + #[test] fn test_shutdown() { let manager = TicketManager::new(); diff --git a/pgdog/tests/gssapi_test.rs b/pgdog/tests/gssapi_test.rs index afaeab19e..5d0daae71 100644 --- a/pgdog/tests/gssapi_test.rs +++ b/pgdog/tests/gssapi_test.rs @@ -96,7 +96,7 @@ async fn test_ticket_cache_acquires_credential() { ); let principal = "test@PGDOG.LOCAL"; - let cache = TicketCache::new(principal, keytab_path); + let cache = TicketCache::new(principal, keytab_path, None); let ticket = cache.acquire_ticket().await; assert!( @@ -161,7 +161,11 @@ async fn test_frontend_authentication() { #[tokio::test] async fn test_missing_keytab_error() { - let cache = TicketCache::new("test@PGDOG.LOCAL", PathBuf::from("/nonexistent/keytab")); + let cache = TicketCache::new( + "test@PGDOG.LOCAL", + PathBuf::from("/nonexistent/keytab"), + None, + ); let ticket = cache.acquire_ticket().await; assert!(ticket.is_err());