1#!/usr/bin/perl -w
2use strict;
3
4use Mail::SpamAssassin::Spamd::Config ();
5use Mail::SpamAssassin::Util          ();    # heavy, loads M::SA
6use Sys::Hostname qw(hostname);
7use File::Spec ();
8use Cwd        ();
9
10=head1 NAME
11
12apache-spamd -- start spamd with Apache as backend
13
14=head1 SYNOPSIS
15
16  apache-spamd --pidfile ... [ OPTIONS ]
17
18OPTIONS:
19  --httpd_path=path      path to httpd, eg. /usr/sbin/httpd.prefork
20  --httpd_opt=opt        option for httpd    (can occur multiple times)
21  --httpd_directive=line directive for httpd (can occur multiple times)
22  -k CMD                 passed to httpd (see L<httpd(1)> for values)
23  --apxs=path            path to apxs, eg /usr/sbin/apxs
24  --httpd_conf=path      just write a config file for Apache and exit
25
26See L<spamd(1)> for other options.
27
28If some modules are not in @INC, invoke this way:
29  perl -I/path/to/modules apache-spamd.pl \
30       --httpd_directive "PerlSwitches -I/path/to/modules"
31
32Note: pass the -H / --helper-home-dir option; there is no reasonable default.
33
34=head1 DESCRIPTION
35
36Starts spamd with Apache as a backend.  Apache is configured according to
37command line options, compatible to spamd where possible and makes sense.
38
39If this script doesn't work for you, complain.
40
41=head1 TODO
42
43 * misc MPMs
44 * testing on different platforms and configurations
45 * fix FIXME's
46 * review XXX's
47 * --create-prefs (?), --help, --virtual-config-dir
48 * current directory (home_dir_for_helpers?)
49
50=cut
51
52# NOTE: the amount of code here and list of loaded modules doesn't matter;
53# we exec() anyway.
54
55# NOTE: no point in using -T, it'd only mess up code with workarounds;
56# we don't process any user input but command line options.
57
58my $opt = Mail::SpamAssassin::Spamd::Config->new(
59	{
60		defaults => { daemonize => 1, port => 783, },
61		moreopts => [
62			qw(httpd_path|httpd-path=s httpd_opt|httpd-opt=s@
63			  httpd_directive|httpd-directive=s@ k:s apxs=s
64			  httpd_conf|httpd-conf=s)
65		],
66	}
67);
68
69# only standalone spamd implements these options.
70# you miss vpopmail?  get a real MTA.
71for my $option (
72	qw(round-robin setuid-with-sql setuid-with-ldap socketpath
73	socketowner socketgroup socketmode paranoid vpopmail)
74  )
75{
76	die "ERROR: --$option can't be used with apache-spamd\n"
77	  if defined $opt->{$option};
78}
79
80#
81# XXX: move these options (and sanity checks for them) to M::SA::S::Config?
82#
83
84die "ERROR: '$opt->{httpd_path}' does not exist or not executable\n"
85  if exists $opt->{httpd_path}
86  and !-f $opt->{httpd_path} || !-x _;
87$opt->{httpd_path} ||= 'httpd';    # FIXME: find full path
88
89$opt->{pidfile} ||= '/var/run/apache-spamd.pid'    # reasonable default
90  if -w '/var/run/' && -x _ && !-e '/var/run/apache-spamd.pid';
91die "ERROR: --pidfile is mandatory\n"    # this seems ugly, but has advantages
92  unless $opt->{pidfile};                # we won't be able to stop otherwise
93$opt->{pidfile} = File::Spec->rel2abs($opt->{pidfile});
94if (-d $opt->{pidfile}) {
95	die "ERROR: can't write pid, '$opt->{pidfile}' directory not writable\n"
96	  unless -x _ && -w _;
97	$opt->{pidfile} = File::Spec->catfile($opt->{pidfile}, 'apache-spamd.pid');
98}
99
100if (exists $opt->{k}) {                  # XXX: other option name?  or not?
101	die "ERROR: can't use -k with --httpd_conf\n" if exists $opt->{httpd_conf};
102	## I'm not sure if this toggle idea is a good one...
103	## useful for development.
104	$opt->{k} ||= -e $opt->{pidfile} ? 'stop' : 'start';
105	die "ERROR: -k start|stop|restart|reload|graceful|graceful-stop"
106	  . " or empty for toggle\n"
107	  unless $opt->{k} =~ /^(?:start|stop|restart|reload|graceful(?:-stop)?)$/;
108}
109$opt->{k} ||= 'start';
110
111if (exists $opt->{httpd_conf}) {
112	die "ERROR: --httpd_conf must be a regular file\n"
113	  if -e $opt->{httpd_conf} && !-f _;
114	$opt->{httpd_conf} = File::Spec->rel2abs($opt->{httpd_conf})
115	  unless $opt->{httpd_conf} eq '-';
116}
117
118unless ($opt->{username}) {
119	warn "$0:  Running as root, huh?  Asking for trouble, aren't we?\n" if $< == 0;
120	$opt->{username} = getpwuid($>);	# weird apache behaviour on 64bit machines if it's missing
121	warn "$0:  setting User to '$opt->{username}', pass --username to override\n"
122		if $opt->{debug} =~ /\b(?:all|info|spamd|prefork|config)\b/;
123}
124
125#
126# start processing command line and preparing config / cmd line for Apache
127#
128
129my @directives;    # -C ... (or write these to a temporary config file)
130my @run = (        # arguments to exec()
131	$opt->{httpd_path},
132	'-k', $opt->{k},
133	'-d', Cwd::cwd(),    # XXX: smarter... home_dir_for_helpers?
134);
135
136if ($opt->{debug} =~ /\ball\b/) {
137	push @run,        qw(-e debug);
138	push @directives, 'LogLevel debug';
139}
140
141push @run, '-X' if !$opt->{daemonize};
142push @run, @{ $opt->{httpd_opts} } if exists $opt->{httpd_opts};
143
144push @directives, 'ServerName ' . hostname(),
145  qq(PidFile "$opt->{pidfile}"),
146  qq(ErrorLog "$opt->{'log-file'}");
147
148#
149# only bother with these when we're not stopping
150#
151if ($opt->{k} !~ /stop|graceful/) {
152	my $modlist = join ' ', static_apache_modules($opt->{httpd_path});
153
154	push @directives,
155	  'LoadModule perl_module ' . apache_module_path('mod_perl.so')
156	  if $modlist !~ /\bmod.perl\.c\b/i;
157
158	# StartServers, MaxClients, etc
159	my $mpm = lc(
160		(
161			$modlist =~ /\b(prefork|worker|mpm_winnt|mpmt_os2
162          |mpm_netware|beos|event|metuxmpm|peruser)\.c\b/ix
163		)[0]
164	);
165	die "ERROR: unable to figure out which MPM is in use\n" unless $mpm;
166	push @directives, mpm_specific_config($mpm);
167
168	# directives from command line; might require mod_perl.so, so let's
169	# ignore these unless we're starting -- shouldn't be critical anyway
170	push @directives, @{ $opt->{httpd_directive} }
171	  if exists $opt->{httpd_directive};
172
173	push @directives, "TimeOut $opt->{'timeout-tcp'}" if $opt->{'timeout-tcp'};
174
175	# Listen
176	push @directives, defined $opt->{'listen-ip'}
177	  && @{ $opt->{'listen-ip'} }
178	  ? map({ 'Listen ' . ($_ =~ /:/ ? "[$_]" : $_) . ":$opt->{port}" }
179		@{ $opt->{'listen-ip'} })
180	  : "Listen $opt->{port}";
181
182	if ($opt->{ssl}) {
183		push @directives,
184		  'LoadModule ssl_module ' . apache_module_path('mod_ssl.so')
185		  if $modlist !~ /\bmod.ssl\.c\b/i;    # XXX: are there other variants?
186		push @directives, qq(SSLCertificateFile "$opt->{'server-cert'}")
187		  if exists $opt->{'server-cert'};
188		push @directives, qq(SSLCertificateKeyFile "$opt->{'server-key'}")
189		  if exists $opt->{'server-key'};
190		push @directives, 'SSLEngine on';
191		my $random = -r '/dev/urandom' ? 'file:/dev/urandom 256' : 'builtin';
192		push @directives, "SSLRandomSeed startup $random",
193		  "SSLRandomSeed connect $random";
194		##push @directives, 'SSLProtocol all -SSLv2';       # or v3 only?
195	}
196
197	# XXX: available in Apache 2.1+; previously in core (AFAIK);
198	# should we parse httpd -v?
199	push @directives,
200	  'LoadModule ident_module ' . apache_module_path('mod_ident.so'),
201	  'IdentityCheck on'
202	  if $opt->{'auth-ident'};
203	push @directives, "IdentityCheckTimeout $opt->{'ident-timeout'}"
204	  if $opt->{'auth-ident'} && defined $opt->{'ident-timeout'};
205
206	# SA stuff
207	push @directives,
208	  'PerlLoadModule Mail::SpamAssassin::Spamd::Apache2::Config',
209	  'SAenabled on';
210	push @directives, "SAAllow from @{$opt->{'allowed-ips'}}"
211	  if exists $opt->{'allowed-ips'};
212	push @directives, 'SAtell on' if $opt->{'allow-tell'};
213	push @directives, "SAtimeout $opt->{'timeout-child'}"
214	  if exists $opt->{'timeout-child'};
215	push @directives, "SAdebug $opt->{debug}" if $opt->{debug};
216	push @directives, 'SAident on'
217	  if $opt->{'auth-ident'};
218
219	push @directives, qq(SANew rules_filename "$opt->{configpath}")
220	  if defined $opt->{configpath};
221	push @directives, qq(SANew site_rules_filename "$opt->{siteconfigpath}")
222	  if defined $opt->{siteconfigpath};
223	push @directives,
224	  qq(SANew home_dir_for_helpers "$opt->{home_dir_for_helpers}")
225	  if defined $opt->{home_dir_for_helpers};
226	push @directives, qq(SANew local_tests_only $opt->{local})
227	  if defined $opt->{local};
228	push @directives, map qq(SANew $_ "$opt->{$_}"), grep defined $opt->{$_},
229	  qw(PREFIX DEF_RULES_DIR LOCAL_RULES_DIR LOCAL_STATE_DIR);
230	push @directives, 'SANew paranoid 1' if $opt->{paranoid};
231	push @directives, qq(SAConfigLine "$_") for @{ $opt->{cf} };
232
233	my @users;
234	push @users, 'local' if $opt->{'user-config'};
235	push @users, 'sql'   if $opt->{'sql-config'};
236	push @users, 'ldap'  if $opt->{'ldap-config'};
237	push @directives, join ' ', 'SAUsers', @users if @users;
238}
239
240# write directives to conf file (or STDOUT) and exit
241if ($opt->{httpd_conf}) {
242	my $fh;
243	if ($opt->{httpd_conf} eq '-') {
244		open $fh, '>&STDOUT' or die "open >&STDOUT: $!";
245	}
246	else {
247		open $fh, '>', $opt->{httpd_conf}
248		  or die "open >'$opt->{httpd_conf}': $!";
249	}
250	print $fh join "\n",
251	  "# generated by $0 on " . localtime(time),
252	  @directives,
253	  "# vim: filetype=apache\n";
254	close $fh or warn "close: $!";
255	exit 0;    # user is supposed to run Apache himself
256}
257
258#
259# add directives to command line and run Apache
260#
261
262push @run, '-f',
263  File::Spec->devnull(),    # XXX: will work on a non-POSIX platform?
264  map { ; '-C' => $_ } @directives;
265
266warn map({ /^-/ ? "\n    $_" : "  $_" } @run), "\n"
267  if $opt->{debug} =~ /\ball|spamd|config|info\b/;
268
269undef $opt;                 # there is no DESTROY... but could be one ;-)
270exec @run;                  # we are done
271
272#
273# helper functions
274#
275
276sub get_libexecdir {
277	get_libexecdir_A2BC() || get_libexecdir_apxs();
278}
279
280# read it from Apache2::BuildConfig
281sub get_libexecdir_A2BC {
282	$INC{'Apache2/Build.pm'}++;    # hack... needlessly required by BuildConfig
283	require Apache2::BuildConfig;
284	my $cfg = Apache2::BuildConfig->new;
285	$cfg->{APXS_LIBEXECDIR} || $cfg->{MODPERL_APXS_LIBEXECDIR};
286}
287
288# `apxs -q LIBEXECDIR`
289sub get_libexecdir_apxs {
290	my @cmd = (($opt->{apxs} || 'apxs'), '-q', 'LIBEXECDIR');
291	chomp(my $modpath = get_cmd_output(@cmd));
292	die "ERROR: failed to obtain module path from '@cmd'\n"
293	  unless length $modpath;
294	die "ERROR: '$modpath' returned by '@cmd' is not an existing directory\n"
295	  unless -d $modpath;
296	$modpath;
297}
298
299# as above, cached version
300our $apache_module_path;
301sub apache_module_path {
302	my $modname = shift;
303	$apache_module_path ||= get_libexecdir();    # path is cached
304	my $module = File::Spec->catfile($apache_module_path, $modname);
305	die "ERROR: '$module' does not exist\n" if !-e $module;
306	$module;
307}
308
309# httpd -l
310# XXX: can MPM be a DSO?
311sub static_apache_modules {
312	my $httpd = shift;
313	my @cmd = ($httpd, '-l');
314	my $out = get_cmd_output(@cmd);
315	my @modlist = $out =~ /\b(\S+\.c)\b/gi;
316	die "ERROR: failed to get list of static modules from '@cmd'\n"
317	  unless @modlist;
318	@modlist;
319}
320
321sub get_cmd_output {
322	my @cmd = @_;
323	my $output = `@cmd` or die "ERROR: failed to run '@cmd': $!\n";
324	$output;
325}
326
327sub mpm_specific_config {
328	my $mpm = shift;
329	my @ret;
330
331	if ($mpm =~ /^prefork|worker|beos|mpmt_os2$/) {
332		push @ret, "User $opt->{username}"   if $opt->{username};
333		push @ret, "Group $opt->{groupname}" if $opt->{groupname};
334	}
335	elsif ($opt->{username} || $opt->{groupname}) {
336		die "ERROR: username / groupname not supported with MPM $mpm\n";
337	}
338
339	if ($mpm eq 'prefork') {
340		push @ret, "StartServers $opt->{'min-spare'}";
341		push @ret, "MinSpareServers $opt->{'min-spare'}";
342		push @ret, "MaxSpareServers $opt->{'max-spare'}";
343		push @ret, "MaxClients $opt->{'max-children'}";
344	}
345	elsif ($mpm eq 'worker') {    # XXX: we could be smarter here
346		push @ret, grep length, map { s/^\s+//; s/\s*\b#.*$//; $_ } split /\n/,
347		  <<"    EOF";
348      StartServers 1
349      ServerLimit 1
350      MinSpareThreads $opt->{'min-spare'}
351      MaxSpareThreads $opt->{'max-spare'}
352      ThreadLimit $opt->{'max-children'}
353      ThreadsPerChild $opt->{'max-children'}
354    EOF
355	}
356	else {
357		warn "WARNING: MPM $mpm not supported, using defaults for performance settings\n";
358		warn "WARNING: prepare for huge memory usage and maybe an emergency reboot\n";
359	}
360
361	push @ret, "MaxRequestsPerChild $opt->{'max-conn-per-child'}"
362	  if defined $opt->{'max-conn-per-child'};
363
364	@ret;
365}
366
367# vim: ts=4 sw=4 noet
368