diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5c0039..b8ae473 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,8 @@ jobs: apt-get update && \ apt-get install -y \ postgresql-18 \ - postgresql-18-statviz && \ + postgresql-18-statviz \ + openssl && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* @@ -246,6 +247,271 @@ jobs: if: always() run: docker rmi radar-integration-test || true + cert-auth-test: + name: Certificate Auth Integration Test + runs-on: ubuntu-latest + needs: lint-and-test + steps: + - name: Download Linux binary + uses: actions/download-artifact@v4 + with: + name: radar-linux + + - name: Make binary executable + run: chmod +x radar + + - name: Create Dockerfile + run: | + cat > Dockerfile.certtest <<'EOF' + FROM debian:bookworm-slim + RUN apt-get update && \ + apt-get install -y wget gnupg2 lsb-release sudo openssl && \ + wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg && \ + echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \ + apt-get update && \ + apt-get install -y postgresql-18 && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + COPY radar /usr/local/bin/radar + RUN chmod +x /usr/local/bin/radar + COPY test-cert-auth.sh /test-cert-auth.sh + RUN chmod +x /test-cert-auth.sh + CMD ["/test-cert-auth.sh"] + EOF + + - name: Create cert auth test script + run: | + cat > test-cert-auth.sh <<'SCRIPT' + #!/bin/bash + set -e + + echo "=== Certificate Auth Integration Test ===" + + rm -rf /var/lib/postgresql/18/main + mkdir -p /var/lib/postgresql/18/main + chown -R postgres:postgres /var/lib/postgresql + sudo -u postgres /usr/lib/postgresql/18/bin/initdb -D /var/lib/postgresql/18/main + sudo -u postgres /usr/lib/postgresql/18/bin/pg_ctl -D /var/lib/postgresql/18/main -l /tmp/postgres.log start + + for i in {1..30}; do + if sudo -u postgres /usr/lib/postgresql/18/bin/pg_isready -q; then break; fi + sleep 1 + done + + sudo -u postgres psql -c "CREATE DATABASE testdb;" + sudo -u postgres psql -d testdb -c "CREATE USER testuser;" + + PGDATA=/var/lib/postgresql/18/main + CERTDIR=/tmp/certs + mkdir -p "$CERTDIR" + + # Generate CA + openssl genpkey -algorithm RSA -out "$CERTDIR/ca.key" 2>/dev/null + openssl req -new -x509 -key "$CERTDIR/ca.key" -out "$CERTDIR/ca.crt" -days 1 -subj "/CN=TestCA" 2>/dev/null + + # Server cert with SAN (Go requires SANs) + openssl genpkey -algorithm RSA -out "$CERTDIR/server.key" 2>/dev/null + openssl req -new -key "$CERTDIR/server.key" -out "$CERTDIR/server.csr" -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost" 2>/dev/null + openssl x509 -req -in "$CERTDIR/server.csr" -CA "$CERTDIR/ca.crt" -CAkey "$CERTDIR/ca.key" \ + -CAcreateserial -out "$CERTDIR/server.crt" -days 1 -copy_extensions copyall 2>/dev/null + + # Client cert (CN=testuser must match PG username) + openssl genpkey -algorithm RSA -out "$CERTDIR/client.key" 2>/dev/null + openssl req -new -key "$CERTDIR/client.key" -out "$CERTDIR/client.csr" -subj "/CN=testuser" 2>/dev/null + openssl x509 -req -in "$CERTDIR/client.csr" -CA "$CERTDIR/ca.crt" -CAkey "$CERTDIR/ca.key" \ + -CAcreateserial -out "$CERTDIR/client.crt" -days 1 2>/dev/null + + cp "$CERTDIR/server.crt" "$CERTDIR/server.key" "$CERTDIR/ca.crt" "$PGDATA/" + chown postgres:postgres "$PGDATA/server.crt" "$PGDATA/server.key" "$PGDATA/ca.crt" + chmod 600 "$PGDATA/server.key" + chmod 600 "$CERTDIR/client.key" + + sudo -u postgres psql -c "ALTER SYSTEM SET ssl = on;" + sudo -u postgres psql -c "ALTER SYSTEM SET ssl_cert_file = 'server.crt';" + sudo -u postgres psql -c "ALTER SYSTEM SET ssl_key_file = 'server.key';" + sudo -u postgres psql -c "ALTER SYSTEM SET ssl_ca_file = 'ca.crt';" + + cat > "$PGDATA/pg_hba.conf" </dev/null; then break; fi + sleep 1 + done + + echo "=== Running radar with certificate auth ===" + radar -h localhost -d testdb -U testuser \ + -sslmode verify-full -sslcert "$CERTDIR/client.crt" -sslkey "$CERTDIR/client.key" -sslrootcert "$CERTDIR/ca.crt" -v + + ZIPFILE=$(ls radar-*.zip 2>/dev/null | head -1) + if [ -z "$ZIPFILE" ]; then + echo "ERROR: No output file" + exit 1 + fi + + apt-get update && apt-get install -y unzip + if ! unzip -l "$ZIPFILE" | grep -q "postgresql/version.tsv"; then + echo "ERROR: Missing PostgreSQL data — cert auth failed" + exit 1 + fi + echo "✓ Certificate auth test PASSED" + + sudo -u postgres /usr/lib/postgresql/18/bin/pg_ctl -D "$PGDATA" stop + SCRIPT + chmod +x test-cert-auth.sh + + - name: Build Docker image + run: docker build -f Dockerfile.certtest -t radar-cert-test . + + - name: Run cert auth test + run: docker run --rm radar-cert-test + + - name: Clean up + if: always() + run: docker rmi radar-cert-test || true + + gssapi-auth-test: + name: GSSAPI/Kerberos Auth Integration Test + runs-on: ubuntu-latest + needs: lint-and-test + steps: + - name: Download Linux binary + uses: actions/download-artifact@v4 + with: + name: radar-linux + + - name: Make binary executable + run: chmod +x radar + + - name: Create Dockerfile + run: | + cat > Dockerfile.gsstest <<'EOF' + FROM debian:bookworm-slim + RUN apt-get update && \ + apt-get install -y wget gnupg2 lsb-release sudo krb5-kdc krb5-admin-server krb5-user && \ + wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg && \ + echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \ + apt-get update && \ + apt-get install -y postgresql-18 && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + COPY radar /usr/local/bin/radar + RUN chmod +x /usr/local/bin/radar + COPY test-gss-auth.sh /test-gss-auth.sh + RUN chmod +x /test-gss-auth.sh + CMD ["/test-gss-auth.sh"] + EOF + + - name: Create GSSAPI test script + run: | + cat > test-gss-auth.sh <<'SCRIPT' + #!/bin/bash + set -e + + echo "=== GSSAPI/Kerberos Auth Integration Test ===" + + rm -rf /var/lib/postgresql/18/main + mkdir -p /var/lib/postgresql/18/main + chown -R postgres:postgres /var/lib/postgresql + sudo -u postgres /usr/lib/postgresql/18/bin/initdb -D /var/lib/postgresql/18/main + sudo -u postgres /usr/lib/postgresql/18/bin/pg_ctl -D /var/lib/postgresql/18/main -l /tmp/postgres.log start + + for i in {1..30}; do + if sudo -u postgres /usr/lib/postgresql/18/bin/pg_isready -q; then break; fi + sleep 1 + done + + sudo -u postgres psql -c "CREATE DATABASE testdb;" + sudo -u postgres psql -d testdb -c "CREATE USER testuser;" + + PGDATA=/var/lib/postgresql/18/main + KRB_REALM="RADAR.TEST" + + # Initialize KDC + mkdir -p /etc/krb5kdc + cat > /etc/krb5.conf < /etc/krb5kdc/kdc.conf </dev/null + kadmin.local -q "addprinc -pw testpass testuser@$KRB_REALM" 2>/dev/null + kadmin.local -q "addprinc -randkey postgres/localhost@$KRB_REALM" 2>/dev/null + kadmin.local -q "ktadd -k $PGDATA/server.keytab postgres/localhost@$KRB_REALM" 2>/dev/null + chown postgres:postgres "$PGDATA/server.keytab" + chmod 600 "$PGDATA/server.keytab" + + krb5kdc + + sudo -u postgres psql -c "ALTER SYSTEM SET krb_server_keyfile = '$PGDATA/server.keytab';" + + cat > "$PGDATA/pg_hba.conf" </dev/null; then break; fi + sleep 1 + done + + echo "testpass" | kinit testuser@$KRB_REALM 2>/dev/null + + echo "=== Running radar with GSSAPI auth ===" + radar -h localhost -d testdb -U testuser -sslmode disable -v + + ZIPFILE=$(ls radar-*.zip 2>/dev/null | head -1) + if [ -z "$ZIPFILE" ]; then + echo "ERROR: No output file" + exit 1 + fi + + apt-get update && apt-get install -y unzip + if ! unzip -l "$ZIPFILE" | grep -q "postgresql/version.tsv"; then + echo "ERROR: Missing PostgreSQL data — GSSAPI auth failed" + exit 1 + fi + echo "✓ GSSAPI/Kerberos auth test PASSED" + + sudo -u postgres /usr/lib/postgresql/18/bin/pg_ctl -D "$PGDATA" stop + SCRIPT + chmod +x test-gss-auth.sh + + - name: Build Docker image + run: docker build -f Dockerfile.gsstest -t radar-gss-test . + + - name: Run GSSAPI auth test + run: docker run --rm radar-gss-test + + - name: Clean up + if: always() + run: docker rmi radar-gss-test || true + macos-test: name: macOS Build and Test runs-on: macos-latest diff --git a/Dockerfile b/Dockerfile index 9766292..e85234f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ sysstat \ policycoreutils \ dmsetup \ + openssl \ + krb5-kdc \ + krb5-admin-server \ + krb5-user \ && rm -rf /var/lib/apt/lists/* # Set up working directory diff --git a/README.md b/README.md index d4f4ab3..aab373c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # radar +[![Codacy Badge](https://api.codacy.com/project/badge/Grade/2645bf9703e7474ba14d618707231276)](https://app.codacy.com/gh/pgEdge/radar?utm_source=github.com&utm_medium=referral&utm_content=pgEdge/radar&utm_campaign=Badge_Grade) + > Agentless, zero-dependency diagnostic data collection tool for PostgreSQL and system metrics. ## What it Does @@ -125,6 +127,14 @@ Options: skip PostgreSQL data collection -skip-system skip system data collection + -sslmode string + SSL mode: prefer, disable, require, verify-ca, verify-full (default "prefer") + -sslcert string + client SSL certificate file + -sslkey string + client SSL private key file + -sslrootcert string + SSL root (CA) certificate file -v verbose output (summary) -vv very verbose output (detailed) @@ -136,6 +146,18 @@ Options: - `PGPORT` - PostgreSQL port - `PGUSER` - PostgreSQL username - `PGPASSWORD` - PostgreSQL password +- `PGDATABASE` - PostgreSQL database name +- `PGSSLMODE` - SSL mode +- `PGSSLCERT` - client SSL certificate file +- `PGSSLKEY` - client SSL private key file +- `PGSSLROOTCERT` - SSL root (CA) certificate file + +### Authentication Methods + +- **Password** — use `-U` and `PGPASSWORD`, or rely on OS user defaults +- **LDAP** — server-side only; no client changes needed. Supply credentials as normal +- **Certificate** — use `-sslmode verify-full -sslcert client.crt -sslkey client.key -sslrootcert ca.crt` +- **GSSAPI/Kerberos** — ensure a valid Kerberos ticket is present (`kinit`); radar will use it automatically ### Sample Output diff --git a/docs/index.md b/docs/index.md index a0e3b86..d5af0d5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -104,6 +104,14 @@ Options: skip PostgreSQL data collection -skip-system skip system data collection + -sslmode string + SSL mode: prefer, disable, require, verify-ca, verify-full (default "prefer") + -sslcert string + client SSL certificate file + -sslkey string + client SSL private key file + -sslrootcert string + SSL root (CA) certificate file -v verbose output (summary) -vv very verbose output (detailed) @@ -115,6 +123,18 @@ Options: - `PGPORT` - PostgreSQL port - `PGUSER` - PostgreSQL username - `PGPASSWORD` - PostgreSQL password +- `PGDATABASE` - PostgreSQL database name +- `PGSSLMODE` - SSL mode +- `PGSSLCERT` - client SSL certificate file +- `PGSSLKEY` - client SSL private key file +- `PGSSLROOTCERT` - SSL root (CA) certificate file + +### Authentication Methods + +- **Password** — use `-U` and `PGPASSWORD`, or rely on OS user defaults +- **LDAP** — server-side only; no client changes needed. Supply credentials as normal +- **Certificate** — use `-sslmode verify-full -sslcert client.crt -sslkey client.key -sslrootcert ca.crt` +- **GSSAPI/Kerberos** — ensure a valid Kerberos ticket is present (`kinit`); radar will use it automatically ### Sample Output diff --git a/go.mod b/go.mod index a241bb4..52e1d49 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,16 @@ module radar -go 1.24 +go 1.24.13 -require github.com/lib/pq v1.10.9 +require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 + github.com/jackc/pgx/v5 v5.8.0 +) -require github.com/DATA-DOG/go-sqlmock v1.5.2 +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/go.sum b/go.sum index 3bcb6a3..219fede 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,29 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/postgres.go b/postgres.go index e333698..8a60c36 100644 --- a/postgres.go +++ b/postgres.go @@ -146,13 +146,7 @@ func generateDatabaseTasks(db *sql.DB) ([]CollectionTask, error) { // execPGQueryOnDB executes a query on a specific database func execPGQueryOnDB(dbname string, cfg *Config, query string, w io.Writer) error { - connStr := fmt.Sprintf("host=%s port=%d dbname=%s user=%s sslmode=disable", - cfg.Host, cfg.Port, dbname, cfg.Username) - if cfg.Password != "" { - connStr += fmt.Sprintf(" password=%s", cfg.Password) - } - - db, err := sql.Open("postgres", connStr) + db, err := sql.Open("pgx", cfg.ConnectionString(dbname)) if err != nil { return fmt.Errorf("connecting to %s: %w", dbname, err) } diff --git a/radar.go b/radar.go index 300462d..bce5e0a 100644 --- a/radar.go +++ b/radar.go @@ -25,7 +25,7 @@ import ( "strings" "time" - _ "github.com/lib/pq" + _ "github.com/jackc/pgx/v5/stdlib" ) // Date/Time Formats @@ -56,12 +56,16 @@ const ( // Config holds connection parameters and collection settings type Config struct { // PostgreSQL connection - Host string - Port int - Database string - Username string - Password string - DataDir string + Host string + Port int + Database string + Username string + Password string + DataDir string + SSLMode string + SSLCert string + SSLKey string + SSLRootCert string // Database connection (injected) DB *sql.DB @@ -271,6 +275,10 @@ func parseConfig() (*Config, error) { flag.StringVar(&cfg.Database, "d", "", "database name") flag.StringVar(&cfg.Username, "U", "", "database user") flag.StringVar(&cfg.DataDir, "data-dir", "", "PostgreSQL data directory") + flag.StringVar(&cfg.SSLMode, "sslmode", "prefer", "SSL mode (prefer, disable, require, verify-ca, verify-full)") + flag.StringVar(&cfg.SSLCert, "sslcert", "", "client SSL certificate file") + flag.StringVar(&cfg.SSLKey, "sslkey", "", "client SSL key file") + flag.StringVar(&cfg.SSLRootCert, "sslrootcert", "", "SSL root certificate file") flag.BoolVar(&cfg.SkipSystem, "skip-system", false, "skip system data collection") flag.BoolVar(&cfg.SkipPostgres, "skip-postgres", false, "skip PostgreSQL data collection") flag.BoolVar(&cfg.Verbose, "v", false, "verbose output (summary)") @@ -321,6 +329,57 @@ func parseConfig() (*Config, error) { cfg.Database = os.Getenv("PGDATABASE") } + if !cfg.SkipPostgres { + sslmodeFlagSet := false + flag.Visit(func(f *flag.Flag) { + if f.Name == "sslmode" { + sslmodeFlagSet = true + } + }) + if !sslmodeFlagSet { + if v := os.Getenv("PGSSLMODE"); v != "" { + cfg.SSLMode = v + } + } + + validSSLModes := map[string]bool{ + "prefer": true, "disable": true, "require": true, + "verify-ca": true, "verify-full": true, + } + if !validSSLModes[cfg.SSLMode] { + return nil, fmt.Errorf("invalid sslmode %q: must be one of prefer, disable, require, verify-ca, verify-full", cfg.SSLMode) + } + + if cfg.SSLCert == "" { + cfg.SSLCert = os.Getenv("PGSSLCERT") + } + if cfg.SSLKey == "" { + cfg.SSLKey = os.Getenv("PGSSLKEY") + } + if cfg.SSLRootCert == "" { + cfg.SSLRootCert = os.Getenv("PGSSLROOTCERT") + } + + // Cert and key must be specified together + if (cfg.SSLCert == "") != (cfg.SSLKey == "") { + return nil, fmt.Errorf("--sslcert and --sslkey must be specified together") + } + // verify-ca and verify-full require a root certificate + if (cfg.SSLMode == "verify-ca" || cfg.SSLMode == "verify-full") && cfg.SSLRootCert == "" { + return nil, fmt.Errorf("--sslrootcert is required for sslmode=%s", cfg.SSLMode) + } + // Validate cert files exist + for _, f := range []struct{ flag, path string }{ + {"--sslcert", cfg.SSLCert}, {"--sslkey", cfg.SSLKey}, {"--sslrootcert", cfg.SSLRootCert}, + } { + if f.path != "" { + if _, err := os.Stat(f.path); err != nil { + return nil, fmt.Errorf("%s: %w", f.flag, err) + } + } + } + } + // Validate skip flag combinations if cfg.SkipSystem && cfg.SkipPostgres { return nil, fmt.Errorf("cannot use --skip-system and --skip-postgres together (nothing would be collected)") @@ -339,18 +398,37 @@ func parseConfig() (*Config, error) { return cfg, nil } -// ConnectionString builds a PostgreSQL connection string. -func (c *Config) ConnectionString() string { +// ConnectionString builds a PostgreSQL connection string for the given database. +// Values containing spaces, quotes, or backslashes are escaped per libpq conventions. +func (c *Config) ConnectionString(dbname string) string { + q := func(v string) string { + if !strings.ContainsAny(v, " '\\") { + return v + } + v = strings.ReplaceAll(v, `\`, `\\`) + v = strings.ReplaceAll(v, `'`, `\'`) + return "'" + v + "'" + } + params := []string{ - fmt.Sprintf("host=%s", c.Host), + "host=" + q(c.Host), fmt.Sprintf("port=%d", c.Port), - fmt.Sprintf("dbname=%s", c.Database), - fmt.Sprintf("user=%s", c.Username), - "sslmode=disable", + "dbname=" + q(dbname), + "user=" + q(c.Username), + "sslmode=" + q(c.SSLMode), } if c.Password != "" { - params = append(params, fmt.Sprintf("password=%s", c.Password)) + params = append(params, "password="+q(c.Password)) + } + if c.SSLCert != "" { + params = append(params, "sslcert="+q(c.SSLCert)) + } + if c.SSLKey != "" { + params = append(params, "sslkey="+q(c.SSLKey)) + } + if c.SSLRootCert != "" { + params = append(params, "sslrootcert="+q(c.SSLRootCert)) } return strings.Join(params, " ") @@ -358,7 +436,7 @@ func (c *Config) ConnectionString() string { // initPostgreSQL opens and verifies the PostgreSQL connection. func initPostgreSQL(cfg *Config) error { - db, err := sql.Open("postgres", cfg.ConnectionString()) + db, err := sql.Open("pgx", cfg.ConnectionString(cfg.Database)) if err != nil { return err } diff --git a/radar_test.go b/radar_test.go index 97bca50..6d2fd6b 100644 --- a/radar_test.go +++ b/radar_test.go @@ -268,13 +268,14 @@ func TestConnectionString(t *testing.T) { Port: 5432, Database: "testdb", Username: "testuser", + SSLMode: "prefer", }, contains: []string{ "host=localhost", "port=5432", "dbname=testdb", "user=testuser", - "sslmode=disable", + "sslmode=prefer", }, }, { @@ -285,6 +286,7 @@ func TestConnectionString(t *testing.T) { Database: "mydb", Username: "admin", Password: "secret", + SSLMode: "prefer", }, contains: []string{ "host=dbhost", @@ -292,14 +294,14 @@ func TestConnectionString(t *testing.T) { "dbname=mydb", "user=admin", "password=secret", - "sslmode=disable", + "sslmode=prefer", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - connStr := tt.config.ConnectionString() + connStr := tt.config.ConnectionString(tt.config.Database) for _, expected := range tt.contains { if !strings.Contains(connStr, expected) { t.Errorf("connection string missing %q: %s", expected, connStr) @@ -747,3 +749,232 @@ func TestSkipFlagValidation(t *testing.T) { }) } } + +// TestSSLModeDefault verifies sslmode defaults to prefer. +func TestSSLModeDefault(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + flag.CommandLine = flag.NewFlagSet("radar", flag.ContinueOnError) + os.Args = []string{"radar"} + + cfg, err := parseConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.SSLMode != "prefer" { + t.Errorf("expected SSLMode=prefer, got %q", cfg.SSLMode) + } + if !strings.Contains(cfg.ConnectionString(cfg.Database), "sslmode=prefer") { + t.Errorf("expected sslmode=prefer in connection string: %s", cfg.ConnectionString(cfg.Database)) + } +} + +// TestSSLModeValidation verifies invalid sslmode values are rejected. +func TestSSLModeValidation(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + tests := []struct { + name string + sslmode string + expectError bool + }{ + {"prefer is valid", "prefer", false}, + {"disable is valid", "disable", false}, + {"require is valid", "require", false}, + {"invalid value rejected", "bogus", true}, + {"allow is not supported", "allow", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flag.CommandLine = flag.NewFlagSet("radar", flag.ContinueOnError) + os.Args = []string{"radar", "--sslmode", tt.sslmode} + + _, err := parseConfig() + if tt.expectError && err == nil { + t.Errorf("expected error for sslmode=%q, got nil", tt.sslmode) + } + if !tt.expectError && err != nil { + t.Errorf("unexpected error for sslmode=%q: %v", tt.sslmode, err) + } + }) + } +} + +// TestSSLModeEnvFallback verifies PGSSLMODE env var is respected. +func TestSSLModeEnvFallback(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + t.Run("PGSSLMODE fallback", func(t *testing.T) { + t.Setenv("PGSSLMODE", "require") + flag.CommandLine = flag.NewFlagSet("radar", flag.ContinueOnError) + os.Args = []string{"radar"} + + cfg, err := parseConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.SSLMode != "require" { + t.Errorf("expected SSLMode=require, got %q", cfg.SSLMode) + } + }) + + t.Run("flag takes precedence over PGSSLMODE", func(t *testing.T) { + t.Setenv("PGSSLMODE", "require") + flag.CommandLine = flag.NewFlagSet("radar", flag.ContinueOnError) + os.Args = []string{"radar", "--sslmode", "disable"} + + cfg, err := parseConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.SSLMode != "disable" { + t.Errorf("expected SSLMode=disable, got %q", cfg.SSLMode) + } + }) +} + +// TestSSLCertValidation verifies cert/key pairing and rootcert requirements. +func TestSSLCertValidation(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + certFile, err := os.CreateTemp("", "cert*.pem") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { closeErrCheck(certFile, "certFile"); os.Remove(certFile.Name()) }) //nolint:errcheck + + keyFile, err := os.CreateTemp("", "key*.pem") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { closeErrCheck(keyFile, "keyFile"); os.Remove(keyFile.Name()) }) //nolint:errcheck + + rootCertFile, err := os.CreateTemp("", "rootcert*.pem") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { closeErrCheck(rootCertFile, "rootCertFile"); os.Remove(rootCertFile.Name()) }) //nolint:errcheck + + tests := []struct { + name string + args []string + expectError bool + }{ + {"sslcert without sslkey rejected", []string{"radar", "--sslcert", certFile.Name()}, true}, + {"sslkey without sslcert rejected", []string{"radar", "--sslkey", keyFile.Name()}, true}, + {"verify-ca without sslrootcert rejected", []string{"radar", "--sslmode", "verify-ca"}, true}, + {"verify-full without sslrootcert rejected", []string{"radar", "--sslmode", "verify-full"}, true}, + {"sslcert with sslkey valid", []string{"radar", "--sslcert", certFile.Name(), "--sslkey", keyFile.Name()}, false}, + {"verify-ca with sslrootcert valid", []string{"radar", "--sslmode", "verify-ca", "--sslrootcert", rootCertFile.Name()}, false}, + {"nonexistent sslcert rejected", []string{"radar", "--sslcert", "/nonexistent/cert.pem", "--sslkey", keyFile.Name()}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flag.CommandLine = flag.NewFlagSet("radar", flag.ContinueOnError) + os.Args = tt.args + _, err := parseConfig() + if tt.expectError && err == nil { + t.Errorf("expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +// TestSSLCertEnvFallbacks verifies PGSSLCERT/PGSSLKEY/PGSSLROOTCERT env vars. +func TestSSLCertEnvFallbacks(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + certFile, err := os.CreateTemp("", "cert*.pem") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { closeErrCheck(certFile, "certFile"); os.Remove(certFile.Name()) }) //nolint:errcheck + + keyFile, err := os.CreateTemp("", "key*.pem") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { closeErrCheck(keyFile, "keyFile"); os.Remove(keyFile.Name()) }) //nolint:errcheck + + t.Run("PGSSLCERT and PGSSLKEY env vars", func(t *testing.T) { + t.Setenv("PGSSLCERT", certFile.Name()) + t.Setenv("PGSSLKEY", keyFile.Name()) + flag.CommandLine = flag.NewFlagSet("radar", flag.ContinueOnError) + os.Args = []string{"radar"} + + cfg, err := parseConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.SSLCert != certFile.Name() { + t.Errorf("expected SSLCert=%q, got %q", certFile.Name(), cfg.SSLCert) + } + if cfg.SSLKey != keyFile.Name() { + t.Errorf("expected SSLKey=%q, got %q", keyFile.Name(), cfg.SSLKey) + } + }) +} + +// TestConnectionStringWithCerts verifies cert params appear in connection string. +func TestConnectionStringWithCerts(t *testing.T) { + cfg := Config{ + Host: "localhost", + Port: 5432, + Database: "testdb", + Username: "testuser", + SSLMode: "verify-full", + SSLCert: "/path/to/cert.pem", + SSLKey: "/path/to/key.pem", + SSLRootCert: "/path/to/root.pem", + } + + s := cfg.ConnectionString(cfg.Database) + for _, want := range []string{ + "sslmode=verify-full", + "sslcert=/path/to/cert.pem", + "sslkey=/path/to/key.pem", + "sslrootcert=/path/to/root.pem", + } { + if !strings.Contains(s, want) { + t.Errorf("expected %q in connection string: %s", want, s) + } + } +} + +// TestConnectionStringQuoting verifies values with special characters are quoted. +func TestConnectionStringQuoting(t *testing.T) { + cfg := Config{ + Host: "localhost", Port: 5432, Database: "testdb", + Username: "testuser", Password: "pass word", SSLMode: "prefer", + SSLCert: "/path/with spaces/cert.pem", SSLKey: "/path/with spaces/key.pem", + } + s := cfg.ConnectionString(cfg.Database) + for _, want := range []string{ + "password='pass word'", + "sslcert='/path/with spaces/cert.pem'", + "sslkey='/path/with spaces/key.pem'", + } { + if !strings.Contains(s, want) { + t.Errorf("expected %q in connection string: %s", want, s) + } + } + + cfg2 := Config{ + Host: "localhost", Port: 5432, Database: "db", + Username: "user", Password: "it's", SSLMode: "prefer", + } + s2 := cfg2.ConnectionString(cfg2.Database) + if !strings.Contains(s2, `password='it\'s'`) { + t.Errorf("expected escaped single quote in: %s", s2) + } +} diff --git a/test-radar.sh b/test-radar.sh index 85a2f1f..e000dc8 100755 --- a/test-radar.sh +++ b/test-radar.sh @@ -30,7 +30,10 @@ su - postgres -c "/usr/lib/postgresql/18/bin/pg_ctl -D /var/lib/postgresql/18/ma echo "" echo "Waiting for PostgreSQL to start..." -sleep 3 +for i in $(seq 1 30); do + if su - postgres -c "/usr/lib/postgresql/18/bin/pg_isready -q" 2>/dev/null; then break; fi + sleep 1 +done echo "" echo "Creating test database and users..." @@ -139,9 +142,157 @@ fi echo -e "${GREEN}✓ Scenario 4 PASSED${NC}" rm -f "$ZIP4" +# Scenario 5: Certificate authentication +echo "" +echo "========================================" +echo -e "${YELLOW}Scenario 5: Certificate authentication${NC}" +echo "========================================" + +PGDATA=/var/lib/postgresql/18/main +CERTDIR=/tmp/certs +mkdir -p "$CERTDIR" + +# CA key and cert +openssl genpkey -algorithm RSA -out "$CERTDIR/ca.key" 2>/dev/null +openssl req -new -x509 -key "$CERTDIR/ca.key" -out "$CERTDIR/ca.crt" -days 1 -subj "/CN=TestCA" 2>/dev/null + +# Server key and cert (SAN=localhost — Go requires SANs, not just CN) +openssl genpkey -algorithm RSA -out "$CERTDIR/server.key" 2>/dev/null +openssl req -new -key "$CERTDIR/server.key" -out "$CERTDIR/server.csr" -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost" 2>/dev/null +openssl x509 -req -in "$CERTDIR/server.csr" -CA "$CERTDIR/ca.crt" -CAkey "$CERTDIR/ca.key" \ + -CAcreateserial -out "$CERTDIR/server.crt" -days 1 -copy_extensions copyall 2>/dev/null + +# Client key and cert (CN=testuser — must match PG username) +openssl genpkey -algorithm RSA -out "$CERTDIR/client.key" 2>/dev/null +openssl req -new -key "$CERTDIR/client.key" -out "$CERTDIR/client.csr" -subj "/CN=testuser" 2>/dev/null +openssl x509 -req -in "$CERTDIR/client.csr" -CA "$CERTDIR/ca.crt" -CAkey "$CERTDIR/ca.key" \ + -CAcreateserial -out "$CERTDIR/client.crt" -days 1 2>/dev/null + +# Set permissions +cp "$CERTDIR/server.crt" "$CERTDIR/server.key" "$CERTDIR/ca.crt" "$PGDATA/" +chown postgres:postgres "$PGDATA/server.crt" "$PGDATA/server.key" "$PGDATA/ca.crt" +chmod 600 "$PGDATA/server.key" +chmod 600 "$CERTDIR/client.key" + +# Configure PostgreSQL for SSL + cert auth +su - postgres -c "cat >> $PGDATA/postgresql.conf << 'SSLCONF' +ssl = on +ssl_cert_file = 'server.crt' +ssl_key_file = 'server.key' +ssl_ca_file = 'ca.crt' +SSLCONF" + +# Replace pg_hba.conf: cert auth for testuser, trust for postgres (scenarios 1-4 already passed) +su - postgres -c "cat > $PGDATA/pg_hba.conf << 'HBA' +local all all trust +hostssl testdb testuser 127.0.0.1/32 cert +host all all 127.0.0.1/32 trust +host all all ::1/128 trust +HBA" + +# Restart PostgreSQL +su - postgres -c "/usr/lib/postgresql/18/bin/pg_ctl -D $PGDATA restart -l /var/lib/postgresql/18/logfile" +for i in $(seq 1 30); do + if su - postgres -c "/usr/lib/postgresql/18/bin/pg_isready -q" 2>/dev/null; then break; fi + sleep 1 +done + +# Run radar with cert auth +./radar -h localhost -d testdb -U testuser \ + -sslmode verify-full -sslcert "$CERTDIR/client.crt" -sslkey "$CERTDIR/client.key" -sslrootcert "$CERTDIR/ca.crt" -vv +ZIP5=$(ls -t radar-*.zip | head -1) +if ! validate_zip "$ZIP5" "Scenario 5" "yes"; then + exit 1 +fi +echo -e "${GREEN}✓ Scenario 5 PASSED${NC}" +rm -f "$ZIP5" + +# Scenario 6: GSSAPI/Kerberos authentication +echo "" +echo "========================================" +echo -e "${YELLOW}Scenario 6: GSSAPI/Kerberos authentication${NC}" +echo "========================================" + +PGDATA=/var/lib/postgresql/18/main +KRB_REALM="RADAR.TEST" + +# Initialize Kerberos KDC +mkdir -p /etc/krb5kdc +cat > /etc/krb5.conf << KRBCONF +[libdefaults] + default_realm = $KRB_REALM + dns_lookup_realm = false + dns_lookup_kdc = false + +[realms] + $KRB_REALM = { + kdc = localhost + admin_server = localhost + } +KRBCONF + +cat > /etc/krb5kdc/kdc.conf << KDCCONF +[kdcdefaults] + kdc_ports = 88 + +[realms] + $KRB_REALM = { + database_name = /var/lib/krb5kdc/principal + key_stash_file = /etc/krb5kdc/stash + max_life = 1h + } +KDCCONF + +# Create KDC database +kdb5_util create -s -r "$KRB_REALM" -P masterpass 2>/dev/null + +# Create principals +kadmin.local -q "addprinc -pw testpass testuser@$KRB_REALM" 2>/dev/null +kadmin.local -q "addprinc -randkey postgres/localhost@$KRB_REALM" 2>/dev/null + +# Export keytab for PostgreSQL +kadmin.local -q "ktadd -k $PGDATA/server.keytab postgres/localhost@$KRB_REALM" 2>/dev/null +chown postgres:postgres "$PGDATA/server.keytab" +chmod 600 "$PGDATA/server.keytab" + +# Start KDC +krb5kdc + +# Configure PostgreSQL for GSSAPI +su - postgres -c "/usr/lib/postgresql/18/bin/psql -c \"ALTER SYSTEM SET krb_server_keyfile = '$PGDATA/server.keytab';\"" + +# Add GSSAPI auth and ident map +cat > "$PGDATA/pg_hba.conf" << HBA +local all all trust +hostgssenc testdb testuser 127.0.0.1/32 gss include_realm=0 +hostssl testdb testuser 127.0.0.1/32 cert +host all all 127.0.0.1/32 trust +host all all ::1/128 trust +HBA + +# Restart PostgreSQL +su - postgres -c "/usr/lib/postgresql/18/bin/pg_ctl -D $PGDATA restart -l /var/lib/postgresql/18/logfile" +for i in $(seq 1 30); do + if su - postgres -c "/usr/lib/postgresql/18/bin/pg_isready -q" 2>/dev/null; then break; fi + sleep 1 +done + +# Obtain Kerberos ticket +echo "testpass" | kinit testuser@$KRB_REALM 2>/dev/null + +# Run radar with GSSAPI +./radar -h localhost -d testdb -U testuser -sslmode disable -vv +ZIP6=$(ls -t radar-*.zip | head -1) +if ! validate_zip "$ZIP6" "Scenario 6" "yes"; then + exit 1 +fi +echo -e "${GREEN}✓ Scenario 6 PASSED${NC}" +rm -f "$ZIP6" + echo "" echo "Stopping PostgreSQL..." su - postgres -c "/usr/lib/postgresql/18/bin/pg_ctl -D /var/lib/postgresql/18/main stop" echo "" -echo -e "${GREEN}All 4 scenarios passed!${NC}" +echo -e "${GREEN}All 6 scenarios passed!${NC}"