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