1# Sets up a KDC and then runs a variety of tests to make sure that the
2# GSSAPI/Kerberos authentication and encryption are working properly,
3# that the options in pg_hba.conf and pg_ident.conf are handled correctly,
4# and that the server-side pg_stat_gssapi view reports what we expect to
5# see for each test.
6#
7# Since this requires setting up a full KDC, it doesn't make much sense
8# to have multiple test scripts (since they'd have to also create their
9# own KDC and that could cause race conditions or other problems)- so
10# just add whatever other tests are needed to here.
11#
12# See the README for additional information.
13
14use strict;
15use warnings;
16use TestLib;
17use PostgresNode;
18use Test::More;
19
20if ($ENV{with_gssapi} eq 'yes')
21{
22	plan tests => 18;
23}
24else
25{
26	plan skip_all => 'GSSAPI/Kerberos not supported by this build';
27}
28
29my ($krb5_bin_dir, $krb5_sbin_dir);
30
31if ($^O eq 'darwin')
32{
33	$krb5_bin_dir  = '/usr/local/opt/krb5/bin';
34	$krb5_sbin_dir = '/usr/local/opt/krb5/sbin';
35}
36elsif ($^O eq 'freebsd')
37{
38	$krb5_bin_dir  = '/usr/local/bin';
39	$krb5_sbin_dir = '/usr/local/sbin';
40}
41elsif ($^O eq 'linux')
42{
43	$krb5_sbin_dir = '/usr/sbin';
44}
45
46my $krb5_config  = 'krb5-config';
47my $kinit        = 'kinit';
48my $kdb5_util    = 'kdb5_util';
49my $kadmin_local = 'kadmin.local';
50my $krb5kdc      = 'krb5kdc';
51
52if ($krb5_bin_dir && -d $krb5_bin_dir)
53{
54	$krb5_config = $krb5_bin_dir . '/' . $krb5_config;
55	$kinit       = $krb5_bin_dir . '/' . $kinit;
56}
57if ($krb5_sbin_dir && -d $krb5_sbin_dir)
58{
59	$kdb5_util    = $krb5_sbin_dir . '/' . $kdb5_util;
60	$kadmin_local = $krb5_sbin_dir . '/' . $kadmin_local;
61	$krb5kdc      = $krb5_sbin_dir . '/' . $krb5kdc;
62}
63
64my $host     = 'auth-test-localhost.postgresql.example.com';
65my $hostaddr = '127.0.0.1';
66my $realm    = 'EXAMPLE.COM';
67
68my $krb5_conf   = "${TestLib::tmp_check}/krb5.conf";
69my $kdc_conf    = "${TestLib::tmp_check}/kdc.conf";
70my $krb5_cache  = "${TestLib::tmp_check}/krb5cc";
71my $krb5_log    = "${TestLib::log_path}/krb5libs.log";
72my $kdc_log     = "${TestLib::log_path}/krb5kdc.log";
73my $kdc_port    = get_free_port();
74my $kdc_datadir = "${TestLib::tmp_check}/krb5kdc";
75my $kdc_pidfile = "${TestLib::tmp_check}/krb5kdc.pid";
76my $keytab      = "${TestLib::tmp_check}/krb5.keytab";
77
78note "setting up Kerberos";
79
80my ($stdout, $krb5_version);
81run_log [ $krb5_config, '--version' ], '>', \$stdout
82  or BAIL_OUT("could not execute krb5-config");
83BAIL_OUT("Heimdal is not supported") if $stdout =~ m/heimdal/;
84$stdout =~ m/Kerberos 5 release ([0-9]+\.[0-9]+)/
85  or BAIL_OUT("could not get Kerberos version");
86$krb5_version = $1;
87
88append_to_file(
89	$krb5_conf,
90	qq![logging]
91default = FILE:$krb5_log
92kdc = FILE:$kdc_log
93
94[libdefaults]
95default_realm = $realm
96
97[realms]
98$realm = {
99    kdc = $hostaddr:$kdc_port
100}!);
101
102append_to_file(
103	$kdc_conf,
104	qq![kdcdefaults]
105!);
106
107# For new-enough versions of krb5, use the _listen settings rather
108# than the _ports settings so that we can bind to localhost only.
109if ($krb5_version >= 1.15)
110{
111	append_to_file(
112		$kdc_conf,
113		qq!kdc_listen = $hostaddr:$kdc_port
114kdc_tcp_listen = $hostaddr:$kdc_port
115!);
116}
117else
118{
119	append_to_file(
120		$kdc_conf,
121		qq!kdc_ports = $kdc_port
122kdc_tcp_ports = $kdc_port
123!);
124}
125append_to_file(
126	$kdc_conf,
127	qq!
128[realms]
129$realm = {
130    database_name = $kdc_datadir/principal
131    admin_keytab = FILE:$kdc_datadir/kadm5.keytab
132    acl_file = $kdc_datadir/kadm5.acl
133    key_stash_file = $kdc_datadir/_k5.$realm
134}!);
135
136mkdir $kdc_datadir or die;
137
138# Ensure that we use test's config and cache files, not global ones.
139$ENV{'KRB5_CONFIG'}      = $krb5_conf;
140$ENV{'KRB5_KDC_PROFILE'} = $kdc_conf;
141$ENV{'KRB5CCNAME'}       = $krb5_cache;
142
143my $service_principal = "$ENV{with_krb_srvnam}/$host";
144
145system_or_bail $kdb5_util, 'create', '-s', '-P', 'secret0';
146
147my $test1_password = 'secret1';
148system_or_bail $kadmin_local, '-q', "addprinc -pw $test1_password test1";
149
150system_or_bail $kadmin_local, '-q', "addprinc -randkey $service_principal";
151system_or_bail $kadmin_local, '-q', "ktadd -k $keytab $service_principal";
152
153system_or_bail $krb5kdc, '-P', $kdc_pidfile;
154
155END
156{
157	kill 'INT', `cat $kdc_pidfile` if -f $kdc_pidfile;
158}
159
160note "setting up PostgreSQL instance";
161
162my $node = get_new_node('node');
163$node->init;
164$node->append_conf('postgresql.conf', "listen_addresses = '$hostaddr'");
165$node->append_conf('postgresql.conf', "krb_server_keyfile = '$keytab'");
166$node->start;
167
168$node->safe_psql('postgres', 'CREATE USER test1;');
169
170note "running tests";
171
172# Test connection success or failure, and if success, that query returns true.
173sub test_access
174{
175	my ($node, $role, $query, $expected_res, $gssencmode, $test_name) = @_;
176
177	# need to connect over TCP/IP for Kerberos
178	my ($res, $stdoutres, $stderrres) = $node->psql(
179		'postgres',
180		undef,
181		extra_params => [
182			'-XAtd',
183			$node->connstr('postgres')
184			  . " host=$host hostaddr=$hostaddr $gssencmode",
185			'-U',
186			$role,
187			'-c',
188			$query
189		]);
190
191	# If we get a query result back, it should be true.
192	if ($res == $expected_res and $res eq 0)
193	{
194		is($stdoutres, "t", $test_name);
195	}
196	else
197	{
198		is($res, $expected_res, $test_name);
199	}
200	return;
201}
202
203# As above, but test for an arbitrary query result.
204sub test_query
205{
206	local $Test::Builder::Level = $Test::Builder::Level + 1;
207
208	my ($node, $role, $query, $expected, $gssencmode, $test_name) = @_;
209
210	# need to connect over TCP/IP for Kerberos
211	my ($res, $stdoutres, $stderrres) = $node->psql(
212		'postgres',
213		"$query",
214		extra_params => [
215			'-XAtd',
216			$node->connstr('postgres')
217			  . " host=$host hostaddr=$hostaddr $gssencmode",
218			'-U',
219			$role
220		]);
221
222	is($res, 0, $test_name);
223	like($stdoutres, $expected, $test_name);
224	is($stderrres, "", $test_name);
225	return;
226}
227
228unlink($node->data_dir . '/pg_hba.conf');
229$node->append_conf('pg_hba.conf',
230	qq{host all all $hostaddr/32 gss map=mymap});
231$node->restart;
232
233test_access($node, 'test1', 'SELECT true', 2, '', 'fails without ticket');
234
235run_log [ $kinit, 'test1' ], \$test1_password or BAIL_OUT($?);
236
237test_access($node, 'test1', 'SELECT true', 2, '', 'fails without mapping');
238
239$node->append_conf('pg_ident.conf', qq{mymap  /^(.*)\@$realm\$  \\1});
240$node->restart;
241
242test_access(
243	$node,
244	'test1',
245	'SELECT gss_authenticated AND encrypted from pg_stat_gssapi where pid = pg_backend_pid();',
246	0,
247	'',
248	'succeeds with mapping with default gssencmode and host hba');
249test_access(
250	$node,
251	"test1",
252	'SELECT gss_authenticated AND encrypted from pg_stat_gssapi where pid = pg_backend_pid();',
253	0,
254	"gssencmode=prefer",
255	"succeeds with GSS-encrypted access preferred with host hba");
256test_access(
257	$node,
258	"test1",
259	'SELECT gss_authenticated AND encrypted from pg_stat_gssapi where pid = pg_backend_pid();',
260	0,
261	"gssencmode=require",
262	"succeeds with GSS-encrypted access required with host hba");
263
264# Test that we can transport a reasonable amount of data.
265test_query(
266	$node,
267	"test1",
268	'SELECT * FROM generate_series(1, 100000);',
269	qr/^1\n.*\n1024\n.*\n9999\n.*\n100000$/s,
270	"gssencmode=require",
271	"receiving 100K lines works");
272
273test_query(
274	$node,
275	"test1",
276	"CREATE TABLE mytab (f1 int primary key);\n"
277	  . "COPY mytab FROM STDIN;\n"
278	  . join("\n", (1 .. 100000))
279	  . "\n\\.\n"
280	  . "SELECT COUNT(*) FROM mytab;",
281	qr/^100000$/s,
282	"gssencmode=require",
283	"sending 100K lines works");
284
285unlink($node->data_dir . '/pg_hba.conf');
286$node->append_conf('pg_hba.conf',
287	qq{hostgssenc all all $hostaddr/32 gss map=mymap});
288$node->restart;
289
290test_access(
291	$node,
292	"test1",
293	'SELECT gss_authenticated AND encrypted from pg_stat_gssapi where pid = pg_backend_pid();',
294	0,
295	"gssencmode=prefer",
296	"succeeds with GSS-encrypted access preferred and hostgssenc hba");
297test_access(
298	$node,
299	"test1",
300	'SELECT gss_authenticated AND encrypted from pg_stat_gssapi where pid = pg_backend_pid();',
301	0,
302	"gssencmode=require",
303	"succeeds with GSS-encrypted access required and hostgssenc hba");
304test_access($node, "test1", 'SELECT true', 2, "gssencmode=disable",
305	"fails with GSS encryption disabled and hostgssenc hba");
306
307unlink($node->data_dir . '/pg_hba.conf');
308$node->append_conf('pg_hba.conf',
309	qq{hostnogssenc all all $hostaddr/32 gss map=mymap});
310$node->restart;
311
312test_access(
313	$node,
314	"test1",
315	'SELECT gss_authenticated and not encrypted from pg_stat_gssapi where pid = pg_backend_pid();',
316	0,
317	"gssencmode=prefer",
318	"succeeds with GSS-encrypted access preferred and hostnogssenc hba, but no encryption"
319);
320test_access($node, "test1", 'SELECT true', 2, "gssencmode=require",
321	"fails with GSS-encrypted access required and hostnogssenc hba");
322test_access(
323	$node,
324	"test1",
325	'SELECT gss_authenticated and not encrypted from pg_stat_gssapi where pid = pg_backend_pid();',
326	0,
327	"gssencmode=disable",
328	"succeeds with GSS encryption disabled and hostnogssenc hba");
329
330truncate($node->data_dir . '/pg_ident.conf', 0);
331unlink($node->data_dir . '/pg_hba.conf');
332$node->append_conf('pg_hba.conf',
333	qq{host all all $hostaddr/32 gss include_realm=0});
334$node->restart;
335
336test_access(
337	$node,
338	'test1',
339	'SELECT gss_authenticated AND encrypted from pg_stat_gssapi where pid = pg_backend_pid();',
340	0,
341	'',
342	'succeeds with include_realm=0 and defaults');
343