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/.github/workflows/ci.yml b/.github/workflows/ci.yml index e32debdf4..799bbdaf3 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,18 @@ 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 DEBIAN_FRONTEND=noninteractive 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,18 @@ jobs: - uses: useblacksmith/rust-cache@v3 with: prefix-key: "v1" # Change this when updating tooling + - name: Install Kerberos dependencies + run: | + sudo apt update + 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 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/ - name: Setup PostgreSQL run: | sudo service postgresql start @@ -59,7 +75,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 clang pkg-config protobuf-compiler sudo gem install bundler sudo apt remove -y cmake sudo pip3 install cmake==3.31.6 @@ -68,10 +84,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: @@ -92,7 +108,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/.gitignore b/.gitignore index 4445363c2..cc18ecd72 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/ @@ -53,3 +54,5 @@ CLAUDE.local.md pgdog-plugin/src/bindings.rs local/ integration/log.txt +integration/gssapi/keytabs/*.keytab +pgdog/docs/*.md 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/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/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/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..c4a03495c --- /dev/null +++ b/integration/gssapi/setup_test_keytabs.sh @@ -0,0 +1,596 @@ +#!/bin/bash + +# 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="password" +OS=$(uname -s) +REALM="PGDOG.LOCAL" +DOMAIN="pgdog.local" +KDC_PASSWORD="admin123" +SUCCESS_COUNT=0 +WARNING_COUNT=0 + +# Silent mode by default, verbose with DEBUG +[ -n "$DEBUG" ] && set -x + +log() { + [ -n "$VERBOSE" ] && echo "$@" || true +} + +error() { + echo "ERROR: $@" >&2 +} + +# 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 + command -v "$tool" 2>/dev/null || 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 +} + +# Setup environment based on OS +setup_kerberos_env() { + if [ "$OS" = "Darwin" ]; then + # macOS with Homebrew + 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" + 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 +create_krb5_conf() { + local config_file="$1" + local config_dir=$(dirname "$config_file") + + # 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..." + + # 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") + + # 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..." + + # 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 [ -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 + 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 + 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 + 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 +} + +# Start KDC services +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 + 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 + # 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 + + # 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 +check_kdc_running() { + local kadmin_local="$1" + [ -z "$kadmin_local" ] && return 1 + + 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 +} + +# Create a principal with recovery +create_principal() { + local kadmin_local="$1" + local principal="$2" + local password="$3" + + # Check if principal exists + 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 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 -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 + + log " Warning: Could not create principal $principal" + return 1 +} + +# Export to keytab with recovery +export_to_keytab() { + local kadmin_local="$1" + local principal="$2" + local keytab_file="$3" + + log " Exporting $principal to keytab..." + + rm -f "$keytab_file" 2>/dev/null || true + + # Try export + 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 + log " ✓ Exported to keytab" + return 0 + fi + + log " Warning: Could not export $principal to keytab" + if [ -n "$VERBOSE" ]; then + log " Export output: $output" + fi + return 1 +} + +# Verify keytab +verify_keytab() { + local keytab_file="$1" + local principal="$2" + + local kinit=$(find_tool "kinit") + local kdestroy=$(find_tool "kdestroy") + + [ -z "$kinit" ] && return 1 + + log " Verifying keytab for $principal..." + + 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 + fi + + log " Warning: Keytab verification failed for $principal" + return 1 +} + +# Main setup function +main() { + 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" + + # 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 + + # 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 + fi + fi + fi + + # Step 8: Create keytab directory + mkdir -p "${KEYTAB_DIR}" + + # Step 9: Define principals + declare -a principals=( + "test@$REALM" + "pgdog-test@$REALM" + "server1@$REALM" + "server2@$REALM" + "principal1@$REALM" + "principal2@$REALM" + ) + + # 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" + + log "" + log "Processing $principal:" + + 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 + + # Step 11: Create additional keytabs + log "" + log "Creating additional test keytabs..." + + if [ -f "${KEYTAB_DIR}/pgdog-test.keytab" ]; then + cp "${KEYTAB_DIR}/pgdog-test.keytab" "${KEYTAB_DIR}/backend.keytab" + log " Created backend.keytab" + fi + + if [ -f "${KEYTAB_DIR}/principal1.keytab" ]; then + cp "${KEYTAB_DIR}/principal1.keytab" "${KEYTAB_DIR}/keytab1.keytab" + log " Created keytab1.keytab" + elif [ -f "${KEYTAB_DIR}/server1.keytab" ]; then + cp "${KEYTAB_DIR}/server1.keytab" "${KEYTAB_DIR}/keytab1.keytab" + log " Created keytab1.keytab" + fi + + if [ -f "${KEYTAB_DIR}/principal2.keytab" ]; then + cp "${KEYTAB_DIR}/principal2.keytab" "${KEYTAB_DIR}/keytab2.keytab" + log " Created keytab2.keytab" + elif [ -f "${KEYTAB_DIR}/server2.keytab" ]; then + cp "${KEYTAB_DIR}/server2.keytab" "${KEYTAB_DIR}/keytab2.keytab" + log " Created keytab2.keytab" + fi + + # Step 12: Create test users configuration + cat > "${SCRIPT_DIR}/test_users.toml" << EOF +# Test users for GSSAPI authentication testing + +[[user]] +name = "test" +principal = "test@$REALM" +strip_realm = true + +[[user]] +name = "pgdog-test" +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" +strip_realm = false + +[[user]] +name = "principal2" +principal = "principal2@$REALM" +strip_realm = false +EOF + + # 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 +main "$@" \ 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..3543de21b --- /dev/null +++ b/integration/gssapi/test_users.toml @@ -0,0 +1,31 @@ +# 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 = "server1" +principal = "server1@PGDOG.LOCAL" +strip_realm = true + +[[user]] +name = "server2" +principal = "server2@PGDOG.LOCAL" +strip_realm = true + +[[user]] +name = "principal1" +principal = "principal1@PGDOG.LOCAL" +strip_realm = false + +[[user]] +name = "principal2" +principal = "principal2@PGDOG.LOCAL" +strip_realm = false diff --git a/integration/setup.sh b/integration/setup.sh index 913732dd1..30c4ad624 100644 --- a/integration/setup.sh +++ b/integration/setup.sh @@ -62,4 +62,21 @@ for bin in toxiproxy-server toxiproxy-cli; do fi done +# 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..." + # 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 (not in CI and Kerberos tools not found)" +fi + popd diff --git a/pgdog/Cargo.toml b/pgdog/Cargo.toml index d8a763ec9..5596f6773 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,6 +61,11 @@ indexmap = "2.9" lru = "0.16" hickory-resolver = "0.25.2" lazy_static = "1" +dashmap = "5.5" + +[dependencies.libgssapi] +version = "0.9.1" +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 new file mode 100644 index 000000000..cdf608da0 --- /dev/null +++ b/pgdog/src/auth/gssapi/context.rs @@ -0,0 +1,230 @@ +//! 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_KRB5_PRINCIPAL}, +}; + +/// Wrapper for GSSAPI security context +#[cfg(feature = "gssapi")] +pub struct GssapiContext { + /// The underlying libgssapi context + inner: ClientCtx, + /// The target service principal + target_principal: String, + /// Whether the context is complete + 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( + keytab: impl AsRef, + principal: impl Into, + target: impl Into, + ) -> Result { + 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())); + } + + // 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)))?; + + // 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( + Some(&principal_name), // Try specific principal first + None, + CredUsage::Initiate, + Some(&desired_mechs), + ) { + Ok(cred) => cred, + Err(cache_err) => { + // If cache acquisition fails (including principal mismatch), + // try acquiring from keytab with a fresh cache + // Set scoped environment so we don't leak global state. + let cache_uri = format!( + "FILE:/tmp/krb5cc_pgdog_context_{}_{}", + principal.replace(['@', '.', '/'], "_"), + std::process::id() + ); + 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, + Some(&desired_mechs), + ) + .map_err(|e| { + GssapiError::CredentialAcquisitionFailed(format!( + "failed for {} using keytab {}: {} (initial error: {})", + principal, + keytab.display(), + e, + cache_err + )) + }); + drop(guard); + result? + } + }; + + // 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; + + 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(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::*; + + #[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", + ); + + #[cfg(feature = "gssapi")] + assert!(result.is_err()); + + #[cfg(not(feature = "gssapi"))] + assert!(result.is_ok()); + } +} 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/error.rs b/pgdog/src/auth/gssapi/error.rs new file mode 100644 index 000000000..f6643f5c3 --- /dev/null +++ b/pgdog/src/auth/gssapi/error.rs @@ -0,0 +1,59 @@ +//! GSSAPI-specific error types + +use std::path::PathBuf; +use thiserror::Error; + +/// Result type for GSSAPI operations +pub type Result = std::result::Result; + +/// GSSAPI-specific errors +#[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 + #[error("{0}")] + Io(#[from] std::io::Error), + + /// Configuration error + #[error("configuration error: {0}")] + Config(String), +} + +// 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 new file mode 100644 index 000000000..ee2c7dacb --- /dev/null +++ b/pgdog/src/auth/gssapi/mod.rs @@ -0,0 +1,113 @@ +//! 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; +mod credential_provider; +pub mod error; +mod scoped_env; +pub mod server; +pub mod ticket_cache; +pub mod ticket_manager; + +#[cfg(test)] +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; + +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Handle GSSAPI authentication from a client +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) => { + // Check if authentication is complete despite having a token + if server.is_complete() { + let principal = server + .client_principal() + .ok_or_else(|| { + GssapiError::ContextError("no client principal found".to_string()) + })? + .to_string(); + + tracing::debug!( + "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::debug!( + "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::debug!("server.accept returned None - authentication complete"); + let principal = server + .client_principal() + .ok_or_else(|| GssapiError::ContextError("No client principal found".to_string()))? + .to_string(); + + tracing::debug!("successfully extracted principal: {}", principal); + let response = GssapiResponse { + is_complete: true, + token: None, + principal: Some(principal.clone()), + }; + tracing::debug!( + "Returning GssapiResponse: is_complete=true, has_token=false, principal={}", + principal + ); + Ok(response) + } + } +} + +/// 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/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 new file mode 100644 index 000000000..3b39b7708 --- /dev/null +++ b/pgdog/src/auth/gssapi/server.rs @@ -0,0 +1,255 @@ +//! Server-side GSSAPI authentication handler. + +use super::error::{GssapiError, Result}; +use std::path::Path; + +#[cfg(feature = "gssapi")] +use std::sync::Arc; + +#[cfg(feature = "gssapi")] +use libgssapi::{ + context::{SecurityContext, ServerCtx}, + credential::{Cred, CredUsage}, + name::Name, + oid::{OidSet, GSS_MECH_KRB5, GSS_NT_KRB5_PRINCIPAL}, +}; + +/// Server-side GSSAPI context for accepting client connections. +#[cfg(feature = "gssapi")] +pub struct GssapiServer { + /// The underlying libgssapi server context. + inner: Option, + /// Server credentials. + credential: Arc, + /// 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().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, + credential: Arc::new(credential), + is_complete: false, + client_principal: None, + }) + } + + /// 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() + ); + + if self.is_complete { + tracing::warn!("GssapiServer::accept called but context already complete"); + return Err(GssapiError::ContextError( + "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"); + 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() + ); + + // 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::debug!( + "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::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::debug!("extracted client principal: {}", principal); + self.client_principal = Some(principal); + } + Err(e) => { + return Err(GssapiError::ContextError(format!( + "failed to get client principal: {}", + e + ))); + } + } + + self.inner = Some(ctx); + tracing::debug!("GssapiServer::accept returning None (success)"); + 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()); + } + + #[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 new file mode 100644 index 000000000..2c969a4fe --- /dev/null +++ b/pgdog/src/auth/gssapi/tests.rs @@ -0,0 +1,142 @@ +//! Unit tests for GSSAPI authentication module + +#[cfg(test)] +mod tests { + use super::super::*; + use std::path::PathBuf; + + #[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"))] + #[tokio::test] + async fn test_mock_ticket_cache() { + 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()); + + let result = cache.acquire_ticket().await; + 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 new file mode 100644 index 000000000..1299807a5 --- /dev/null +++ b/pgdog/src/auth/gssapi/ticket_cache.rs @@ -0,0 +1,404 @@ +//! 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 crate::auth::gssapi::ScopedEnv; + +#[cfg(feature = "gssapi")] +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 +#[cfg(feature = "gssapi")] +pub struct TicketCache { + /// The principal for this cache + 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 + last_refresh: RwLock, + /// How often to refresh the ticket + 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, + 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 + } + } + + /// 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 async fn acquire_ticket(&self) -> Result> { + // Check if keytab exists + if !self.keytab_path.exists() { + return Err(GssapiError::KeytabNotFound(self.keytab_path.clone())); + } + + 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(); + + let krb5_config = self.krb5_config.clone(); + + 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()))); + } + + 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); + *self.credential.write() = Some(cred_arc.clone()); + *self.last_refresh.write() = Instant::now(); + Ok(cred_arc) + } + Err(err) => Err(err), + } + } + + /// Get the cached credential, acquiring it if necessary + 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 + if self.last_refresh.read().elapsed() < self.refresh_interval { + return Ok(cred.clone()); + } + } + + // Need to acquire or refresh + self.acquire_ticket().await + } + + /// 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 async fn refresh(&self) -> Result<()> { + self.acquire_ticket().await?; + Ok(()) + } + + /// 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, + _krb5_config: Option, + ) -> 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 async fn acquire_ticket(&self) -> Result<()> { + Err(GssapiError::LibGssapi( + "GSSAPI support not compiled in".to_string(), + )) + } + + /// Get the cached credential (mock) + pub async 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 async 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::*; + + #[test] + fn test_ticket_cache_creation() { + 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", None); + #[cfg(feature = "gssapi")] + { + let result = cache.acquire_ticket().await; + 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().await; + assert!(result.is_err()); + match result.unwrap_err() { + GssapiError::LibGssapi(msg) => { + assert!(msg.contains("not compiled")); + } + _ => panic!("Expected LibGssapi error"), + } + } + } + + #[test] + fn test_refresh_interval() { + 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 new file mode 100644 index 000000000..410c3e5d3 --- /dev/null +++ b/pgdog/src/auth/gssapi/ticket_manager.rs @@ -0,0 +1,299 @@ +//! 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 std::path::Path; +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()); +} + +/// 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>>, + #[cfg(feature = "gssapi")] + // Per-principal acquisition locks + principal_locks: PrincipalLocks, +} + +impl TicketManager { + /// Create a new ticket manager + pub fn new() -> Self { + Self { + caches: Arc::new(DashMap::new()), + refresh_tasks: Arc::new(DashMap::new()), + #[cfg(feature = "gssapi")] + principal_locks: PrincipalLocks::new(), + } + } + + /// Get the global ticket manager instance + pub fn global() -> Arc { + INSTANCE.clone() + } + + /// 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") { + 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")] + pub async 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(); + + if self.caches.contains_key(&server) { + return Ok(()); + } + + 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(); + + self.principal_locks + .with_principal(&principal, || async move { + if caches.contains_key(&server_clone) { + return Ok(()); + } + + let cache = Arc::new(TicketCache::new( + principal_clone.clone(), + keytab_path.clone(), + krb5_config.clone(), + )); + + cache.get_credential().await.map(|_| ())?; + + 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) + #[cfg(not(feature = "gssapi"))] + pub async 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 + #[allow(dead_code)] + fn start_refresh_task(&self, server: String, cache: Arc) { + 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().await { + Ok(()) => { + tracing::info!("[gssapi] 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)); + } + + #[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") + .await; + assert!(result.is_err()); + + // Even though ticket acquisition failed, the cache should not be stored + 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(); + 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 3cb6a3a36..aa5822fa8 100644 --- a/pgdog/src/auth/scram/server.rs +++ b/pgdog/src/auth/scram/server.rs @@ -189,6 +189,12 @@ impl Server { } } } + + Password::GssapiResponse { .. } => { + // TODO: Implement GSSAPI response handling + // This will be implemented in Phase 3 + panic!("GSSAPI response handling not implemented"); + } } } diff --git a/pgdog/src/backend/pool/address.rs b/pgdog/src/backend/pool/address.rs index f438f76d3..b600802b1 100644 --- a/pgdog/src/backend/pool/address.rs +++ b/pgdog/src/backend/pool/address.rs @@ -20,11 +20,61 @@ 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, + /// GSSAPI target service principal (what we authenticate to). + pub gssapi_target_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) + }; + + // 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, @@ -33,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() { @@ -47,9 +91,17 @@ impl Address { } else { user.password().to_string() }, + 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(); @@ -74,6 +126,9 @@ impl Address { user: "pgdog".into(), password: "pgdog".into(), database_name: "pgdog".into(), + gssapi_keytab: None, + gssapi_principal: None, + gssapi_target_principal: None, } } } @@ -100,6 +155,9 @@ impl TryFrom for Address { password, user, database_name, + gssapi_keytab: None, + gssapi_principal: None, + gssapi_target_principal: None, }) } } @@ -107,6 +165,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 +213,105 @@ 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); + assert_eq!(addr.gssapi_target_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_backend_target_principal: Some("postgres/default@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()), + gssapi_target_principal: Some("postgres/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())); + assert_eq!( + addr1.gssapi_target_principal, + Some("postgres/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())); + assert_eq!( + addr2.gssapi_target_principal, + Some("postgres/default@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/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/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/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 diff --git a/pgdog/src/backend/server.rs b/pgdog/src/backend/server.rs index 9f4d1dbfa..d1765e38f 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,58 @@ impl Server { .await?; stream.flush().await?; + // Check if GSSAPI is configured for this server + let mut gssapi_context = if let (Some(keytab), Some(principal)) = + (&addr.gssapi_keytab, &addr.gssapi_principal) + { + // 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) + .await + { + 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,6 +247,71 @@ impl Server { let client = md5::Client::new_salt(&addr.user, &addr.password, &salt)?; stream.send_flush(&client.response()).await?; } + 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 + 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 + return Err(Error::ConnectionError(Box::new(ErrorResponse::auth( + &addr.user, + "SSPI authentication is not supported", + )))); + } + 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() { + return Err(Error::ConnectionError(Box::new( + ErrorResponse::auth( + &addr.user, + "GSSAPI negotiation incomplete", + ), + ))); + } + // Continue to wait for Authentication::Ok + } + Err(e) => { + return Err(Error::ConnectionError(Box::new( + ErrorResponse::from_err(&e), + ))); + } + } + } else { + return Err(Error::ConnectionError(Box::new(ErrorResponse::auth( + &addr.user, + "Received GSSAPI continue without context", + )))); + } + } } } 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/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..25805b999 100644 --- a/pgdog/src/config/database.rs +++ b/pgdog/src/config/database.rs @@ -103,6 +103,12 @@ 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, + /// 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 new file mode 100644 index 000000000..27fdc07c5 --- /dev/null +++ b/pgdog/src/config/gssapi.rs @@ -0,0 +1,201 @@ +//! 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, + + /// 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")] + 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, + default_backend_target_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}; 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 928abbbbf..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(_) => { @@ -157,30 +159,240 @@ impl Client { }; let auth_type = &config.config.general.auth_type; - let auth_ok = 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 + + // Check if GSSAPI is configured and available + let gssapi_available = config + .config + .gssapi + .as_ref() + .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(), + gssapi_config.server_principal.as_deref(), + ) { + Ok(server) => { + 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 + 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"); + break; + } + }; + + // Process token + let response = match crate::auth::gssapi::handle_gssapi_auth( + server.clone(), + client_token, + ) + .await + { + 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; + } + }; + + 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 { + let stripped = principal + .split('@') + .next() + .unwrap_or(&principal) + .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; + info!( + "GSSAPI authentication successful for principal: {} (matched user: {})", + principal, user + ); + } else { + error!( + "GSSAPI principal {} does not match requested user {}", + 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" + ); + debug!("Breaking from GSSAPI negotiation loop (error)"); + 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) + } + } + } + } + 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 + }; - (AuthType::Scram, false) => { - stream.send_flush(&Authentication::scram()).await?; + // If GSSAPI wasn't tried or failed with fallback, use regular auth + 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 + } + } - let scram = Server::new(password); - let res = scram.handle(&mut stream).await; - matches!(res, Ok(true)) - } + (AuthType::Scram, false) => { + stream.send_flush(&Authentication::scram()).await?; - (AuthType::Trust, _) => true, + let scram = Server::new(password); + let res = scram.handle(&mut stream).await; + matches!(res, Ok(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/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/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..1d7fb2192 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, } } } @@ -42,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() { @@ -75,6 +95,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 +110,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); + } +} 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/gssapi_test.rs b/pgdog/tests/gssapi_test.rs new file mode 100644 index 000000000..5d0daae71 --- /dev/null +++ b/pgdog/tests/gssapi_test.rs @@ -0,0 +1,219 @@ +//! 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, None); + let ticket = cache.acquire_ticket().await; + + assert!( + ticket.is_ok(), + "Failed to acquire ticket: {:?}", + ticket.as_ref().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"); +} + +#[tokio::test] +async fn test_missing_keytab_error() { + let cache = TicketCache::new( + "test@PGDOG.LOCAL", + PathBuf::from("/nonexistent/keytab"), + None, + ); + let ticket = cache.acquire_ticket().await; + + 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()); +}