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