Hi all,
I too have decided to tackle this problem and have what I believe to be a good solution.
First off though, we all have slightly different requirements, so I will first explain what my program does, then I will highlight those areas where you may want to make modifications.
I'd like to thank Sean for the original concept and Brett for pointing me at the MailEnable.Administration.dll.
Okay, here we go;
1. I read the activity log file like we have been taking about. I get the location from the official registry key defined by MailEnable (no hard-coding.)
2. I loop forever with a Sleep(). I chose this because sleeping is is less expensive CPU wise than a fresh program launch via scheduler.
Also, sleeping allows us to do most of our setup one-time only.
3. With each activity file roll-over (each midnight) I clear the ME Access Control (ban) list and start anew.
** your requirements may differ **
This works for me since it makes the code clear and straight forward. No need to attach an expiration date to each IP address, yuck. Simply clear the list every night and start over. So what if bad-guyX gets one more failed login attempt before being banned again?
4. Once lines are processed, I do not process them again and instead zip through the file to the last read entry in the file.
I still 'read' the lines, but throw them away immediately without ever doing any parsing or matching. This is very fast and has almost no cost (I am testing this on 20K to 40K line files.)
** optimization **
You probably could use Seek() to eliminate the reading the lines altogether, but I found my method clear and fast enough.
5. My program bans on the FIRST failed login.
** your requirements will be different **
My situation is somewhat special in that I do not have user-owned mail boxes. All mailboxes are owned by me.
I do send email to my subscribers, and my subscribers send me email, but I don't have random users logging into my mailboxes. Therefore I *know* any failed login is an attack and therefore block it.
==> You can easily modify my implementation to count failed login attempts and ban accordingly.
6. This is a console app that displays the various state changes: banning someone, clearing the ban list, etc.
You can easily launch this app on machine startup or user login from a login script.
I think that's about it.
Best to all,
luket
[UPDATE] Version 2
This version adds the following enhancements:
1. Only reads the log file if there has been an update.
(This works hand-in-hand with only reading those lines that are new.)
2. Special midnight log rollover check handling.
I suspect a race condition where we are trying to open/read the new file and ME either hasn't created it yet, or is in the process of creating/writing it. This code now ensures we are out of the way when ME goes to create the file and write to it for the first time.
I left the diagnostic message in place as they describe the interesting midnight rollover sequence.
3. Partial line handling.
It is possible to read a partial line because ME has not finished writing it. This code detects such an edge case and ignores such lines and ensures it is read next pass.
Code: Select all
#undef LOCAL // define LOCAL to use local copy of ME activity for debugging / development
using System;
using System.Collections.Generic;
using System.Text;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
namespace BanFailLogin
{
class Program
{
static void Main(string[] args)
{
string path = AppDomain.CurrentDomain.BaseDirectory;
path = path.Replace("\\", "/"); // cleanup the path
Directory.SetCurrentDirectory(path); // set current directory to the path of this executable
DateTime lastDateTime = new DateTime(); // place to save the date
string activityFile = string.Empty; // file to parse
int last_lineRead = 0; // last line read
int current_lineRead = 0; // current line
List<String> ips = new List<String>(); // Create a list of IP addresses.
long last_fileLength = 0; // we check this to see if the file has been written to
#if (LOCAL)
// just use the current path (".") to an activiy file for debug / development purposes
// the current path will point to a local copy of the avtivity file that lives with this exe
#else
// read the location of the real activity log file
String keyName = "HKEY_LOCAL_MACHINE\\SOFTWARE\\Wow6432Node\\Mail Enable\\Mail Enable\\Connectors\\SMTP\\";
path = (string)Microsoft.Win32.Registry.GetValue(keyName, "Activity Log Directory", path);
path = path.Replace("\\", "/"); // cleanup the path
if (path[path.Length - 1] != '/') path += '/'; // add a trailing '/' if needed
#endif
// first time run
if (activityFile == String.Empty)
{
// create the name of the new activity file
activityFile = String.Format("{0}SMTP-Activity-{1}.log", path, DateTime.Now.ToString("yyMMdd"));
lastDateTime = DateTime.Now; // a new day and a fresh start
last_lineRead = 0; // start over at the beginning of the file
last_fileLength = 0; // don't read the file unless the file size changes`
}
// forever baby
for (; ; )
{
// MIDNIGHT ROLLOVER CODE
// if we have rolled over to a new day AND ME has created a new activity file, reset everything:
// get the new file name, clear the ME ban list, clear our local copy of banned addresses,
// reset the last_lineRead so that we start reading at the beginning of the new file
// But don't wait until we have actually rolled over since it can cause a race condition between us trying to
// read the old file while ME is trying to delete it. Instead enter our waiting state 5 seconds before
// midnight to make sure we are well out of the way when ME deletes the old file and creates the new file.
int secondsToMidnight = 0;
if (lastDateTime.Day == DateTime.Now.Day)
{ // this if statement protects us incase the clock has already rolled over and we missed it.
// in this case, we just proceed as if it's midnight.
TimeSpan ts = DateTime.Today.AddDays(1).Subtract(DateTime.Now);
secondsToMidnight = (int)ts.TotalSeconds;
}
if (secondsToMidnight <= 5)
{
try
{
// *** DIAG
// we found that ME didn't delete the old activity file until 175 minutes after midnight.
// This probably means that the creation of the new one (which is created at precisely at midnight)
// and the deletion of the old one are not bound, but rather the delition of the old one is done in some
// lazy cleanup task. We will try to prove that now.
String old_activityFile = activityFile;
// *** DIAG
Console.WriteLine("It's {0} seconds until midnight, entering a waiting state.", secondsToMidnight);
// Wait for the day to click over to a new day
// will just loop until it's ready.
while (lastDateTime.Day == DateTime.Now.Day)
{
Console.WriteLine("Waiting for midnight...");
System.Threading.Thread.Sleep(5000); // give ME a chance to update without us having it open
continue; // just loop without doing any more work.
}
Console.WriteLine("Okay, it's midnight...");
// create/get the name of the new activity file
activityFile = String.Format("{0}SMTP-Activity-{1}.log", path, DateTime.Now.ToString("yyMMdd"));
// Okay, now wait for the new activity file
// will just loop until it's ready.
while (File.Exists(activityFile) == false)
{ // it's midnight but ME has not yet created the new log file
Console.WriteLine("Waiting for ME to create the new Activity file...");
System.Threading.Thread.Sleep(5000); // give ME a chance to update without us having it open
continue; // just loop without doing any more work.
}
Console.WriteLine("Okay, new Activity file created at {0}.", DateTime.Now);
if (File.Exists(old_activityFile))
Console.WriteLine("Old Activity file still exists at {0} and is {1} bytes.", DateTime.Now, new FileInfo(old_activityFile).Length);
else
Console.WriteLine("Old Activity file no longer exists at {0}.", DateTime.Now);
// Okay, now wait for the new activity file to contain some data
// will just loop until it's ready.
while (new FileInfo(activityFile).Length == 0)
{ // ME has created the new log file, but it is empty
Console.WriteLine("Waiting for ME to write to the new Activity file...");
if (File.Exists(old_activityFile))
Console.WriteLine("Meanwhile, the old Activity file exists and is {0} bytes", new FileInfo(old_activityFile).Length);
System.Threading.Thread.Sleep(5000); // give ME a chance to update without us having it open
continue; // just loop without doing any more work.
}
Console.WriteLine("Okay, the new Activity file contains data at {0}.", DateTime.Now);
// *** DIAG
if (File.Exists(old_activityFile))
{// it's midnight but ME has not yet deleted the old log file
Console.WriteLine("At {0}:", DateTime.Now);
Console.WriteLine("The new activity file exists and has been written to, but the old Activity file still exists.");
Console.WriteLine("The old Activity file is {0} bytes", new FileInfo(old_activityFile).Length);
Console.WriteLine("We will just continue on now and assume ME will clean this up later");
}
// *** DIAG
// okay, the new activity file has been created by ME
Console.WriteLine(String.Format("** Resetting: banned IP Addresses **"));
TimeSpan tx = DateTime.Now - new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day, 0, 0, 0);
Console.WriteLine("** Reset at " + tx.TotalMinutes.ToString() + " minutes past midnight");
lastDateTime = DateTime.Now; // a new day and a fresh start
ClearMEBanList(); // Clear the ME ban list
ips.Clear(); // Clear our local ban list
last_lineRead = 0; // start over at the beginning of the file
last_fileLength = 0; // initial value
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}
try
{ // only process if the file has changed size
if (new FileInfo(activityFile).Length != last_fileLength)
{
last_fileLength = new FileInfo(activityFile).Length;
// Open the text file using a stream reader.
using (FileStream fs = new FileStream(activityFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
using (StreamReader sr = new StreamReader(fs))
{
String line;
current_lineRead = 0;
// skip over the parts we have already read
while ((line = ReadLine(sr)) != null)
{
// skip over lines we have already read
if (current_lineRead < last_lineRead)
{
current_lineRead++;
continue;
}
// there are new lines to read, lets read them now
break;
}
// check and see if we are at the end of the file
if (line != null)
{
// Read a line and split into fields
do
{ // we read another line
last_lineRead++;
// see if it's an invalid login (hacker)
if (line.IndexOf("535 Invalid Username or Password") != -1)
{
string[] fields = line.Split();
if (fields.Length >= 6)
{ // record the IP address
if (ips.Contains(fields[5]) == false)
{
ips.Add(fields[5]);
AddMEBanList(fields[5]); // Add to the ME ban list
Console.WriteLine("Banning IP Address: " + fields[5]);
}
}
}
} while ((line = ReadLine(sr)) != null);
}
}
}
}
}
catch (Exception e)
{
Console.WriteLine("The file could not be read:");
Console.WriteLine(e.Message);
}
// always sleep
System.Threading.Thread.Sleep(5000);
}
}
static void ClearMEBanList()
{
MailEnable.Administration.SMTPAccess smtpAccess = new MailEnable.Administration.SMTPAccess();
// Set filter defaults
smtpAccess.AddressMask = "";
smtpAccess.Host = "";
smtpAccess.Mode = 1;
smtpAccess.Account = "";
smtpAccess.Right = "";
smtpAccess.Status = -1;
int res = smtpAccess.FindFirstAccess();
while (res > 0)
{ // wipe this guy from the naughty list
Console.WriteLine("Wiping " + smtpAccess.AddressMask + " from ME ban list");
smtpAccess.RemoveAccess();
// reset filter defaults
smtpAccess.AddressMask = "";
smtpAccess.Host = "";
smtpAccess.Mode = 1;
smtpAccess.Account = "";
smtpAccess.Right = "";
smtpAccess.Status = -1;
//res = smtpAccess.FindNextAccess(); // list gets messed up when calling Next
res = smtpAccess.FindFirstAccess(); // and deleting. Better to keep calling First
}
}
static void AddMEBanList(String ips)
{
MailEnable.Administration.SMTPAccess smtpAccess = new MailEnable.Administration.SMTPAccess();
// Set filter to add
smtpAccess.AddressMask = ips;
smtpAccess.Host = "";
smtpAccess.Mode = 1;
smtpAccess.Account = "SYSTEM";
smtpAccess.Right = "CONNECT";
smtpAccess.Status = 1;
// ban this guy
smtpAccess.AddAccess();
}
static String ReadLine(StreamReader sr)
{
try
{
// read a line of text
// we write our own line-reader here so that we can know if the line is complete, ending in \r\n
// if the line is not terminated properly, we may be reading before ME has finished writing.
// in such a case, we will ignore the line and read it again next time.
String line = null;
int cx;
bool have_term = false;
while ((cx = sr.Read()) != -1)
{
switch (cx)
{
case '\r': continue;
case '\n': { have_term = true; break; }
default: { line += (Char)cx; continue; }
}
break;
}
/////////////////////////
// The next two checks make sure the line is properly terminated.
// we check both cases since we don't know what happens if you are reading
// while the other process is writing. Will you read EOF? Better to be safe.
// have we reached EOF?
if (cx == -1)
{ // we should never reach EOF with a valid line
line = null;
return null;
}
// see if our line is properly terminated
if (have_term == false)
{ // if not, we will ignore this line and read it next time
line = null;
return null;
}
return line;
}
catch
{
return null;
}
}
}
}