2016年3月13日 星期日

垃圾郵件防治 EFA-Project SPAM Gateway



話說垃圾郵件一直都是MIS的痛,尤其是沒預算買SPAM閘道的(最小25U、50U對於10多人的小企業負擔不起),上Gmail就更不用說了(10U一年就要一萬多),防火牆或者UTM上面的SPAM過濾往往是半殘(在主旨加上SPAM或對中文內容無法過濾),雖然知道Linux可以架設SPAM閘道但是身為兼職的MIS多一事不如少一事,遲遲沒時間研究,偶然找到EFA這個專案(https://www.efa-project.org),他有ova在虛擬化上部署極快,啟動後經過簡單的設定即可開始運作。


EFA使用的是 CentOS6 + Postfix + MailScaner + SpamAssassin + MailWatch
而且已經預先配置完成,在CLI的精靈模式設定可以很容易地完成初始化
只需要設定欲接收的網域與轉送的Mail Server IP即可

其實公司的Mail Server有內建垃圾郵件過濾與灰名單功能,但是提供可設定的項目太少,隔離清單還是英文的,內勤人員根本不知道怎麼用。至於灰名單功能,時常會造成郵件延遲,所以後來關掉了。

基本上EFA滿足了對於一個基本的SPAM閘道需求
1. 有隔離所
2. 有spam分數,用來分析校調
3. 能做客製化調整(MailWatch是PHP寫的配合的資料庫是MySQL)





來說說限制跟地雷吧
1. 非透通式的,需要修改MX
2. 如果Mail主機不對外,EFA的SMTP要提供認證寄信功能只能用LDAP
3. MailWatch介面與通知信件只有英文
4. 沒有LDAP或者於MailWatch中建立帳號不會產生隔離報告郵件
5. 預設的通知郵件沒有取回郵件的連結,只有連結到MailWatch該封郵件的連結,需要登入才能處理郵件



基於上面的架構限制,也不想多維護一個LDAP,因此使用MX的方式將EFA的MX優先權放到5,而原本的Mail主機MX則設定為10,這樣讓現有的outlook用戶端不需要任何調整即可使用,而寄進來的郵件,會先用MX為5的EFA投遞郵件

不過,剛開始測試時因為預設開啟灰名單導致有些郵件主機被EFA因為灰名單因素reject投遞後會改用第二個MX繼續投遞,導致Mail沒有經過EFA的過濾,因此如過使用MX這種旁路架構,建議灰名單功能要停用。


再來處理隔離所通知沒帳號不會產生的問題,因為Mail Server是一個BOX軟體形式的,沒辦法安裝套件或者修改(便宜嘛),因此要用LDAP去同步帳號到EFA是不可行的,要重新建立帳號不是不行,只是這樣使用者又多了一組帳號密碼,操作上也頗麻煩(取回郵件還要多一個登入步驟),翻一下EFA論壇的文章發現隔離報告是由Crontab每日執行的

Cron設定位於 /etc/cron.daily/mailwatch

其中產生報告與寄送的程式是在 /usr/local/bin/mailwatch/tools/Cron_jobs/quarantine_report.php

修改後如下


#!/usr/bin/php -q
<?php
/*
MailWatch for MailScanner
Copyright (C) 2003-2011 Steve Freegard (steve@freegard.name)
Copyright (C) 2011 Garrod Alwood (garrod.alwood@lorodoes.com)
Copyright (C) 2014-2015 MailWatch Team (https://github.com/orgs/mailwatch/teams/team-stable)
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
In addition, as a special exception, the copyright holder gives permission to link the code of this program
with those files in the PEAR library that are licensed under the PHP License (or with modified versions of those
files that use the same license as those files), and distribute linked combinations including the two.
You must obey the GNU General Public License in all respects for all of the code used other than those files in the
PEAR library that are licensed under the PHP License. If you modify this program, you may extend this exception to
your version of the program, but you are not obligated to do so.
If you do not wish to do so, delete this exception statement from your version.
As a special exception, you have permission to link this program with the JpGraph library and
distribute executables, as long as you follow the requirements of the GNU GPL in regard to all of the software
in the executable aside from JpGraph.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
define("RELEASE_NOAUTH_URL",'http://EFA-HOST.Domain/cgi-bin/release-msg-noauth.cgi');
// Change the following to reflect the location of functions.php
require_once('/var/www/html/mailscanner/functions.php');
$required_constant = array(
'QUARANTINE_REPORT_DAYS',
'QUARANTINE_REPORT_HOSTURL',
'QUARANTINE_DAYS_TO_KEEP',
'QUARANTINE_REPORT_FROM_NAME',
'QUARANTINE_FROM_ADDR',
'QUARANTINE_REPORT_SUBJECT',
'MAILWATCH_HOME',
'QUARANTINE_MAIL_HOST',
'FROMTO_MAXLEN',
'SUBJECT_MAXLEN',
'TIME_ZONE',
'DATE_FORMAT',
'TIME_FORMAT'
);
$required_constant_missing_count = 0;
foreach ($required_constant as $constant) {
if (!defined($constant)) {
echo "The variable $constant is empty, please set a value in conf.php.\n";
$required_constant_missing_count++;
}
}
if ($required_constant_missing_count == 0) {
require_once('Mail.php');
require_once('Mail/mime.php');
date_default_timezone_set(TIME_ZONE);
ini_set('html_errors', 'off');
ini_set('display_errors', 'on');
ini_set('implicit_flush', 'false');
ini_set("memory_limit", '256M');
ini_set("error_reporting", E_ALL);
ini_set("max_execution_time", 0);
if (version_compare(phpversion(), '5.3.0', '<')) {
error_reporting(E_ALL ^ E_STRICT);
} else {
// E_DEPRECATED added in PHP 5.3
error_reporting(E_ALL ^ E_STRICT ^ E_DEPRECATED);
}
/*
** HTML Template
*/
$html = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<title>每日垃圾郵件報告</title>
<style type="text/css">
<!--
body, td, tr {
font-family: sans-serif;
font-size: 8pt;
}
-->
</style>
</head>
<body style="margin: 5px;">
<!-- Outer table -->
<table width="100%%" border="0">
<tr>
<td><img src="mailwatch-logo.png"/></td>
<td align="center" valign="middle">
<h2>%s 的垃圾郵件隔離清單</h2>
在最近的 %s 天,您接收了 %s 封垃圾郵件,隔離的清單如下。<br />隔離的郵件將會在收到後的 %s 天後被系統清除。
</td>
</tr>
<tr>
<td colspan="2">%s</td>
</tr>
</table>
</body>
</html>';
$html_table = '<table width="100%%" border="0">
<tr>
<td style="background-color: #F7CE4A"><b>接收時間</b></td>
<!--- <td style="background-color: #F7CE4A"><b>收件者</b></td> --->
<td style="background-color: #F7CE4A"><b>寄件者</b></td>
<td style="background-color: #F7CE4A"><b>主旨</b></td>
<td style="background-color: #F7CE4A"><b>類型</b></td>
<td style="background-color: #F7CE4A"><b>動作</b></td>
</tr>
%s
</table>';
$html_content = ' <tr>
<td style="background-color: #EBEBEB">%s</td>
<!--- <td style="background-color: #EBEBEB">%s</td> --->
<td style="background-color: #EBEBEB">%s</td>
<td style="background-color: #EBEBEB">%s</td>
<td style="background-color: #EBEBEB">%s</td>
<td style="background-color: #EBEBEB">%s</td>
</tr>
';
/*
** Text Template
*/
$text = '%s 的垃圾郵件隔離清單
在最近的 %s 天,您接收了 %s 封垃圾郵件,隔離的清單如下。
隔離的郵件將會在收到後的 %s 天後被系統清除。
%s';
$text_content = '接收時間: %s
寄件者: %s
主旨: %s
類型: %s
動作:
%s
';
/*
** SQL Templates
*/
$users_sql = "
SELECT
username,
quarantine_rcpt,
type
FROM
users
WHERE
quarantine_report=1
";
$filters_sql = "
SELECT
filter
FROM
user_filters
WHERE
username=%s
AND
active='Y'
";
$sql = "
SELECT DISTINCT
a.id AS id,
DATE_FORMAT(timestamp,'" . str_replace('%', '%%', DATE_FORMAT) . " <br/>" . str_replace('%', '%%', TIME_FORMAT) . "') AS datetime,
DATE_FORMAT(timestamp,'%%Y%%m%%d') AS releasedate,
a.to_address AS to_address,
a.from_address AS from_address,
a.subject AS subject,
CASE
WHEN a.virusinfected>0 THEN 'Virus'
WHEN a.nameinfected>0 THEN 'Bad Content'
WHEN a.otherinfected>0 THEN 'Infected'
WHEN a.ishighspam>0 THEN 'Spam'
WHEN a.issaspam>0 THEN 'Spam'
WHEN a.isrblspam>0 THEN 'Spam'
WHEN a.spamblacklisted>0 THEN 'Blacklisted'
WHEN a.isspam THEN 'Spam'
WHEN a.ismcp>0 THEN 'Policy'
WHEN a.ishighmcp>0 THEN 'Policy'
WHEN a.issamcp>0 THEN 'Policy'
WHEN a.mcpblacklisted>0 THEN 'Policy'
WHEN a.isspam>0 THEN 'Spam'
ELSE 'UNKNOWN'
END AS reason
FROM
maillog a
WHERE
/*
a.quarantined = 1
AND
*/
((to_address=%s) OR (to_domain=%s))
AND
((a.isspam > 0) OR (a.virusinfected > 0) OR (a.nameinfected > 0))
AND
a.date >= DATE_SUB(CURRENT_DATE(), INTERVAL " . QUARANTINE_REPORT_DAYS . " DAY)";
// Hide high spam/mcp from users if enabled
if (defined('HIDE_HIGH_SPAM') && HIDE_HIGH_SPAM) {
$sql .= "
AND
ishighspam=0
AND
ishighmcp=0";
}
$sql .= "
ORDER BY a.date DESC, a.time DESC";
$users_sql= "
select distinct to_address as username,
to_address as quarantine_rcpt,
'U' as type from maillog where timestamp >'%s' and isspam!=0";
$users_sql=sprintf($users_sql,date('Y-m-d','-'.QUARANTINE_REPORT_DAYS.' day'));
//echo 'SQL:'.$users_sql."\n";
$result = dbquery($users_sql);
$rows = mysql_num_rows($result);
//echo 'SPAM item:'.$rows."\n";
if ($rows > 0) {
while ($user = mysql_fetch_object($result)) {
dbg("\n === Generating report for " . $user->username . " type=" . $user->type);
// Work out destination e-mail address
switch ($user->type) {
case 'U':
// Type: user - see if to address needs to be overridden
if (!empty($user->quarantine_rcpt)) {
$email = $user->quarantine_rcpt;
} else {
$email = $user->username;
}
$to_address = $user->username;
$to_domain = $user->username;
break;
case 'D':
// Type: domain admin - this must be overridden
$email = $user->quarantine_rcpt;
$to_address = $user->username;
if (preg_match('/(\S+)@(\S+)/', $user->username, $split)) {
$to_domain = $split[2];
} else {
$to_domain = $user->username;
}
break;
default:
// Shouldn't ever get here - but just in case...
$email = $user->quarantine_rcpt;
$to_address = $user->username;
$to_domain = $user->username;
break;
}
// Make sure we have a destination address
if (!empty($email)) {
dbg(" ==== Recipient e-mail address is $email");
// Get any additional reports required
$filters = array_merge(array($email), return_user_filters($user->username));
foreach ($filters as $filter) {
dbg(" ==== Building list for $filter");
$quarantined = return_quarantine_list_array($filter, $to_domain);
dbg(" ==== Found " . count($quarantined) . " quarantined e-mails");
//print_r($quarantined);
if (count($quarantined) > 0) {
send_quarantine_email($email, $filter, $quarantined);
}
unset($quarantined);
}
} else {
dbg(" ==== " . $user->username . " has empty e-mail recipient address, skipping...");
}
}
}
}
function dbg($text)
{
echo $text . "\n";
}
function return_user_filters($user)
{
global $filters_sql;
$result = dbquery(sprintf($filters_sql, quote_smart($user)));
$rows = mysql_num_rows($result);
$array = array();
if ($rows > 0) {
while ($row = mysql_fetch_object($result)) {
$array[] = $row->filter;
}
}
return $array;
}
function return_quarantine_list_array($to_address, $to_domain)
{
global $sql;
$result = dbquery(sprintf($sql, quote_smart($to_address), quote_smart($to_domain)));
$rows = mysql_num_rows($result);
$array = array();
if ($rows > 0) {
while ($row = mysql_fetch_object($result)) {
$array[] = array(
'id' => trim($row->id),
'datetime' => trim($row->datetime),
'releasedate' => trim($row->releasedate),
'to' => trim_output($row->to_address, FROMTO_MAXLEN),
'from' => trim_output($row->from_address, FROMTO_MAXLEN),
'subject' => trim_output($row->subject, SUBJECT_MAXLEN),
'reason' => trim($row->reason)
);
}
}
return $array;
}
function send_quarantine_email($email, $filter, $quarantined)
{
global $html, $html_table, $html_content, $text, $text_content;
// Setup variables to prevent warnings
$h1 = "";
$t1 = "";
// Build the quarantine list for this recipient
foreach ($quarantined as $qitem) {
// HTML Version
$release_URL='<a href="' . RELEASE_NOAUTH_URL . '?id=' . $qitem['id'] . '&datenumber=' . $qitem['releasedate'] . '">取回郵件</a>';
if($qitem['reason']!='Spam'){
$release_URL='停用';
}
$h1 .= sprintf(
$html_content,
$qitem['datetime'],
$qitem['to'],
$qitem['from'],
$qitem['subject'],
$qitem['reason'],
//'<a href="' . QUARANTINE_REPORT_HOSTURL . '/viewmail.php?id=' . $qitem['id'] . '">檢視</a>'
$release_URL
);
// Text Version
$t1 .= sprintf(
$text_content,
strip_tags($qitem['datetime']),
$qitem['to'],
$qitem['from'],
$qitem['subject'],
$qitem['reason'],
//'<a href="' . QUARANTINE_REPORT_HOSTURL . '/viewmail.php?id=' . $qitem['id'] . '">檢視</a>'
$release_URL
);
}
// HTML
$h2 = sprintf($html_table, $h1);
$html_report = sprintf($html, $filter, QUARANTINE_REPORT_DAYS, count($quarantined), QUARANTINE_DAYS_TO_KEEP, $h2);
if (DEBUG) {
echo $html_report;
}
// Text
$text_report = sprintf($text, $filter, QUARANTINE_REPORT_DAYS, count($quarantined), QUARANTINE_DAYS_TO_KEEP, $t1);
if (DEBUG) {
echo "<pre>$text_report</pre>\n";
}
// Send e-mail
$mime = new Mail_mime("\n");
$hdrs = array(
//'From' => QUARANTINE_REPORT_FROM_NAME . ' <' . QUARANTINE_FROM_ADDR . '>',
'From' => '郵件隔離所 <' . QUARANTINE_FROM_ADDR . '>',
'To' => $email,
'Subject' => '每日垃圾郵件隔離清單',
'Date' => date("r"),
'Content-Type' => 'text/html; charset=UTF-8',
'Content-Transfer-Encoding' => '8bit'
);
$mimeparams['text_encoding']="8bit";
$mimeparams['text_charset']="UTF-8";
$mimeparams['html_charset']="UTF-8";
$mimeparams['head_charset']="UTF-8";
$mime->addHTMLImage(MAILWATCH_HOME . '/images/mailwatch-logo.png', 'image/png', 'mailwatch-logo.png', true);
$mime->setTXTBody($text_report);
$mime->setHTMLBody($html_report);
$body = $mime->get($mimeparams);
$hdrs = $mime->headers($hdrs);
$mail_param = array('host' => QUARANTINE_MAIL_HOST);
$mail =& Mail::factory('smtp', $mail_param);
$mail->send($email, $hdrs, $body);
dbg(" ==== Sent e-mail to $email");
}

變更內容
1. 不使用MailWatch資料庫中的帳號清單(改為從隔離所中取得收件者當作帳號)
2. 中文化,去除收件者欄位
3. 不只顯示SPAM清單,連同病毒郵件與禁止的附件郵件也包含在清單中(不過後兩者無法取回,只能要求寄件者重新寄送)
4. 去除檢視郵件連結改為取回郵件(無需認證)



取回郵件在MailWatch的設計中需要登入,為了使用上的方便所以修改一版不需要登入的版本如下

#!/usr/bin/perl
# +--------------------------------------------------------------------+
# EFA release spam message script version 20140105
# This script is an modification of the previous ESVA release-msg.cgi
# +--------------------------------------------------------------------+
# Copyright (C) 2013~2015 http://www.efa-project.org
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# +--------------------------------------------------------------------+
use CGI::Carp qw(fatalsToBrowser);
use CGI qw(:standard);
use utf8;
#binmode(STDOUT, ':encoding(utf8)');
print "Content-type: text/html; charset=UTF-8 \n\n";
my $release_success =<<'RE_SUCCESS';
<html><head></head>
<body style="text-align:center;">
<p>&nbsp;</p>
<p><h1>已解除隔離,將郵件寄到您的信箱中。</h1></p>
</body></html>
RE_SUCCESS
my $release_error =<<'RE_ERR';
<html><head></head>
<body style="text-align:center;">
<p>&nbsp;</p>
<p><h1>解除隔離失敗,請聯絡系統管理員。</h1></p>
</body></html>
RE_ERR
$query = new CGI;
$id = param("id");
$datenumber = param("datenumber");
# Check if the variables contain data if one of them is not we die and not even check the syntax..
if ($id eq "" ){
die "Error variable is empty"
}
if ($datenumber eq "" ){
die "Error variable is empty"
}
# First check the ID variable
if ($id =~ /^[A-F0-9]{8}\.[A-F0-9]{5}|[A-F0-9]{9}\.[A-F0-9]{5}|[A-F0-9]{10}\.[A-F0-9]{5}|[A-F0-9]{11}\.[A-F0-9]{5}$/){
if ($datenumber =~ /^([2-9]\d{3}((0[1-9]|1[012])(0[1-9]|1\d|2[0-8])|(0[13456789]|1[012])(29|30)|(0[13578]|1[02])31)|(([2-9]\d)(0[48]|[2468][048]|[13579][26])|(([2468][048]|[3579][26])00))0229)$/){
$sendmail = "/usr/sbin/sendmail.postfix";
$msgtorelease = "/var/spool/MailScanner/quarantine/$datenumber/spam/$id";
if (-e $msgtorelease){
open(MAIL, "|$sendmail -t <$msgtorelease") or die "Cannot open $sendmail: $!";
close(MAIL);
print $release_success;
} else {
print $release_error;
}
} else {
print "Error in datanumber syntax";
}
} else {
print "Error in id syntax";
}

放置到 /var/www/cgi-bin/ 目錄中即可

沒有留言:

張貼留言