]> git.proxmox.com Git - pmg-api.git/blob - PMG/CLI/pmgqm.pm
7f231b211a7ac4522b210c13052f0c950e5c3039
[pmg-api.git] / PMG / CLI / pmgqm.pm
1 package PMG::CLI::pmgqm;
2
3 use strict;
4 use Data::Dumper;
5 use Template;
6 use MIME::Entity;
7 use HTML::Entities;
8 use Time::Local;
9 use Clone 'clone';
10 use Mail::Header;
11 use POSIX qw(strftime);
12 use File::Find;
13 use File::stat;
14 use URI::Escape;
15
16 use PVE::SafeSyslog;
17 use PVE::Tools;
18 use PVE::INotify;
19 use PVE::CLIHandler;
20
21 use PMG::RESTEnvironment;
22 use PMG::Utils;
23 use PMG::Ticket;
24 use PMG::DBTools;
25 use PMG::RuleDB;
26 use PMG::Config;
27 use PMG::ClusterConfig;
28 use PMG::API2::Quarantine;
29
30 use base qw(PVE::CLIHandler);
31
32 sub setup_environment {
33 PMG::RESTEnvironment->setup_default_cli_env();
34 }
35
36 sub domain_regex {
37 my ($domains) = @_;
38
39 my @ra;
40 foreach my $d (@$domains) {
41 # skip domains with non-DNS name characters
42 next if $d =~ m/[^A-Za-z0-9\-\.]/;
43 if ($d =~ m/^\.(.*)$/) {
44 my $dom = $1;
45 $dom =~ s/\./\\\./g;
46 push @ra, $dom;
47 push @ra, "\.\*\\.$dom";
48 } else {
49 $d =~ s/\./\\\./g;
50 push @ra, $d;
51 }
52 }
53
54 my $re = join ('|', @ra);
55
56 my $regex = qr/\@($re)$/i;
57
58 return $regex;
59 }
60
61 sub get_item_data {
62 my ($data, $ref) = @_;
63
64 my @lines = split ('\n', $ref->{header});
65 my $head = new Mail::Header(\@lines);
66
67 my $item = {};
68
69 $item->{id} = sprintf("C%dR%d", $ref->{cid}, $ref->{rid});
70
71 $item->{subject} = PMG::Utils::rfc1522_to_html(
72 PVE::Tools::trim($head->get('subject')) || 'No Subject');
73
74 my @fromarray = split('\s*,\s*', $head->get('from') || $ref->{sender});
75 my $from = PMG::Utils::rfc1522_to_html(PVE::Tools::trim($fromarray[0]));
76 my $sender = PMG::Utils::rfc1522_to_html(PVE::Tools::trim($head->get('sender')));
77
78 if ($sender) {
79 $item->{sender} = $sender;
80 $item->{from} = sprintf ("%s on behalf of %s", $sender, $from);
81 } else {
82 $item->{from} = $from;
83 }
84
85 $item->{pmail} = $ref->{pmail};
86 $item->{receiver} = $ref->{receiver} || $ref->{pmail};
87
88 $item->{date} = strftime("%F", localtime($ref->{time}));
89 $item->{time} = strftime("%H:%M:%S", localtime($ref->{time}));
90
91 $item->{bytes} = $ref->{bytes};
92 $item->{spamlevel} = $ref->{spamlevel};
93 $item->{spaminfo} = $ref->{info};
94
95 my $title = "Received: $item->{date} $item->{time}\n";
96 $title .= "From: $ref->{sender}\n";
97 $title .= "To: $ref->{receiver}\n" if $ref->{receiver};
98 $title .= sprintf("Size: %d KB\n", int (($ref->{bytes} + 1023) / 1024 ));
99 $title .= sprintf("Spam level: %d\n", $ref->{spamlevel}) if $ref->{qtype} eq 'S';
100 $title .= sprintf("Virus info: %s\n", encode_entities ($ref->{info})) if $ref->{qtype} eq 'V';
101 $title .= sprintf("File: %s", encode_entities($ref->{file}));
102
103 $item->{title} = $title;
104
105 my $basehref = "https://$data->{fqdn}:$data->{port}/quarantine";
106 my $ticket = uri_escape($data->{ticket});
107 $item->{wlhref} = "$basehref?ticket=$ticket&cselect=$item->{id}&action=whitelist";
108 $item->{blhref} = "$basehref?ticket=$ticket&cselect=$item->{id}&action=blacklist";
109 $item->{deliverhref} = "$basehref?ticket=$ticket&cselect=$item->{id}&action=deliver";
110 $item->{deletehref} = "$basehref?ticket=$ticket&cselect=$item->{id}&action=delete";
111
112 return $item;
113 }
114
115 sub finalize_report {
116 my ($tt, $template, $data, $mailfrom, $receiver, $debug) = @_;
117
118 my $html = '';
119
120 $tt->process($template, $data, \$html) ||
121 die $tt->error() . "\n";
122
123 my $title;
124 if ($html =~ m|^\s*<title>(.*)</title>|m) {
125 $title = $1;
126 } else {
127 die "unable to extract template title\n";
128 }
129
130 my $top = MIME::Entity->build(
131 Type => "multipart/related",
132 To => $data->{pmail},
133 From => $mailfrom,
134 Subject => PMG::Utils::bencode_header(decode_entities($title)));
135
136 $top->attach(
137 Data => $html,
138 Type => "text/html",
139 Encoding => $debug ? 'binary' : 'quoted-printable');
140
141 if ($debug) {
142 $top->print();
143 return;
144 }
145 # we use an empty envelope sender (we dont want to receive NDRs)
146 PMG::Utils::reinject_mail ($top, '', [$receiver], undef, $data->{fqdn});
147 }
148
149 __PACKAGE__->register_method ({
150 name => 'send',
151 path => 'send',
152 method => 'POST',
153 description => "Generate and send spam report emails.",
154 parameters => {
155 additionalProperties => 0,
156 properties => {
157 receiver => {
158 description => "Generate report for a single email address. If not specified, generate reports for all users.",
159 type => 'string', format => 'email',
160 optional => 1,
161 },
162 timespan => {
163 description => "Select time span.",
164 type => 'string',
165 enum => ['today', 'yesterday', 'week'],
166 default => 'today',
167 optional => 1,
168 },
169 style => {
170 description => "Spam report style. Value 'none' just prints the spam counts and does not send any emails. Default value is read from spam quarantine configuration.",
171 type => 'string',
172 enum => ['none', 'short', 'verbose', 'custom'],
173 optional => 1,
174 },
175 redirect => {
176 description => "Redirect spam report email to this address.",
177 type => 'string', format => 'email',
178 optional => 1,
179 },
180 debug => {
181 description => "Debug mode. Print raw email to stdout instead of sending them.",
182 type => 'boolean',
183 optional => 1,
184 default => 0,
185 }
186 },
187 },
188 returns => { type => 'null'},
189 code => sub {
190 my ($param) = @_;
191
192 my $cinfo = PMG::ClusterConfig->new();
193 my $role = $cinfo->{local}->{type} // '-';
194
195 if (!(($role eq '-') || ($role eq 'master'))) {
196 die "local node is not master - not sending spam report\n";
197 }
198
199 my $cfg = PMG::Config->new();
200
201 my $reportstyle = $param->{style} // $cfg->get('spamquar', 'reportstyle');
202
203 my $timespan = $param->{timespan} // 'today';
204
205 my (undef, undef, undef, $mday, $mon, $year) = localtime(time());
206 my $daystart = timelocal(0, 0, 0, $mday, $mon, $year);
207
208 my $start;
209 my $end;
210
211 if ($timespan eq 'today') {
212 $start = $daystart;
213 $end = $start + 86400;
214 } elsif ($timespan eq 'yesterday') {
215 $end = $daystart;
216 $start = $end - 86400;
217 } elsif ($timespan eq 'week') {
218 $end = $daystart;
219 $start = $end - 7*86400;
220 } else {
221 die "internal error";
222 }
223
224 my $hostname = PVE::INotify::nodename();
225
226 my $fqdn = $cfg->get('spamquar', 'hostname') //
227 PVE::Tools::get_fqdn($hostname);
228
229 my $port = 8006;
230
231 my $global_data = {
232 protocol => 'https',
233 port => $port,
234 fqdn => $fqdn,
235 hostname => $hostname,
236 date => strftime("%F", localtime($end - 1)),
237 timespan => $timespan,
238 items => [],
239 };
240
241 my $mailfrom = $cfg->get ('spamquar', 'mailfrom') //
242 "Proxmox Mail Gateway <postmaster>";
243
244 my $dbh = PMG::DBTools::open_ruledb();
245
246 my $target = $param->{receiver};
247 my $redirect = $param->{redirect};
248
249 if (defined($redirect) && !defined($target)) {
250 die "can't redirect mails for all users\n";
251 }
252
253 my $domains = PVE::INotify::read_file('domains');
254 my $domainregex = domain_regex([keys %$domains]);
255
256 my $template;
257
258 if ($reportstyle ne 'none') {
259
260 $template = "spamreport-${reportstyle}.tt";
261 my $found = 0;
262 foreach my $path (@$PMG::Config::tt_include_path) {
263 if (-f "$path/$template") { $found = 1; last; }
264 }
265 if (!$found) {
266 warn "unable to find template '$template' - using default\n";
267 $template = "spamreport-verbose.tt";
268 }
269 }
270
271 my $sth = $dbh->prepare(
272 "SELECT * FROM CMailStore, CMSReceivers " .
273 "WHERE time >= $start AND time < $end AND " .
274 ($target ? "pmail = ? AND " : '') .
275 "QType = 'S' AND CID = CMailStore_CID AND RID = CMailStore_RID " .
276 "AND Status = 'N' " .
277 "ORDER BY pmail, time, receiver");
278
279 if ($target) {
280 $sth->execute($target);
281 } else {
282 $sth->execute();
283 }
284
285 my $lastref;
286 my $mailcount = 0;
287 my $creceiver = '';
288 my $data;
289
290 my $tt = PMG::Config::get_template_toolkit();
291
292 my $finalize = sub {
293
294 my $extern = ($domainregex && $creceiver !~ $domainregex);
295
296 if ($template) {
297 if (!$extern) {
298 $data->{mailcount} = $mailcount;
299 my $sendto = $redirect ? $redirect : $creceiver;
300 finalize_report($tt, $template, $data, $mailfrom, $sendto, $param->{debug});
301 }
302 } else {
303 my $hint = $extern ? " (external address)" : "";
304 printf ("%-5d %s$hint\n", $mailcount, $creceiver);
305 }
306 };
307
308 while (my $ref = $sth->fetchrow_hashref()) {
309 if ($creceiver ne $ref->{pmail}) {
310
311 $finalize->() if $data;
312
313 $data = clone($global_data);
314
315 $creceiver = $ref->{pmail};
316 $mailcount = 0;
317
318 $data->{pmail} = $creceiver;
319 $data->{ticket} = PMG::Ticket::assemble_quarantine_ticket($data->{pmail});
320 my $esc_ticket = uri_escape($data->{ticket});
321 $data->{managehref} = "https://$fqdn:$port/quarantine?ticket=${esc_ticket}";
322 }
323
324 if ($template) {
325 push @{$data->{items}}, get_item_data($data, $ref);
326 }
327
328 $mailcount++;
329 $lastref = $ref;
330 }
331
332 $sth->finish();
333
334 $finalize->() if $data;
335
336 if (defined($target) && !$mailcount) {
337 print STDERR "no mails for '$target'\n";
338 }
339
340 return undef;
341 }});
342
343 sub find_stale_files {
344 my ($path, $lifetime, $purge) = @_;
345
346 return if ! -d $path;
347
348 my (undef, undef, undef, $mday, $mon, $year) = localtime(time());
349 my $daystart = timelocal(0, 0, 0, $mday, $mon, $year);
350 my $expire = $daystart - $lifetime*86400;
351
352 my $wanted = sub {
353 my $name = $File::Find::name;
354 return if $name !~ m|^($path/.*)$|;
355 $name = $1; # untaint
356 my $stat = stat($name);
357 return if ! -f _;
358 return if $stat->mtime >= $expire;
359 if ($purge) {
360 if (unlink($name)) {
361 print "removed: $name\n";
362 }
363 } else {
364 print "$name\n";
365 }
366 };
367
368 find({ wanted => $wanted, no_chdir => 1 }, $path);
369 }
370
371 sub test_quarantine_files {
372 my ($spamlifetime, $viruslifetime, $purge) = @_;
373
374 print STDERR "searching for stale files\n" if !$purge;
375
376 my $spooldir = $PMG::MailQueue::spooldir;
377
378 find_stale_files ("$spooldir/spam", $spamlifetime, $purge);
379 foreach my $dir (<"/var/spool/pmg/cluster/*/spam">) {
380 next if $dir !~ m|^(/var/spool/pmg/cluster/\d+/spam)$|;
381 $dir = $1; # untaint
382 find_stale_files ($dir, $spamlifetime, $purge);
383 }
384
385 find_stale_files ("$spooldir/virus", $viruslifetime, $purge);
386 foreach my $dir (<"/var/spool/pmg/cluster/*/virus">) {
387 next if $dir !~ m|^(/var/spool/pmg/cluster/\d+/virus)$|;
388 $dir = $1; # untaint
389 find_stale_files ($dir, $viruslifetime, $purge);
390 }
391 }
392
393 __PACKAGE__->register_method ({
394 name => 'purge',
395 path => 'purge',
396 method => 'POST',
397 description => "Cleanup Quarantine database. Remove entries older than configured quarantine lifetime.",
398 parameters => {
399 additionalProperties => 0,
400 properties => {
401 check => {
402 description => "Only search for quarantine files older than configured quarantine lifetime. Just print found files, but do not remove them.",
403 type => 'boolean',
404 optional => 1,
405 default => 0,
406 }
407 }
408 },
409 returns => { type => 'null'},
410 code => sub {
411 my ($param) = @_;
412
413 my $cfg = PMG::Config->new();
414
415 my $spamlifetime = $cfg->get('spamquar', 'lifetime');
416 my $viruslifetime = $cfg->get ('virusquar', 'lifetime');
417
418 my $purge = !$param->{check};
419
420 if ($purge) {
421 print STDERR "purging database\n";
422
423 my $dbh = PMG::DBTools::open_ruledb();
424
425 if (my $count = PMG::DBTools::purge_quarantine_database($dbh, 'S', $spamlifetime)) {
426 print STDERR "removed $count spam quarantine files\n";
427 }
428
429 if (my $count = PMG::DBTools::purge_quarantine_database($dbh, 'V', $viruslifetime)) {
430 print STDERR "removed $count virus quarantine files\n";
431 }
432 }
433
434 test_quarantine_files($spamlifetime, $viruslifetime, $purge);
435
436 return undef;
437 }});
438
439
440 our $cmddef = {
441 'purge' => [ __PACKAGE__, 'purge', []],
442 'send' => [ __PACKAGE__, 'send', []],
443 'spam' => [ 'PMG::API2::Quarantine', 'spam', [], undef, sub {
444 my $res = shift;
445 print "Day Count AVG(Spam)\n";
446 foreach my $ref (@$res) {
447 print sprintf("%-12s %5d %10.2f\n",
448 strftime("%F", localtime($ref->{day})),
449 $ref->{count}, $ref->{spamavg});
450 }
451 }],
452 };
453
454 1;