#!/usr/bin/perl use strict; use warnings; LematLimit->new( DB_DSN => 'DBI:mysql:policyd', DB_USER => 'policyd', DB_PASS => '', )->run() unless caller; # based on policyd-lemat2 version 1.4 # # 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. # # general purpose: limit the number of emails (recipients) an authenticated user can send # to external recipients # requires: # 1. EVERY local user is authenticated. In eg. PHP mail() function sends mail # without authentication and therefore this policyd is ***not***able*** to limit it. # 2. reject_sender_login_mismatch must be implemented because sending emails # from local user to local user is NOT limited. # 3. this script does not run as a daemon therefore may fail under heavy traffic. # Your users should use port 587 or 465 only and this script should be run only on submission port. # 4. postfix >= 2.2 # master.cf # policyd-lemat2 unix - n n - - spawn user=nobody argv=/usr/local/bin/policyd-lemat2 # submission inet n - n - - smtpd # ... # smtpd_recipient_restrictions=reject_non_fqdn_recipient,reject_unknown_recipient_domain,lemat_limit,permit_sasl_authenticated,reject # and for smtps - if there is override smtpd_recipient_restrictions then you should put lemat_limit before permit_sasl_authenticated # main.cf # smtpd_restriction_classes=lemat_limit # lemat_limit=check_policy_service unix:private/policyd-lemat2 # smtpd_sender_login_maps = mysql:/etc/postfix/mysql_virtual_sender_login_maps.cfg # smtpd_sender_restrictions = # reject_unknown_sender_domain, # reject_non_fqdn_sender, # reject_sender_login_mismatch, # smtpd_recipient_restrictions = # ... # reject_non_fqdn_recipient, # reject_unknown_recipient_domain, # ... # lemat_limit, # ... # permit_sasl_authenticated # ... # reject_unauth_destination, # ... package LematLimit; use version; our $VERSION = qv('1.4'); use strict; use IO::Handle; use Sys::Syslog qw(:DEFAULT setlogsock); use DBI; sub new { my ( $class, %a ) = @_; my $self = { VERBOSE => 0, DEFAULT_RESPONSE => 'DUNNO', ERROR_RESPONSE => 'DEFER_IF_PERMIT server configuration error', RECIPIENT_HOUR_LIMIT => 50, RECIPIENT_DAY_LIMIT => 100, IP_HOUR_LIMIT => 10, IP_DAY_LIMIT => 20, DB_DSN => 'DBI:mysql:database_name', DB_USER => 'database_user', DB_PASS => 'database_password', # # Syslogging options for verbose mode and for fatal errors. # NOTE: comment out the $SYSLOG_SOCKTYPE line if syslogging does not # work on your system. # SYSLOG_SOCKTYPE => 'unix', # inet, unix, stream, console SYSLOG_FACILITY => 'mail', SYSLOG_OPTIONS => 'pid', SYSLOG_IDENT => 'postfix/policyd-lemat2', NOW => 'now()', }; $self->{$_} = $a{$_} for ( keys %a ); $self->{DBH} = DBI->connect( $self->{DB_DSN}, $self->{DB_USER}, $self->{DB_PASS}, { PrintError => 0, mysql_multi_statements => 1 } ); return bless $self, $class; } sub DESTROY { my $self = shift; $self->{DBH}->disconnect() if $self->{DBH}; } sub create_tables { my $self = shift; my $sql = <<'END_SQL_DUMP'; -- phpMyAdmin SQL Dump -- version 4.1.14 -- http://www.phpmyadmin.net -- -- Host: localhost -- Generation Time: 02 Cze 2015, 20:23 -- Server version: 5.5.33a-MariaDB -- PHP Version: 5.3.29 SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; SET time_zone = "+00:00"; -- -- Database: `policyd_test` -- -- -------------------------------------------------------- -- -- Struktura tabeli dla tabeli `lemat_limit` -- DROP TABLE IF EXISTS `lemat_limit`; CREATE TABLE IF NOT EXISTS `lemat_limit` ( `username` varchar(255) NOT NULL, `recipient` varchar(255) NOT NULL, `ip` varchar(255) NOT NULL, `data` datetime NOT NULL, KEY `username` (`username`) ) ENGINE=MyISAM DEFAULT CHARSET=latin1; -- -------------------------------------------------------- -- -- Struktura tabeli dla tabeli `mailbox` -- DROP TABLE IF EXISTS `mailbox`; CREATE TABLE IF NOT EXISTS `mailbox` ( `username` varchar(255) NOT NULL, `recipient_hour_limit` int(11) NOT NULL, `recipient_day_limit` int(11) NOT NULL, `ip_hour_limit` int(11) NOT NULL, `ip_day_limit` int(11) NOT NULL, PRIMARY KEY (`username`) ) ENGINE=MyISAM DEFAULT CHARSET=latin1; END_SQL_DUMP return $self->{DBH}->do($sql); } sub check_limit { my ( $self, $attr, $column, $interval, $limit ) = @_; my $dbh = $self->{DBH}; my $user = $attr->{'sasl_username'}; my $sth = $dbh->prepare( "SELECT count(DISTINCT $column) as ile FROM lemat_limit WHERE username=? AND DATE_SUB(" . $self->{NOW} . ",INTERVAL $interval)execute($user) ) { syslog( err => "Couldn't execute statement: " . $sth->errstr ); return $self->{ERROR_RESPONSE}; } if ( my $result = $sth->fetchrow_hashref() ) { if ( $result->{ile} > $limit ) { return "550 sendmail $column limit (" . $limit . ') exceeded for ' . $user . ' ' . ( $attr->{'client_name'} || '' ) . '[' . $attr->{'client_address'} . ']'; } } return; } sub check_limits { my $self = shift; my $attr = shift; my $dbh = $self->{DBH}; my (@to_check) = @_; for (@to_check) { my @args = @$_; my $msg = $self->check_limit( $attr, @args ); return $msg if $msg; } return $self->{DEFAULT_RESPONSE}; } sub do_query { my $self = shift; my $attr = shift; my $user = $attr->{'sasl_username'}; my $recipient = $attr->{'recipient'}; my $client = $attr->{'client_address'}; my $RECIPIENT_HOUR_LIMIT = $self->{RECIPIENT_HOUR_LIMIT}; my $RECIPIENT_DAY_LIMIT = $self->{RECIPIENT_DAY_LIMIT}; my $IP_HOUR_LIMIT = $self->{IP_HOUR_LIMIT}; my $IP_DAY_LIMIT = $self->{IP_DAY_LIMIT}; my $dbh = $self->{DBH}; if ( !$dbh ) { syslog( err => "Could not connect to database: $DBI::errstr" ); return $self->{ERROR_RESPONSE}; } # fetch config from database, you may comment this out if you don't have extra fields (limits per user) my $sth = $dbh->prepare("SELECT * FROM mailbox WHERE username=?"); if ( !$sth->execute($user) ) { syslog( err => "Couldn't execute statement: " . $sth->errstr ); return $self->{ERROR_RESPONSE}; } if ( my $result = $sth->fetchrow_hashref() ) { if ( $result->{recipient_hour_limit} > 0 ) { $RECIPIENT_HOUR_LIMIT = $result->{recipient_hour_limit}; } if ( $result->{recipient_day_limit} > 0 ) { $RECIPIENT_DAY_LIMIT = $result->{recipient_day_limit}; } $IP_HOUR_LIMIT = $result->{ip_hour_limit} if $result->{ip_hour_limit} > 0; $IP_DAY_LIMIT = $result->{ip_day_limit} if $result->{ip_day_limit} > 0; } # insert into database $sth = $dbh->prepare( 'INSERT INTO lemat_limit (username,recipient,ip,data) VALUES (?,?,?,' . $self->{NOW} . ')' ); if ( !$sth->execute( $user, $recipient, $client ) ) { syslog( err => "Couldn't execute statement: " . $sth->errstr ); return $self->{ERROR_RESPONSE}; } # garbage collector if ( rand() > 0.9 ) { $sth = $dbh->prepare( 'DELETE FROM lemat_limit WHERE DATE_SUB(' . $self->{NOW} . ',INTERVAL 7 DAY)>data' ); if ( !$sth->execute() ) { syslog( err => "Couldn't execute statement: " . $sth->errstr ); return $self->{ERROR_RESPONSE}; } } return $self->check_limits( $attr, [ 'recipient', '1 HOUR', $RECIPIENT_HOUR_LIMIT ], [ 'ip', '1 HOUR', $IP_HOUR_LIMIT ], [ 'recipient', '1 DAY', $RECIPIENT_DAY_LIMIT ], [ 'ip', '1 DAY', $IP_DAY_LIMIT ], ); } sub run { my $self = shift; # # Syslogging options for verbose mode and for fatal errors. # NOTE: comment out the $SYSLOG_SOCKTYPE line if syslogging does not # work on your system. # # ---------------------------------------------------------- # initialization # ---------------------------------------------------------- # # Unbuffer standard output. # STDOUT->autoflush(1); # syslog so that people can actually see our messages. setlogsock( $self->{SYSLOG_SOCKTYPE} ); openlog( $self->{SYSLOG_IDENT}, $self->{SYSLOG_OPTIONS}, $self->{SYSLOG_FACILITY} ); my %attr; while () { chomp; if (/=/) { my ( $key, $value ) = split( /=/, $_, 2 ); $attr{$key} = $value; next; } elsif (length) { syslog( warning => sprintf( "warning: ignoring garbage: %.100s", $_ ) ); next; } if ( $self->{VERBOSE} ) { for ( sort keys %attr ) { syslog( debug => "Attribute: %s=%s", $_, $attr{$_} ); } } # if user is authenticated if ( $attr{'sasl_username'} ne '' ) { my ( $suser, $sdomain ) = split( /@/, $attr{'sender'}, 2 ); my ( $ruser, $rdomain ) = split( /@/, $attr{'recipient'}, 2 ); # if @localdomain -> @localdomain if ( $sdomain eq $rdomain ) { STDOUT->print( 'action=' . $self->{DEFAULT_RESPONSE} . "\n\n" ); } else { # if @localdomain -> @externaldomain my $action = $self->do_query( \%attr ); syslog( info => "%s: Policy action=%s", $attr{queue_id}, $action ); STDOUT->print("action=$action\n\n"); %attr = (); } } # mail from external else { STDOUT->print( 'action=' . $self->{DEFAULT_RESPONSE} . "\n\n" ); } } } 1;