maildiode 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
data/lib/engine.rb ADDED
@@ -0,0 +1,344 @@
1
+ # Copyright 2007-2008 Kevin B. Smith
2
+ # This file is part of MailDiode.
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License version 3, as
6
+ # published by the Free Software Foundation.
7
+
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+
17
+ module MailDiode
18
+ NOOP = 'NOOP'
19
+ HELO = 'HELO'
20
+ EHLO = 'EHLO'
21
+ QUIT = 'QUIT'
22
+ RSET = 'RSET'
23
+ VRFY = 'VRFY'
24
+ MAIL = 'MAIL'
25
+ RCPT = 'RCPT'
26
+ DATA = 'DATA'
27
+
28
+ RESULT_BYE = '221 Bye'
29
+ RESULT_OK = '250 Ok'
30
+ RESULT_UNSURE = '252'
31
+ RESULT_DATA_OK = '354'
32
+
33
+ ACCEPTED = :accepted
34
+ NO_OPINION = :no_opinion
35
+ end
36
+
37
+ module MailDiode
38
+
39
+ class FilterableData
40
+ attr_accessor :sender_ip
41
+ attr_accessor :helo
42
+ attr_accessor :sender
43
+ attr_accessor :recipient
44
+ end
45
+
46
+ class Filter
47
+ def process(filterable_data)
48
+ filterable_data
49
+ end
50
+ end
51
+
52
+ class MailHandler
53
+ def valid_recipient?(recipient)
54
+ false
55
+ end
56
+
57
+ def process_message(sender, recipient, message)
58
+ nil
59
+ end
60
+ end
61
+
62
+
63
+ class Envelope
64
+ def initialize(from_address)
65
+ @from = from_address
66
+ @to = []
67
+ end
68
+
69
+ def add_recipient(to_address)
70
+ @to << to_address
71
+ end
72
+
73
+ def sender
74
+ @from
75
+ end
76
+
77
+ def recipients
78
+ @to
79
+ end
80
+ end
81
+
82
+ # State machine that handles incoming SMTP commands
83
+ class Engine
84
+ def initialize(hostname)
85
+ @hostname = hostname
86
+ @greeting = "220 #{hostname} ESMTP"
87
+ @helo_response = "250 #{hostname}"
88
+ @filters = []
89
+ end
90
+
91
+ def set_mail_handler(handler)
92
+ @mail_handler = handler
93
+ end
94
+
95
+ def add_filter(filter)
96
+ @filters << filter
97
+ end
98
+
99
+ def start(sender_ip)
100
+ @sender_ip = sender_ip
101
+ return @greeting
102
+ end
103
+
104
+ def process_line(line)
105
+ begin
106
+ if @message_text
107
+ return do_message_text(line)
108
+ end
109
+
110
+ command, args = extract_args(line)
111
+ result = process_command(command, args)
112
+ MailDiode::log_success(command, args, result)
113
+ return result
114
+ rescue SMTPError => error
115
+ MailDiode.log_info(error.text)
116
+ return error.text
117
+ end
118
+
119
+ end
120
+
121
+ def terminate?
122
+ return @should_terminate
123
+ end
124
+
125
+ def process_command(command, args)
126
+ case command.upcase
127
+ when NOOP then return do_noop(args)
128
+ when QUIT then return do_quit(args)
129
+ when HELO then return do_helo(args)
130
+ when EHLO then return do_helo(args)
131
+ when VRFY then return do_vrfy(args)
132
+ when RSET then return do_rset(args)
133
+ when MAIL then return do_mail(args)
134
+ when RCPT then return do_rcpt(args)
135
+ when DATA then return do_data(args)
136
+ end
137
+
138
+ raise_smtp_error(SMTPError::BAD_COMMAND + ': ' + command)
139
+ end
140
+
141
+ def do_noop(args)
142
+ enforce_no_args(args, SMTPError::SYNTAX_NOOP)
143
+ return RESULT_OK
144
+ end
145
+
146
+ def do_quit(args)
147
+ enforce_no_args(args, SMTPError::SYNTAX_QUIT)
148
+ @should_terminate = true
149
+ return RESULT_BYE
150
+ end
151
+
152
+ def do_helo(args)
153
+ enforce_host_arg(args, SMTPError::SYNTAX_HELO)
154
+ @helo = args
155
+ @filters.each do | filter |
156
+ if filter.respond_to? :helo
157
+ filter.helo @helo
158
+ end
159
+ end
160
+ return @helo_response
161
+ end
162
+
163
+ def do_vrfy(args)
164
+ enforce_host_arg(args, SMTPError::SYNTAX_VRFY)
165
+ return RESULT_UNSURE
166
+ end
167
+
168
+ def do_rset(args)
169
+ enforce_no_args(args, SMTPError::SYNTAX_RSET)
170
+ @envelope = nil
171
+ return RESULT_OK
172
+ end
173
+
174
+ def do_mail(args)
175
+ from_address = enforce_address_arg(args, 'from', SMTPError::SYNTAX_MAIL)
176
+ @envelope = Envelope.new(from_address)
177
+ @filters.each do | filter |
178
+ if filter.respond_to? :mail
179
+ filter.mail from_address
180
+ end
181
+ end
182
+ return RESULT_OK
183
+ end
184
+
185
+ def do_rcpt(args)
186
+ if(! @envelope)
187
+ raise_smtp_error(SMTPError::NEED_MAIL_BEFORE_RCPT)
188
+ end
189
+ if(@envelope.recipients.size >= 100)
190
+ raise_smtp_error(SMTPError::TOO_MANY_RECIPIENTS)
191
+ end
192
+ recipient = enforce_address_arg(args, 'to', SMTPError::SYNTAX_RCPT)
193
+ @filters.each do | filter |
194
+ if filter.respond_to? :rcpt
195
+ filter.rcpt recipient
196
+ end
197
+ end
198
+ filterable_data = apply_filters(@envelope.sender, recipient)
199
+ if !@mail_handler.valid_recipient?(filterable_data.recipient)
200
+ raise_smtp_error(SMTPError::UNKNOWN_RECIPIENT)
201
+ end
202
+ @envelope.add_recipient(recipient)
203
+ return RESULT_OK
204
+ end
205
+
206
+ def do_data(args)
207
+ if(! @envelope || ! @envelope.sender)
208
+ raise_smtp_error(SMTPError::NEED_RCPT_BEFORE_DATA)
209
+ end
210
+ enforce_no_args(args, SMTPError::SYNTAX_DATA)
211
+ @message_text = []
212
+ return RESULT_DATA_OK
213
+ end
214
+
215
+
216
+
217
+
218
+
219
+ def apply_filters(sender, recipient)
220
+ filterable_data = FilterableData.new
221
+ filterable_data.sender_ip = @sender_ip
222
+ filterable_data.helo = @helo
223
+ filterable_data.sender = sender
224
+ filterable_data.recipient = recipient
225
+ @filters.each do | filter |
226
+ if filter.respond_to? :process
227
+ filter.process(filterable_data)
228
+ end
229
+ end
230
+ return filterable_data
231
+ end
232
+
233
+ def do_message_text(line)
234
+ if line == '.'
235
+ return terminate_message
236
+ end
237
+
238
+ if line[0..0] == '.'
239
+ line = line[1..-1]
240
+ end
241
+
242
+ @message_text << line
243
+ return nil
244
+ end
245
+
246
+ def terminate_message
247
+ sender = @envelope.sender
248
+ recipients = @envelope.recipients
249
+ text = @message_text
250
+ @envelope = nil
251
+ @message_text = nil
252
+
253
+ ids = []
254
+ recipients.each do | recipient |
255
+ ids << process_message(sender, recipient, text)
256
+ end
257
+ return "#{RESULT_OK} #{ids.join(';')}"
258
+ end
259
+
260
+ def process_message(sender, recipient, text)
261
+ received = "Received: from #{@helo} (#{@sender_ip}) " +
262
+ "by #{@hostname} " +
263
+ "for <#{recipient}> " +
264
+ "at #{Time.now.to_s}"
265
+ full_text = received + NEWLINE + text.join(NEWLINE)
266
+
267
+ filterable_data = apply_filters(sender, recipient)
268
+ id = @mail_handler.process_message(filterable_data.recipient, full_text)
269
+ return id
270
+ end
271
+
272
+ def enforce_no_args(args, error_text)
273
+ if ! args.empty?
274
+ raise_smtp_error(error_text)
275
+ end
276
+ end
277
+
278
+ def enforce_host_arg(args, error_text)
279
+ args = args.split(/\s+/)
280
+ if args.size != 1
281
+ raise_smtp_error(error_text)
282
+ end
283
+ end
284
+
285
+ def enforce_address_arg(args, keyword, error_text)
286
+ re = /#{keyword}:\s*(.+)/i
287
+ match = re.match(args)
288
+ if !match
289
+ raise_smtp_error(error_text)
290
+ end
291
+ candidate = match[1].strip
292
+ strip_brackets = /<(.*)>/
293
+ match = strip_brackets.match(candidate)
294
+ if(match)
295
+ candidate = match[1]
296
+ end
297
+
298
+ return candidate
299
+ end
300
+
301
+ def extract_args(line)
302
+ command, args = line.split(nil, 2)
303
+ if command.nil?
304
+ command = ''
305
+ end
306
+ if args.nil?
307
+ args = ''
308
+ end
309
+ return command, args
310
+ end
311
+
312
+ def raise_smtp_error(text)
313
+ raise SMTPError.new(text)
314
+ end
315
+
316
+ end
317
+ end
318
+
319
+ module MailDiode
320
+
321
+ # SMTP errors (not success results)
322
+ class SMTPError < RuntimeError
323
+ attr_reader :text
324
+
325
+ def initialize(text)
326
+ @text = text
327
+ end
328
+
329
+ MESSAGE_NOT_HANDLED = "451 Failed: message not handled"
330
+ SYNTAX_NOOP = "501 Syntax: NOOP"
331
+ SYNTAX_QUIT = "501 Syntax: QUIT"
332
+ SYNTAX_HELO = "501 Syntax: HELO"
333
+ SYNTAX_RSET = "501 Syntax: RSET"
334
+ SYNTAX_VRFY = "501 Syntax: VRFY"
335
+ SYNTAX_MAIL = "501 Syntax: MAIL"
336
+ SYNTAX_RCPT = "501 Syntax: RCPT"
337
+ SYNTAX_DATA = "501 Syntax: DATA"
338
+ BAD_COMMAND = '502 Error: command not implemented'
339
+ TOO_MANY_RECIPIENTS = '552 Error: Too many recipients'
340
+ NEED_MAIL_BEFORE_RCPT = '503 Error: need MAIL before RCPT'
341
+ NEED_RCPT_BEFORE_DATA = '503 Error: need RCPT before DATA'
342
+ UNKNOWN_RECIPIENT = "550 Error: No such recipient here"
343
+ end
344
+ end
data/lib/greylist.rb ADDED
@@ -0,0 +1,137 @@
1
+ # Copyright 2007-2008 Kevin B. Smith
2
+ # This file is part of MailDiode.
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License version 3, as
6
+ # published by the Free Software Foundation.
7
+
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+ require 'kirbybase'
17
+
18
+ ONE_HOUR_IN_SECONDS = 60*60
19
+
20
+ module MailDiode
21
+ class GreylistFilter < Filter
22
+ # Traditional (client IP, sender address, recipient address)
23
+ # or selective (client IP, HELO name, sender domain)?
24
+ GREYLIST_ADDED = "450 You are being put on a waiting list--please try again later"
25
+ GREYLIST_WAITING = "450 You are still on the waiting list--please try later"
26
+ GREYLIST_DELAY_IN_SECONDS = 60
27
+
28
+ def initialize(settings)
29
+ @delay_minutes = 60
30
+ load_settings(settings)
31
+ @kirby = KirbyBase.new(:local, nil, nil, '/var/local/maildiode/')
32
+ ensure_database_table_exists
33
+ end
34
+
35
+ def delay_seconds
36
+ @settings.delay_minutes * 60
37
+ end
38
+
39
+ def process(filterable_data)
40
+ ip = filterable_data.sender_ip
41
+ helo = filterable_data.helo
42
+ from = filterable_data.sender
43
+ to = filterable_data.recipient
44
+
45
+ if is_bounce_message(from)
46
+ MailDiode::log_info "Greylist allowing bounce message"
47
+ return true
48
+ end
49
+ from_user, from_domain = from.split('@')
50
+ table = get_database_table
51
+ found = table.select { | r | (r.ip == ip && r.helo == helo && r.from_domain = from_domain) }
52
+ case found.size
53
+ when 0
54
+ record = create_record(ip, helo, from_user, from_domain, to)
55
+ MailDiode::log_info "Greylist added #{key(record)}"
56
+ raise SMTPError.new(GREYLIST_ADDED)
57
+ when 1
58
+ if !check_existing_record(found[0])
59
+ raise SMTPError.new(GREYLIST_WAITING)
60
+ end
61
+ else
62
+ MailDiode::log_warning "Greylist found multiple entries for #{ip};#{helo};#{from};#{to}"
63
+ end
64
+ end
65
+
66
+ def is_bounce_message(from)
67
+ return from.empty?
68
+ end
69
+
70
+ def key(record)
71
+ return "#{record.ip};#{record.helo};#{record.from_domain}"
72
+ end
73
+
74
+ def create_record(ip, helo, from_user, from_domain, to)
75
+ recno = get_database_table.insert do |r|
76
+ r.ip = ip
77
+ r.helo = helo
78
+ r.from_user = from_user
79
+ r.from_domain = from_domain
80
+ r.to = to
81
+ r.first_seen = now
82
+ r.last_seen = now
83
+ r.approved = false
84
+ record = r
85
+ end
86
+ return get_database_table.select { | r | r.recno == recno }
87
+ end
88
+
89
+ def check_existing_record(record)
90
+ if(record.approved)
91
+ MailDiode::log_info "Greylist approved"
92
+ return true
93
+ end
94
+
95
+ table = get_database_table
96
+ delay_until = record.first_seen + delay_seconds
97
+ if now > delay_until
98
+ table.update(:approved => true) { | r | r.recno == record.recno }
99
+ MailDiode::log_info "Greylist approved #{key(record)}"
100
+ return true
101
+ end
102
+
103
+ table.update(:last_seen => now) { | r | r.recno == record.recno }
104
+ MailDiode::log_info "Greylist delaying #{key(record)} until #{Time.at(delay_until)}"
105
+ return false
106
+ end
107
+
108
+ def now
109
+ return Time.now.to_i
110
+ end
111
+
112
+ def get_database_table
113
+ return @kirby.get_table(:greylist)
114
+ end
115
+
116
+ def ensure_database_table_exists
117
+ if @kirby.table_exists?(:greylist)
118
+ return
119
+ end
120
+ MailDiode::log_info "Creating greylist database"
121
+ @kirby.create_table(:greylist,
122
+ :ip, :String,
123
+ :helo, :String,
124
+ :from_user, :String,
125
+ :from_domain, :String,
126
+ :to, :String,
127
+ :first_seen, :Integer,
128
+ :last_seen, :Integer,
129
+ :approved, :Boolean
130
+ )
131
+ end
132
+
133
+ def load_settings(settings)
134
+ @delay_minutes = settings.get_setting('greylist', 'delayminutes').to_i
135
+ end
136
+ end
137
+ end
data/lib/maildir.rb ADDED
@@ -0,0 +1,113 @@
1
+ # Copyright 2007-2008 Kevin B. Smith
2
+ # This file is part of MailDiode.
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License version 3, as
6
+ # published by the Free Software Foundation.
7
+
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+ require 'gurgitate/deliver'
17
+
18
+ module MailDiode
19
+ class FolderMissingError < IOError
20
+ attr_reader :folder
21
+
22
+ def initialize(folder_name)
23
+ @folder = folder_name
24
+ end
25
+ end
26
+
27
+ class DeliverableMessage
28
+ include Gurgitate::Deliver::Maildir
29
+
30
+ def initialize(message_content)
31
+ @message = message_content
32
+ end
33
+
34
+ def save(maildir)
35
+ deliver_message(maildir)
36
+ end
37
+
38
+ def to_s
39
+ @message
40
+ end
41
+ end
42
+
43
+ class MaildirMessageHandler < MailHandler
44
+ def initialize(settings)
45
+ @maildirs = {}
46
+ load_settings(settings)
47
+ end
48
+
49
+ def valid_recipient?(recipient)
50
+ return get_maildir(recipient)
51
+ end
52
+
53
+ def process_message(recipient, full_text)
54
+ message = DeliverableMessage.new(full_text)
55
+ raw_dir = get_maildir(recipient)
56
+ folder = ''
57
+ dir = "#{raw_dir.chomp('/')}#{folder}/"
58
+ MailDiode::log_info "Delivering to #{dir}"
59
+ if !File.exist?(dir)
60
+ MailDiode::log_error "ERROR: Missing maildir folder: #{dir}"
61
+ raise FolderMissingError.new(File.basename(dir))
62
+ end
63
+ message.save(dir)
64
+ end
65
+
66
+ def get_maildir(recipient)
67
+ return @maildirs[recipient]
68
+ end
69
+ =begin
70
+ def valid_recipient?(recipient)
71
+ if (get_maildir(recipient))
72
+ return true
73
+ end
74
+ # @core.log.debug "Rejecting recipient: #{recipient}"
75
+ return false
76
+ end
77
+
78
+ def deliver_message(recipient, envelope_to, folder, raw_message_object)
79
+ received = "Received: from #{raw_message_object.heloname} " +
80
+ "(#{raw_message_object.origin_ip}) " +
81
+ "by #{raw_message_object.myhostname} " +
82
+ "with SMTP ID #{raw_message_object.smtp_id} " +
83
+ "for <#{recipient}>; " +
84
+ #"#{raw_message_object.timestamp.to_s}\n"
85
+ "#{Time.now.to_s}\n"
86
+ full_text = received + raw_message_object.content.gsub("\r","")
87
+ message = DeliverableMessage.new(full_text)
88
+ dir = "#{get_maildir(recipient).chomp('/')}#{folder}/"
89
+ # @core.log.info "Delivering for #{envelope_to} to #{dir}"
90
+ if !File.exist?(dir)
91
+ # @core.log.error "ERROR: Missing maildir folder: #{dir}"
92
+ raise FolderMissingError.new(File.basename(dir))
93
+ end
94
+ message.save(dir)
95
+
96
+ return true
97
+ end
98
+
99
+ =end
100
+
101
+ def load_settings(settings)
102
+ settings.get_settings('maildir').each do | setting |
103
+ name, maildir = setting
104
+ if(maildir.index('/') != 0)
105
+ raise "Maildir must start with slash (/): #{maildir}"
106
+ end
107
+ maildir.untaint
108
+ @maildirs[name] = maildir
109
+ end
110
+ end
111
+ end
112
+ end
113
+
data/lib/server.rb ADDED
@@ -0,0 +1,93 @@
1
+ # Copyright 2007-2008 Kevin B. Smith
2
+ # This file is part of MailDiode.
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License version 3, as
6
+ # published by the Free Software Foundation.
7
+
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+ require 'gserver'
17
+ require 'engine'
18
+ require 'io/wait'
19
+
20
+ module MailDiode
21
+ class Server < GServer
22
+ def initialize(engine_factory, settings)
23
+ load_settings(settings)
24
+
25
+ @factory = engine_factory
26
+ super(@port, @ip, @max_connections)
27
+ end
28
+
29
+ def serve(client)
30
+ begin
31
+ MailDiode::log_debug("Accepting client")
32
+ MailDiode::log_info("Connections: #{connections}")
33
+ client.binmode
34
+ engine = @factory.create_engine(@hostname)
35
+ family, port, hostname, ip = client.addr
36
+ greeting = engine.start(ip)
37
+ MailDiode::log_info("Greeting: #{greeting}")
38
+ client << response(greeting)
39
+ while !engine.terminate?
40
+ line = get_line(client, TIMEOUT_SECONDS)
41
+ if(!line)
42
+ MailDiode::log_info("---client disconnected")
43
+ return
44
+ end
45
+ line.chomp!(NEWLINE)
46
+ result = engine.process_line(line)
47
+ if(result)
48
+ discard_early_input(client)
49
+ client << response(result)
50
+ end
51
+ end
52
+ rescue Errno::ECONNRESET
53
+ MailDiode::log_info "---client dropped connection"
54
+ rescue Exception => e
55
+ MailDiode::log_error("Exception #{e.class}: #{e}")
56
+ e.backtrace.each do | line |
57
+ MailDiode::log_error(line)
58
+ end
59
+ client << response("451 Internal Server Error")
60
+ end
61
+ end
62
+
63
+ def get_line(input, timeout_seconds)
64
+ line = nil
65
+ t = Thread.new { line = input.gets(NEWLINE) }
66
+ t.join(timeout_seconds)
67
+ return line
68
+ end
69
+
70
+ def discard_early_input(client)
71
+ while(client.ready?)
72
+ client.getc
73
+ end
74
+ end
75
+
76
+ def response(text)
77
+ return "#{text}#{NEWLINE}"
78
+ end
79
+
80
+ def load_settings(settings)
81
+ @hostname = settings.get_setting('server', 'hostname')
82
+ @ip = settings.get_setting('server', 'ip')
83
+ @ip.untaint
84
+ @port = settings.get_int('server', 'port', DEFAULT_PORT)
85
+ @port.untaint
86
+ @max_connections = settings.get_int('server', 'maxconnections', DEFAULT_MAX_CONNECTIONS)
87
+ end
88
+
89
+ DEFAULT_PORT = 10025
90
+ DEFAULT_MAX_CONNECTIONS = 20
91
+ TIMEOUT_SECONDS = 1200 # djb recommends 1200
92
+ end
93
+ end