diff --git a/CHANGELOG b/CHANGELOG
new file mode 100644
index 0000000..e2a73f0
--- /dev/null
+++ b/CHANGELOG
@@ -0,0 +1,7 @@
+= Change Log
+
+Below is a complete listing of changes for each revision of PostPolicy.
+
+=== 0.0.1
+
+* Initial release
diff --git a/MIT-LICENSE b/MIT-LICENSE
new file mode 100644
index 0000000..4bf38e5
--- /dev/null
+++ b/MIT-LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2009 Michał Łomnicki
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README b/README
new file mode 100644
index 0000000..7dda549
--- /dev/null
+++ b/README
@@ -0,0 +1,43 @@
+= PostPolicy: Postfix Policy Server in Ruby
+
+PostPolicy uses ACL system, which allow administrators to create rules based on mail source.
+Unlike simple Postfix policy restrictions in PostPolicy one can create very complex rules against incoming mail.
+PostPolicy is built on top of eventmachine, event-driven network library used for critical networked applications.
+
+PostPolicy is under heavy development so don't expect too much at the moment ;)
+
+== DEPENDENCIES
+
+* eventmachine
+* rpsec (only for tests)
+
+== USAGE
+
+Configure postpolicy in /etc/postpolicy.yml
+
+Read http://www.postfix.org/SMTPD_POLICY_README.html
+
+append to your master.cf
+
+ policy unix - n n - 0 spawn user=nobody argv=/path/to/postpolicy
+
+in your main.cf
+
+ smtpd_recipient_restrictions =
+ ...
+ reject_unauth_destination
+ check_policy_service unix:private/policy
+
+== ABOUT
+
+Author:: Michał Łomnicki
+License:: Copyright 2009 by Michał Łomnicki
+ Released under a MIT license.
+
+== Warranty
+
+This software is provided "as is" and without any express or
+implied warranties, including, without limitation, the implied
+warranties of merchantibility and fitness for a particular
+purpose.
+
diff --git a/bin/postpolicy.rb b/bin/postpolicy.rb
new file mode 100644
index 0000000..84d6852
--- /dev/null
+++ b/bin/postpolicy.rb
@@ -0,0 +1,36 @@
+require 'optparse'
+
+$LOAD_PATH.unshift File.join( File.dirname( __FILE__ ), '../lib' )
+require 'postpolicy'
+
+DEFAULT_CONFIG = File.exists?( '/etc/postpolicy.yml' ) ? '/etc/postpolicy.yml' :
+ File.join( File.dirname( __FILE__ ), '../postpolicy.yml')
+
+DEFAULT_OPTIONS = {
+ :verbose => false,
+ :config => DEFAULT_CONFIG
+}
+
+begin
+ options = DEFAULT_OPTIONS
+ OptionParser.new do |opts|
+ opts.banner = "Usage: #{$0} [options]"
+
+ opts.on("-v", "--verbose", "Verbose logging") do |v|
+ options[:verbose] = v
+ end
+
+ opts.on("-c", "--config", "Path to configuration file") do |c|
+ options[:config] = c
+ end
+ end.parse!
+
+ VERBOSE = options[:verbose]
+
+ Logger.info( "Starting PostPolicy #{PostPolicy::VERSION::STRING}" ) if VERBOSE
+ PostPolicy::Config.load_from_file( options[:config] )
+ PostPolicy::Protocol.new.start!
+rescue
+ Logger.error( $!.message )
+ raise $!
+end
diff --git a/lib/postpolicy.rb b/lib/postpolicy.rb
new file mode 100644
index 0000000..f007166
--- /dev/null
+++ b/lib/postpolicy.rb
@@ -0,0 +1,11 @@
+require 'rubygems'
+require 'eventmachine'
+
+require 'postpolicy/config'
+require 'postpolicy/protocol'
+require 'postpolicy/access_manager'
+require 'postpolicy/logger'
+require 'postpolicy/extensions'
+require 'postpolicy/version'
+
+VERBOSE = false unless defined?( VERBOSE )
diff --git a/lib/postpolicy/access_manager.rb b/lib/postpolicy/access_manager.rb
new file mode 100644
index 0000000..f9b9d33
--- /dev/null
+++ b/lib/postpolicy/access_manager.rb
@@ -0,0 +1,36 @@
+require 'rubygems'
+require 'eventmachine'
+
+module PostPolicy
+
+ class AccessManager
+
+ include EventMachine::Deferrable
+
+ DEFAULT_ACTION = "DUNNO"
+
+ @@access_list = []
+
+ def self.access_list
+ @@access_list
+ end
+
+ def self.<<( action )
+ @@access_list << action
+ end
+
+ def check( args )
+ action = DEFAULT_ACTION
+ @@access_list.each do |access|
+ if access.match?( args )
+ action = access.action
+ break
+ end
+ end
+ yield action if block_given?
+ return action
+ end
+
+ end
+end
+
diff --git a/lib/postpolicy/config.rb b/lib/postpolicy/config.rb
new file mode 100644
index 0000000..52f1815
--- /dev/null
+++ b/lib/postpolicy/config.rb
@@ -0,0 +1,48 @@
+require 'yaml'
+require 'singleton'
+
+Dir.glob( File.join( File.dirname( __FILE__ ), "plugins/base/*" ) ).each { |base| require base }
+Dir.glob( File.join( File.dirname( __FILE__ ), "plugins/acl/*" ) ).each { |acl| require acl }
+Dir.glob( File.join( File.dirname( __FILE__ ), "plugins/datasource/*" ) ).each { |datasource| require datasource }
+
+module PostPolicy
+
+ class Config
+
+ def self.load_from_file( filename )
+ load( YAML.load_file( filename ) )
+ end
+
+ def self.load( config_hash )
+ acls = Hash.new { |hsh, key| hsh[key] = Array.new }
+ config_acls = config_hash.delete( "acl" )
+ config_acls.each_pair do |human_name, klass_value_maps|
+ klass_value_maps.each_pair do |klass, value|
+ acls[human_name] << ACL.const_get(klass.classify).new( resolve_datasource( value ) )
+ end
+ end
+ actions = config_hash.delete( "action" )
+ accesses = config_hash.delete( "access" )
+ accesses.each_pair do |action, acl|
+ AccessManager << Access.new( acls[acl], actions[action] )
+ end
+ end
+
+ protected
+
+ def self.resolve_datasource( klass_and_value )
+ @@datasource_cache ||= {}
+ klass, value = klass_and_value.split( "://" )
+ if value == nil # consider as constant value
+ value = klass
+ klass = "value"
+ end
+ @@datasource_cache[klass] ||= DataSource.const_get(klass.classify)
+
+ return @@datasource_cache[klass].new( value )
+ end
+
+ end
+
+end
+
diff --git a/lib/postpolicy/extensions.rb b/lib/postpolicy/extensions.rb
new file mode 100644
index 0000000..b4e75e6
--- /dev/null
+++ b/lib/postpolicy/extensions.rb
@@ -0,0 +1 @@
+require File.join( File.dirname( __FILE__ ), 'extensions/string' )
diff --git a/lib/postpolicy/extensions/string.rb b/lib/postpolicy/extensions/string.rb
new file mode 100644
index 0000000..0d77b66
--- /dev/null
+++ b/lib/postpolicy/extensions/string.rb
@@ -0,0 +1,11 @@
+class String
+
+ def camelize
+ self.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase }
+ end
+
+ def classify
+ self.sub(/.*\./, '').camelize
+ end
+
+end
diff --git a/lib/postpolicy/logger.rb b/lib/postpolicy/logger.rb
new file mode 100644
index 0000000..b1433ce
--- /dev/null
+++ b/lib/postpolicy/logger.rb
@@ -0,0 +1,28 @@
+require 'syslog'
+
+module Logger
+
+ @@app_name = "rpolicy"
+ @@log_opts = (Syslog::LOG_PID | Syslog::LOG_CONS)
+ @@facility = Syslog::LOG_MAIL
+ Syslog.open( @@app_name, @@log_opts, @@facility )
+
+ def self.error( msg )
+ Syslog.err( msg )
+ end
+
+ def self.info( msg )
+ Syslog.info( msg )
+ end
+
+ def self.debug( msg )
+ Syslog.debug( msg )
+ end
+
+ def self.warn( msg )
+ Syslog.warning( msg )
+ end
+
+
+end
+
diff --git a/lib/postpolicy/plugins/acl/recipient.rb b/lib/postpolicy/plugins/acl/recipient.rb
new file mode 100644
index 0000000..121ad49
--- /dev/null
+++ b/lib/postpolicy/plugins/acl/recipient.rb
@@ -0,0 +1,13 @@
+module PostPolicy
+ module ACL
+
+ class Recipient < Base
+
+ def match?( args )
+ datasource.exists? args[:recipient]
+ end
+
+ end
+
+ end
+end
diff --git a/lib/postpolicy/plugins/acl/sender.rb b/lib/postpolicy/plugins/acl/sender.rb
new file mode 100644
index 0000000..685f9a1
--- /dev/null
+++ b/lib/postpolicy/plugins/acl/sender.rb
@@ -0,0 +1,13 @@
+module PostPolicy
+ module ACL
+
+ class Sender < Base
+
+ def match?( args )
+ datasource.exists? args[:sender]
+ end
+
+ end
+
+ end
+end
diff --git a/lib/postpolicy/plugins/base/access.rb b/lib/postpolicy/plugins/base/access.rb
new file mode 100644
index 0000000..9a16c71
--- /dev/null
+++ b/lib/postpolicy/plugins/base/access.rb
@@ -0,0 +1,20 @@
+module PostPolicy
+
+ class Access
+
+ def initialize( acls, action )
+ @acls = acls
+ @action = action
+ end
+
+ def match?( args )
+ @acls.all? { |acl| acl.match?( args ) }
+ end
+
+ def action
+ @action
+ end
+
+ end
+
+end
diff --git a/lib/postpolicy/plugins/base/acl.rb b/lib/postpolicy/plugins/base/acl.rb
new file mode 100644
index 0000000..597b113
--- /dev/null
+++ b/lib/postpolicy/plugins/base/acl.rb
@@ -0,0 +1,19 @@
+module PostPolicy
+ module ACL
+
+ class Base
+
+ attr_reader :datasource
+
+ def initialize( datasource )
+ @datasource = datasource
+ end
+
+ def match?( args )
+ :dunno
+ end
+
+ end
+
+ end
+end
diff --git a/lib/postpolicy/plugins/base/datasource.rb b/lib/postpolicy/plugins/base/datasource.rb
new file mode 100644
index 0000000..1e42dcb
--- /dev/null
+++ b/lib/postpolicy/plugins/base/datasource.rb
@@ -0,0 +1,17 @@
+module PostPolicy
+ module DataSource
+
+ class Base
+
+ def initialize( values = [] )
+ @values = values
+ end
+
+ def exists?( object )
+ @values.include?( object )
+ end
+
+ end
+
+ end
+end
diff --git a/lib/postpolicy/plugins/datasource/value.rb b/lib/postpolicy/plugins/datasource/value.rb
new file mode 100644
index 0000000..96a55a3
--- /dev/null
+++ b/lib/postpolicy/plugins/datasource/value.rb
@@ -0,0 +1,12 @@
+module PostPolicy
+ module DataSource
+
+ class Value < Base
+
+ def initialize( values )
+ @values = [values].flatten #make it always an array
+ end
+
+ end
+ end
+end
diff --git a/lib/postpolicy/protocol.rb b/lib/postpolicy/protocol.rb
new file mode 100644
index 0000000..b9538fc
--- /dev/null
+++ b/lib/postpolicy/protocol.rb
@@ -0,0 +1,100 @@
+require 'set'
+
+module PostPolicy
+ class Protocol
+
+ PROTOCOLS = %w(ESMTP SMTP)
+ STATES = %w(CONNECT EHLO HELO MAIL RCPT DATA END-OF-MESSAGE VRFY ETRN)
+
+ TERMINATOR = "\n\n"
+
+ attr_reader :attributes
+
+ @@required_request_attributes = [:request, :protocol_state, :protocol_name, :helo_name, :queue_id, :sender, :recipient, :recipient_count, :client_address, :client_name, :reverse_client_name, :instance].to_set # Postfix 2.1 and later
+
+ @@request_attributes = @@required_request_attributes +
+ [:sasl_method, :sasl_username, :sasl_sender, :size, :ccert_subject, :ccert_issuer, :ccert_fingerprint, # Postifx 2.2 and later
+ :encryption_protocol, :encryption_cipher, :encryption_keysize, :etrn_domain, # Postifx 2.3 and later
+ :stress].to_set # Postifx 2.5 and later
+
+ def initialize
+ @attributes = {}
+ $stdout.sync = true
+ @buffer = []
+ end
+
+ def start!
+ while line = gets do
+ receive_line( line.chomp )
+ end
+ end
+
+ def receive_line( line )
+ unless line.empty?
+ @buffer << line
+ else
+ Logger.info @buffer.inspect if VERBOSE
+ parse_request
+ @buffer.clear
+ end
+ end
+
+ def response( action )
+ puts "action=#{action}#{TERMINATOR}" unless POST_POLICY_ENV == 'test'
+ end
+
+ protected
+ def parse_request
+ @buffer.each do |line|
+ key, value = line.split( '=' )
+ @attributes[key.to_sym] = value
+ end
+ return false if( sanitize_arguments == false || validate_arguments == false )
+ run_actions
+ end
+
+ def run_actions
+ am = PostPolicy::AccessManager.new
+ am.callback do |args|
+ am.check( args ) do |action|
+ Logger.debug "Returning response #{action}" if VERBOSE
+ response( action )
+ end
+ end
+ am.set_deferred_status :succeeded, @attributes
+ end
+
+ def sanitize_arguments
+ missing_attributes = @@required_request_attributes - @attributes.keys.to_set
+ unless missing_attributes.empty?
+ Logger.err "missing #{missing_attributes.to_a.join( ',' )}"
+ return false
+ end
+ unknown_attributes = @attributes.keys.to_set - @@request_attributes
+ unless unknown_attributes.empty?
+ Logger.warn( 'Unknown attributes in policy request %s' % unknown_attributes.to_a.join( ',' ) ) if VERBOSE
+ end
+ unknown_attributes.each { |attr| @attributes.delete( attr ) }
+ true
+ end
+
+
+ def validate_arguments
+ unless @attributes[:request] == "smtpd_access_policy"
+ Logger.err( 'only smtpd_access_policy request allowed' )
+ return false
+ end
+ unless PROTOCOLS.include?( @attributes[:protocol_name] )
+ Logger.err( "only #{PROTOCOLS.join(',')} protocols allowed" )
+ return false
+ end
+ unless STATES.include?( @attributes[:protocol_state] )
+ Logger.err( "only #{STATES.join(',')} states allowed" )
+ return false
+ end
+
+ true
+ end
+
+ end
+end
diff --git a/lib/postpolicy/version.rb b/lib/postpolicy/version.rb
new file mode 100644
index 0000000..f353159
--- /dev/null
+++ b/lib/postpolicy/version.rb
@@ -0,0 +1,9 @@
+module PostPolicy
+ module VERSION
+ MAJOR = 0
+ MINOR = 0
+ TINY = 1
+
+ STRING = [MAJOR, MAJOR, TINY].join('.')
+ end
+end
diff --git a/postpolicy.yml b/postpolicy.yml
new file mode 100644
index 0000000..e501d64
--- /dev/null
+++ b/postpolicy.yml
@@ -0,0 +1,11 @@
+acl:
+ foo:
+ sender: michal@lomnicki.com.pl
+
+action:
+ pass: REJECT
+
+access:
+ pass: foo
+
+
diff --git a/test/access_manager_spec.rb b/test/access_manager_spec.rb
new file mode 100644
index 0000000..1fa2a69
--- /dev/null
+++ b/test/access_manager_spec.rb
@@ -0,0 +1,47 @@
+require 'test_helper'
+
+CONFIG = {
+ "acl" => {
+ "michal-lomnicki" => {
+ "sender" => "michal@lomnicki.com.pl"
+ },
+ "foo-to-bar" => {
+ "sender" => "foo",
+ "recipient" => "bar"
+ }
+ },
+
+ "action" => { "reject" => "REJECT", "defer" => "DEFER_IF_PERMIT" },
+ "access" => {
+ "reject" => "michal-lomnicki",
+ "defer" => "foo-to-bar"
+ }
+}
+
+describe PostPolicy::AccessManager do
+
+ before(:all) do
+ PostPolicy::Config.load( CONFIG )
+ @am = PostPolicy::AccessManager.new
+ end
+
+ it "should reject michal@lomnicki.com.pl" do
+ @am.check( { :sender => "michal@lomnicki.com.pl" } ) do |action|
+ action.should == "REJECT"
+ end
+ end
+
+ it "should defer foo to bar" do
+ @am.check( { :sender => "foo", :recipient => "bar" } ) do |action|
+ action.should == "DEFER_IF_PERMIT"
+ end
+ end
+
+ # on any other acl default action should be given
+ it "should return default action on bar to foo" do
+ @am.check( { :sender => "bar", :recipient => "foo" } ) do |action|
+ action.should == PostPolicy::AccessManager::DEFAULT_ACTION
+ end
+ end
+
+end
diff --git a/test/acl_spec.rb b/test/acl_spec.rb
new file mode 100644
index 0000000..30be650
--- /dev/null
+++ b/test/acl_spec.rb
@@ -0,0 +1,21 @@
+require 'test_helper'
+
+describe PostPolicy::ACL::Sender do
+
+ it "should match given sender" do
+ ds = PostPolicy::DataSource::Value.new( ATTRS[:sender] )
+ sender_acl = PostPolicy::ACL::Sender.new( ds )
+ sender_acl.match?( ATTRS ).should == true
+ end
+
+end
+
+describe PostPolicy::ACL::Recipient do
+
+ it "should match given recipient" do
+ ds = PostPolicy::DataSource::Value.new( ATTRS[:recipient] )
+ recipient_acl = PostPolicy::ACL::Recipient.new( ds )
+ recipient_acl.match?( ATTRS ).should == true
+ end
+
+end
diff --git a/test/datasource_spec.rb b/test/datasource_spec.rb
new file mode 100644
index 0000000..4312230
--- /dev/null
+++ b/test/datasource_spec.rb
@@ -0,0 +1,24 @@
+require 'test_helper'
+
+describe PostPolicy::DataSource::Value do
+
+ it "should allow passing one or multiple values" do
+ value = PostPolicy::DataSource::Value.new( 1 )
+ value.exists?( 1 ).should == true
+ value = PostPolicy::DataSource::Value.new( [1,2] )
+ value.exists?( 1 ).should == true
+ value.exists?( 2 ).should == true
+ end
+
+ it "should match initialized values" do
+ GOOD_VALUES = [1,10,100]
+ BAD_VALUES = GOOD_VALUES.collect { |v| v * 2 }
+ value = PostPolicy::DataSource::Value.new( GOOD_VALUES )
+ value.exists?( GOOD_VALUES.first ).should == true
+ value.exists?( GOOD_VALUES.last ).should == true
+ value.exists?( BAD_VALUES.first ).should == false
+ value.exists?( BAD_VALUES.last ).should == false
+ end
+
+
+end
diff --git a/test/protocol_spec.rb b/test/protocol_spec.rb
new file mode 100644
index 0000000..6325f46
--- /dev/null
+++ b/test/protocol_spec.rb
@@ -0,0 +1,42 @@
+require 'stringio'
+require 'test_helper'
+
+describe PostPolicy::Protocol do
+
+ before(:each) do
+ @protocol = PostPolicy::Protocol.new
+ end
+
+ it "should parse attributes" do
+ form_string( ATTRS ).each_line { |l| @protocol.receive_line( l.strip ) }
+ @protocol.attributes.should == ATTRS
+ end
+
+ it "should discard unknown attributes" do
+ ATTRS.merge( :foo => 'bar', :bar => 'baz' ).each_pair { |k,v| @protocol.receive_line( [k,v].join( '=' ) ) }
+ form_string( ATTRS ).each_line { |l| @protocol.receive_line( l.strip ) }
+ @protocol.attributes.should == ATTRS
+ end
+
+ #it "should return false on missing attributes" do
+ # ATTRS.reject { |k,v| k == :protocol_name }.each_pair { |k,v| @protocol.receive_line( [k,v].join( '=' ) ) }
+ # form_string( ATTRS ).each_line { |l| @protocol.receive_line( l.strip ) }
+ # @protocol.receive.should == false
+ #end
+
+ #it "should validate arguments" do
+ # ATTRS.merge( :request => "bar" ).each_pair { |k,v| @protocol.receive_line( [k,v].join( '=' ) ) }
+ # form_string( ATTRS ).each_line { |l| @protocol.receive_line( l.strip ) }
+ # @protocol.receive.should == false
+ #end
+
+ #it "should terminate response" do
+ # @protocol.response("dunno").should == "action=dunno\n\n"
+ #end
+
+ protected
+ def form_string( args )
+ StringIO.new( args.each.inject("") { |str, kv| str << (kv.join('=') + "\n") } << "\n" )
+ end
+
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
new file mode 100644
index 0000000..d07d156
--- /dev/null
+++ b/test/test_helper.rb
@@ -0,0 +1,27 @@
+$LOAD_PATH.unshift File.join( File.dirname( __FILE__ ), '../lib' )
+require 'postpolicy'
+
+POST_POLICY_ENV = ENV['POST_POLICY_ENV'] || "test"
+
+ATTRS = {
+ :request => "smtpd_access_policy",
+ :protocol_state => "RCPT",
+ :protocol_name => "SMTP",
+ :helo_name => "some.domain.tld",
+ :queue_id => "8045F2AB23",
+ :sender => "foo@bar.tld",
+ :recipient => "bar@foo.tld",
+ :recipient_count => "0",
+ :client_address => "1.2.3.4",
+ :client_name => "another.domain.tld",
+ :reverse_client_name => "another.domain.tld",
+ :instance => "123.456.7"
+}
+
+Object.send(:remove_const, :Logger)
+
+module Logger
+ def self.method_missing(symbol, *args)
+ #simpy do nothing
+ end
+end