pgBouncer and auth pass-through

I’ve noticed several individuals inquiring lately about pgBouncer and how they can avoid putting all users and their passwords in it’s auth_file. After the most recent such inquiry (hi Richard!) I decided I’d write this"post"to hopefully make it clearer how to use ‘pass-through auth’ and avoid maintaining your users and their passwords in an external file. So let’s see what this takes, shall we?

First, install pgBouncer as per your OS (yum, apt, brew, etc):

$ brew install pgbouncer
Updating Homebrew...
==> Auto-updated Homebrew!
Updated 1 tap (homebrew/core).
==> Updated Formulae
<snip>
==> Downloading https://homebrew.bintray.com/bottles/pgbouncer-1.8.1.high_sierra
######################################################################## 100.0%
==> Pouring pgbouncer-1.8.1.high_sierra.bottle.tar.gz
==> Caveats
The config file: /usr/local/etc/pgbouncer.ini is in the "ini" format and you
will need to edit it for your particular setup. See:
https://pgbouncer.github.io/config.html

The auth_file option should point to the /usr/local/etc/userlist.txt file which
can be populated by the /usr/local/opt/pgbouncer/bin/mkauth.py script.

To have launchd start pgbouncer now and restart at login:
  brew services start pgbouncer
Or, if you do not want/need a background service you can just run:
  pgbouncer -q /usr/local/etc/pgbouncer.ini
==> Summary
🍺  /usr/local/Cellar/pgbouncer/1.8.1: 17 files, 399.9KB

Great, so now we have pgBouncer installed.

To make life easier on ourselves, we’re going to temporarily enable trusted local socket connections in our pg_hba.conf:

# TYPE  DATABASE        USER            ADDRESS                 METHOD

# "local" is for Unix domain socket connections only
local   all             all trust

Right now, this is the only line in my pg_hba.conf. Let’s SIGHUP the postmaster so it takes affect:

$ pg_ctl -D $PGDATA reload
server signaled

And test it:

$ unset PGPASSWORD ; psql -U doug -d doug -c "select now();"
┌───────────────────────────────┐
│              now              │
├───────────────────────────────┤
│ 2018-08-07 13:19:06.343245-04 │
└───────────────────────────────┘
(1 row)

Time: 1.959 ms

OK, we can connect without issue.

Let’s configure pgBouncer now. As per the output above, I need to edit /usr/local/etc/pgbouncer.ini but yours is probably in plain old /etc:

[databases]

; any db over Unix socket
* =

[pgbouncer]

logfile = /Users/doug/pgbouncer.log
pidfile = /Users/doug/pgbouncer.pid

; IP address or * which means all IPs
listen_addr = 127.0.0.1
listen_port = 6432

unix_socket_dir = /tmp

; any, trust, plain, crypt, md5, cert, hba, pam
auth_type = trust
auth_file = /Users/doug/userlist.txt

admin_users = doug

stats_users = doug

pool_mode = transaction

server_reset_query = DISCARD ALL


max_client_conn = 100

default_pool_size = 20

As you can see, we’re gonna pass connections to any db back to the postmaster via a local socket. I put the logs in my $HOME for ease of use. I put the auth_file in my $HOME as well. Then I set myself up as both an admin and stats user. I changed the mode into transaction which is usually the mode you want. Now, I add myself to the auth_file:

$ echo '"doug" "md5540094bd8172cd963fdfa773fe44b488"' > userlist.txt

(NOTE: I did a select on pg_shadow as a SUPERUSER to get these values.)

And start pgBouncer:

$ pgbouncer /usr/local/etc/pgbouncer.ini
2018-08-07 13:43:46.453 92057 LOG File descriptor limit: 7168 (H:-1), max_client_conn: 90, max fds possible: 100
2018-08-07 13:43:46.455 92057 LOG listening on 127.0.0.1:6432
2018-08-07 13:43:46.455 92057 LOG listening on unix:/tmp/.s.PGSQL.6432
2018-08-07 13:43:46.455 92057 LOG process up: pgbouncer 1.8.1, libevent 2.1.8-stable (kqueue), adns: evdns2, tls: OpenSSL 1.0.2o  27 Mar 2018

Now, we see if we can connect to pgBouncer’s internal db:

$ psql -h 127.0.0.1 -p 6432 -d pgbouncer -X
psql (10.4, server 1.8.1/bouncer)
Type "help" for help.
pgbouncer=# show pools;
 database  |   user    | cl_active | cl_waiting | sv_active | sv_idle | sv_used | sv_tested | sv_login | maxwait | maxwait_us | pool_mode
+++++++++--++++++++++--++++++++++--+++++++++++++++++++++++--++++++++++++++++++++++++++++++--++++++++++-+++++++++++++++++++++++++++++++++--
 pgbouncer | pgbouncer |         1 |          0 |         0 |       0 |       0 |         0 |        0 |       0 |          0 | statement
(1 row)

Success!

Now, can we connect to one of our PostgreSQL dbs through pgBouncer:

$ psql -h 127.0.0.1 -p 6432 -d doug
psql (10.4)
Type "help" for help.

(doug@127.0.0.1:6432/doug[92825]) #

Huzzah!

We will now alter pgbouncer.ini and set auth_type = md5 and edit pg_hba.conf to use md5 as well to make sure we’re not passing around plaintext passwords. Our retest looks like:

$ grep ^local $PGDATA/pg_hba.conf
local   all             all md5

$ pg_ctl -D $PGDATA reload
server signaled
doug@ReturnOfTheMac ~> psql -h 127.0.0.1 -p 6432 -d doug
Password:
psql (10.4)
Type "help" for help.

(doug@127.0.0.1:6432/doug[97966]) \q
$ psql -h 127.0.0.1 -p 6432 -d pgbouncer -X
Password:
psql (10.4, server 1.8.1/bouncer)
Type "help" for help.
pgbouncer= \q

Which, as you can see, we were now password prompted both times!

Now, that we know it all works, we can go about changing things to not expose users through the auth_file. First, we’ll create a pgbouncer user on our db, then we will create a SECURITY DEFINER function will allow the pgbouncer user to (essentially) ‘sudo’ as a superuser to look at the pg_shadow table, and finally we will ensure only our pgbouncer user can execute that function:

(doug@127.0.0.1:6432/doug[92825]) # CREATE ROLE pgbouncer ENCRYPTED PASSWORD 'secret';
CREATE ROLE
Time: 4.102 ms
(doug@127.0.0.1:6432/doug[92825]) # GRANT CONNECT ON DATABASE doug TO pgbouncer;
GRANT
Time: 13.531 ms
(doug@[local]:5432/doug[92825]) # ALTER ROLE pgbouncer LOGIN;
ALTER ROLE
Time: 6.457 ms
(doug@127.0.0.1:6432/doug[92825]) # CREATE OR REPLACE FUNCTION public.user_lookup(in i_username text, out uname text, out phash text)
[more] - > RETURNS record AS $$
[more] $ > BEGIN
[more] $ >     SELECT usename, passwd FROM pg_catalog.pg_shadow
[more] $ >     WHERE usename = i_username INTO uname, phash;
[more] $ >     RETURN;
[more] $ > END;
[more] $ > $$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE FUNCTION
Time: 21.219 ms
(doug@127.0.0.1:6432/doug[92825]) # REVOKE ALL ON FUNCTION public.user_lookup(text) FROM public, pgbouncer;
REVOKE
Time: 7.330 ms
(doug@127.0.0.1:6432/doug[92825]) # GRANT EXECUTE ON FUNCTION public.user_lookup(text) TO pgbouncer;
GRANT
Time: 7.572 ms

(NOTE: Astute readers will note I’m connected as ‘doug’ to the db. This works cause in my setup that is a SUPERUSER. You should probably use the ‘postgres’ account.)

And, let’s tell PgBouncer to use this function:

$ grep ^auth /usr/local/etc/pgbouncer.ini
auth_type = md5
auth_file = /Users/doug/userlist.txt
auth_user = pgbouncer
auth_query = SELECT * FROM public.user_lookup($1);

And let’s edit our auth_file to only contain the pgbouncer user’s info:

doug@ReturnOfTheMac ~> cat userlist.txt
"pgbouncer" "md509d12ff67352814e4c467c7f55a3a1d7"

Restart pgBouncer, and let’s recheck:

doug@ReturnOfTheMac ~> psql -h 127.0.0.1 -p 6432 -d doug                      2
Password:
psql (10.4)
Type "help" for help.

(doug@127.0.0.1:6432/doug[7076]) # select now();
┌───────────────────────────────┐
│              now              │
├───────────────────────────────┤
│ 2018-08-07 14:43:45.938919-04 │
└───────────────────────────────┘
(1 row)

Time: 0.438 ms

It works! But what about the pgBouncer internal db?

$ psql -h 127.0.0.1 -p 6432 -d pgbouncer
psql: ERROR:  No such user: doug

Well, that makes sense. The auth_file only has a pgbouncer user. So, let’s edit the pgbouncer.ini:

$ grep '_users' /usr/local/etc/pgbouncer.ini
admin_users = pgbouncer
stats_users = pgbouncer

Retart pgBouncer once more and check:

doug@ReturnOfTheMac ~> psql -h 127.0.0.1 -p 6432 -d pgbouncer -U pgbouncer -X
Password for user pgbouncer:
psql (10.4, server 1.8.1/bouncer)
Type "help" for help.

pgbouncer=# show clients;
 type |   user    | database  | state  |   addr    | port  | local_addr | local_port |    connect_time     |    request_time     | wait | wait_us |      ptr       | link | remote_pid | tls
++++++++++++++++--++++++++++--+++++++--++++++++++--+++++++-+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-++++++++++++++++++++++++--
 C    | pgbouncer | pgbouncer | active | 127.0.0.1 | 54191 | 127.0.0.1  |       6432 | 2018-08-07 14:48:03 | 2018-08-07 14:48:06 |    0 |       0 | 0x7fa082005010 |      |          0 |
(1 row)

And we’re golden!

You can connect to pgBouncer internally using the pgbouncer user, and you can connect to our normal PostgreSQL db as any valid user and it uses our function to do the auth!

To complete this setup, we’re gonna move PostgreSQL to port 5433 and pgBouncer to port 5432:

$ grep port /usr/local/etc/pgbouncer.ini
listen_port = 5432
doug@ReturnOfTheMac ~> grep port $PGDATA/postgresql.conf
port = 5433				# (change requires restart)

So now, if someone tries to connect to our PostgreSQL on the default TCP/IP port, it goes through PgBouncer transparently (and then pgBouncer connects locally via a socket). Our users/apps are none the wiser, and us DBAs can always ssh into the box and connect directly to PostgreSQL via socket if needed. And we’re not exposing any user/app passwords in a text file on the OS.

WIN WIN

One final note: this is only working for the ‘doug’ database currently. If I wanted this to also work for another database, say ‘postgres’ or ‘prod_app’ then I would need to GRANT CONNECT on those dbs to ‘pgbouncer’ and would need to create my function in them as well.

☮️