#!/usr/local/bin/ruby
# $Id: pfgray,v 1.1.1.2 2005/07/10 09:51:28 tommy Exp $

require "mysql"
require "optconfig"
require "syslog"

class List
  def initialize(fname)
    @fname = fname
    read_file if fname
  end

  def parse_hostname(s)
    if s =~ /^\/(.*)\/$/ then
      re = /#{$1}/i
    elsif s == "*" then
      re = /.*/
    elsif s[0,1] == "." then
      re = /#{Regexp.escape(s)}$/i
    else
      re = /^#{Regexp.escape(s)}$/i
    end
    return re
  end

  def parse_mailaddr(s)
    if s =~ /^\/.*\/$/ then
      re = /#{s}/i
    elsif s == "*" then
      re = /.*/
    elsif s == "<>" then
      re = /^$/
    elsif s.include? "@" then
      re = /^#{Regexp.escape(s)}$/i
    elsif s[0,1] == "." then
      re = /#{Regexp.escape(s)}$/i
    else
      re = /@#{Regexp.escape(s)}$/i
    end
    return re
  end

  def read_file()
    File.open(@fname) do |f|
      st = f.stat
      @ino, @size, @mtime = st.ino, st.size, st.mtime
      @value = []
      f.each do |l|
        l.chomp!
        next if l[0,1] == "#"
        a, h, s, r = l.split(/\t+/, 4)
        if a.nil? or h.nil? or s.nil? or r.nil? then
          Syslog.warning("%s", "invalid line: #{@fname}: #{l}")
          next
        end
        a = parse_hostname(a)
        h = parse_hostname(h)
        s = parse_mailaddr(s)
        r = parse_mailaddr(r)
        @value << [a, h, s, r]
      end
    end
  end

  def check(addr, name, helo, sender, rcpt)
    st = File.stat(@fname)
    unless st.ino == @ino and st.size == @size and st.mtime == @mtime then
      Syslog.notice("%s", "#{@fname} updated: rereading")
      read_file
    end
    @value.each do |a,h,s,r|
      if (a =~ addr or a =~ name) and h =~ helo and s =~ sender and r =~ rcpt then
        return true
      end
    end
    return false
  end
end

def usage()
  puts <<EOS
Usage: pfgray [options]
 options:
  --accept-range=time:time
  --gray-limit=time
  --expire=time
  --white-list=filename
  --black-list=filename
  --defer-message=message
  --mysql-server=hostname
  --mysql-user=username
  --mysql-passwd=password
  --mysql-db=databasename
EOS
  exit 1
end

def parse_time(s)
  unless s =~ /^(\d+)([smhd]?)$/ then
    raise "parse_time: invalid format: #{s}"
  end
  d = $1.to_i
  u = $2
  sec = u=="s" ? d : u=="m" ? d*60 : u=="h" ? d*60*60 : u=="d" ? d*24*60*60 : d
  return sec
end

def q(s)
  @my.quote(s.to_s)
end

def judgment(caddr, cname, helo, sender, recipient)
  @my.query("delete from log where timestamp < now()-interval #{@expire} second")
  if caddr and cname and helo and sender and recipient then
    judge = judgment2(caddr, cname, helo, sender, recipient)
  else
    judge = "impossible"
  end
  @my.query("insert into log (client_address,client_name,helo_name,sender,recipient,judge,timestamp) values ('#{q caddr}','#{q cname}','#{q helo}','#{q sender}','#{q recipient}','#{q judge}',now())")
  return judge
end

def judgment2(caddr, cname, helo, sender, recipient)
  if @white_list and @white_list.check caddr, cname, helo, sender, recipient then
    return "white"
  end

  if @black_list and @black_list.check caddr, cname, helo, sender, recipient then
    return "black"
  end

  n = @my.query("select 1 from log where client_address='#{q caddr}' and judge='accept' and timestamp > now() - interval #{@limit} second limit 1").num_rows
  if n > 0 then
    return "gray"
  end

  q = <<EOS
select 1 from log where client_address='#{q caddr}'
 and helo_name='#{q helo}'
 and sender='#{q sender}'
 and recipient='#{q recipient}'
 and timestamp between now()-interval #{@max} second and now()-interval #{@min} second
 and judge="postpone"
 limit 1
EOS
  if @my.query(q).num_rows > 0 then
    return "accept"
  end

  return "postpone"
end

def main()
  hash = {}
  STDIN.each do |line|
    line.chomp!
    if not line.empty? then
      param, value = line.chomp.split(/=/, 2)
      hash[param] = value
      next
    end

    caddr, cname, helo, sender, recipient = hash.values_at("client_address", "client_name", "helo_name", "sender", "recipient")

    judge = judgment(caddr, cname, helo, sender, recipient)

    case judge
    when "white", "accept", "gray"
      action = "DUNNO"
    when "postpone"
      action = "defer_if_permit #{@defer_message}"
    when "black"
      action = "reject"
    when "impossible"
      action = "DUNNO"
    else
      Syslog.err("%s", "unknown judgment: #{judge}")
      raise "unknown judgment: #{judge}"
    end

    STDOUT.puts "action=#{action}\n\n"
    STDOUT.flush

    hash = {}
  end
end

if __FILE__ == $0 then
  Syslog.open(File.basename($0), nil, Syslog::LOG_MAIL)

  opt = OptConfig.new
  opt.options = {
    "accept-range" => [/^\d+[smhd]?:\d+[smhd]?$/, "30s:6h"],  # 30sec6hour
    "gray-limit"   => [/^\d+[smhd]?$/, "7d"],        # 7day
    "expire"       => [/^\d+[smhd]?$/, "31d"],       # 31day
    "white-list"   => true,
    "black-list"   => true,
    "defer-message"=> [true, "Please access later"],
    "mysql-server" => true,
    "mysql-user"   => true,
    "mysql-passwd" => true,
    "mysql-db"     => true,
  }
  file = File.dirname($0)+"/pfgray.conf"
  opt.file = file if File.exist? file
  begin
    n = opt.parse(ARGV)
    @min, @max = opt["accept-range"].split(/:/).map{|i|parse_time(i)}
    if @min > @max then @min, @max = @max, @min end
    @limit = parse_time(opt["gray-limit"])
    @expire = parse_time(opt["expire"])
    @defer_message = opt["defer-message"]
    @white_list = List.new opt["white-list"] if opt["white-list"]
    @black_list = List.new opt["black-list"] if opt["black-list"]
    @my = Mysql.new(opt["mysql-server"], opt["mysql-user"], opt["mysql-passwd"], opt["mysql-db"])
    main
  rescue => e
    Syslog.err("%s: %s", e.class, e.message)
    exit 1
  end
end
