#!/usr/bin/perl
#
# realtime ddos detection and blocking
# petya@nigilist.ru
#
#

use strict;
use warnings;

# seconds
my $scan_time_period = 30;
my $block_url_speed_limit = int($scan_time_period * 0.825 + 0.5); # 25
my $att_url_speed_limit = int($scan_time_period * 0.4 + 0.5); # 12
my $attacked_url_cache_time = 1800;

my $block_ip_speed_limit = int($scan_time_period * 0.825 + 0.5);
my $att_ip_speed_limit = int($scan_time_period * 0.4 + 0.5);

my $ngx_banned = "/home/lj-admin/caching-nginx/conf/banned";

my @known_bad_urls = ();

# whitelist
my $whitelist_ips = {
  };
my $good_ips = {};
my $bad_ips = {};
my $banned = {};
my @banned_arr = ();
my $banned_urls = {};
my $banned_locations = {};
my $last_urls = {};
my $last_ips = {};
my $existing_chains = {};

sub gen_chain {
  my ($chain_name, $net_name, $target_chain) = @_;

  if (!$existing_chains->{$chain_name}) {
    my $create_chain = qx/iptables -n -L $chain_name 2>\&1 | grep -i 'no chain'/;

    my $mask;
    if ($net_name =~ /^([\d]+?)\.([\d]+?)\.([\d]+?)$/oi) {
      $mask = "24";
    }
    elsif ($net_name =~ /^([\d]+?)\.([\d]+?)$/oi) {
      $mask = "16";
    }
    elsif ($net_name =~ /^([\d]+?)$/oi) {
      $mask = "8";
    }

    if ($create_chain) {
      my $b = qx/iptables -N $chain_name/;
      system("iptables -I ${target_chain} 1 -s ${net_name}/${mask} -j $chain_name");
    }
    $existing_chains->{$chain_name} = 1;
  }
}

sub get_chain_name {
  my ($ip) = @_;

  my $oct1;
  my $oct2;
  my $oct3;
  if ($ip =~ /^([\d]+?)\.([\d]+?)\.([\d]+?)\./oi) {
    $oct1 = $1;
    $oct2 = $2;
    $oct3 = $3;
    my $c1 = "i_" . $oct1;
    my $c2 = $c1 . "_" . $oct2;
    my $c3 = $c2 . "_" . $oct3;
    if (!$existing_chains->{$c3}) {
      gen_chain($c1, $oct1, 'INPUT');
      gen_chain($c2, join(".", ($oct1, $oct2)), $c1);
      gen_chain($c3, join(".", ($oct1, $oct2, $oct3)), $c2);
    }
    return $c3;
  }
  else {
    return 'INPUT';
  }
}

sub nginx_location_prepare {
  my ($u) = @_;
  
  my $rx_ready = $u;
  $rx_ready =~ s/\?.+$//goi;
  $rx_ready =~ s/\#.+$//goi;
#  $rx_ready =~ s/([\.\?\=\[\]\+\-])/\\$1/goi;
  
  return $rx_ready;
}

sub nginx_reread_conf {
  system("kill -HUP `cat /home/lj-admin/caching-nginx/logs/nginx.pid`");
}

sub remove_attacked_url {
  my ($u, $now) = @_;
  
  my $tmp_filename = $ngx_banned . '.new';
  my $nginx_location = nginx_location_prepare($u);
  
  my $fh_prev;
  my $fh_new;
  open($fh_new, ">$tmp_filename");
  open($fh_prev, "cat $ngx_banned |");
  while (my $l = <$fh_prev>) {
    if ($l !~ /\=\s$nginx_location\s/) {
      print $fh_new $l;
    }
  }
  close($fh_prev);
  close($fh_new);
  unlink($ngx_banned);
  link($tmp_filename, $ngx_banned);
  unlink($tmp_filename);

  delete($banned_locations->{$nginx_location});
  nginx_reread_conf();
}

sub add_attacked_url {
  my ($u, $now) = @_;
  
  return unless $u;
  
  $banned_urls->{$u} = $now;
  
  my $nginx_location = nginx_location_prepare($u);
  next if $banned_locations->{$nginx_location};
  $banned_locations->{$nginx_location} = $now;
  
  my $fhn;
  open($fhn, ">>$ngx_banned");
  print $fhn 'location = ' . $nginx_location . ' { return 503; } ' . "\n";
  close($fhn);
  
  nginx_reread_conf();

  # remove those good ips which were counted as good
  # because of the url which become DDOSed now
  # (good people would be readded with
  # another url, zombies will be blocked)
  foreach my $ip (keys %{$good_ips}) {
    if ($good_ips->{$ip}->{'url'} eq $u) {
      delete($good_ips->{$ip});
    }
  }
}

sub do_log {
  my ($m) = @_;
  print $m . "\n";

  my $fh;
  open ($fh, ">>/var/log/ddos.log") || die "couldn't open /tmp/ddos.log\n";
  print $fh localtime() . ": " . $m . "\n";
  close($fh);
}

sub is_banned {
  my ($ip) = @_;
  my $chain_name = get_chain_name($ip);
  my $already_banned = qx/iptables -n -L $chain_name 2>\&1 | grep DROP | grep "$ip "/;
  return $already_banned;
}

sub unban {
  my ($ip, $opts) = @_;
  $opts = {} unless $opts;

  if ($banned->{$ip}) {
    my $chain_name = get_chain_name($ip);
    system ("iptables -D $chain_name -s $ip -j DROP 2>/dev/null");
  }
  
  delete ($banned->{$ip});
  delete ($bad_ips->{$ip});
}


sub ban {
  my ($ip) = @_;

  if (!is_banned($ip)) {
    my $chain_name = get_chain_name($ip);
    system("iptables -I $chain_name -s $ip -j DROP");
  }

  # remove ip from list which is accessing attacked pages
  # since it is diagnosed to be bad.
  delete($bad_ips->{$ip});

  if (!$banned->{$ip}) {
    $banned->{$ip} = time();
    push (@banned_arr, $ip);
  }
}

sub populate_from_iptables {
  my $current_chain;
  my $fh;
  open ($fh, "iptables -n -L |") || die "couldn't open the pipe with";
  while (my $l = <$fh>) {
    if ($l =~ /^Chain\s(.+?)\s/oi) {
      $current_chain = $1;
      $existing_chains->{$current_chain} = 1;
      next;
    }

    next unless $current_chain;
    if ($current_chain ne 'INPUT' && $current_chain !~ /^i_/oi) {
      next;
    }
    next unless $l =~ /DROP/oi;

    my $ip;
    if ($l =~ /^([^\s]+?)\s+([^\s]+?)\s+([^\s]+?)\s+([^\s]+?)\s+/oi) {
      $ip = $4;
    }
    else {
      next;
    }

    # remove network mask
    $ip =~ s/\/.+$//sgoi;
    
    # remove non-decimal characters
    $ip =~ s/[^0-9\.]//sgoi;
    
    # iptables had it in some chains; just in case
    next if $ip eq '0.0.0.0';

    if (!$banned->{$ip}) {
      $banned->{$ip} = time();
      push (@banned_arr, $ip);
    }

#    print $ip . "\n";
  }
  close ($fh);
}


# clear banned urls file (it will be calculated in runtime)
my $fhn;
open($fhn, ">$ngx_banned");
close($fhn);
nginx_reread_conf();

# mark start time
do_log("restarted");
foreach my $u (@known_bad_urls) {
  add_attacked_url($u, time());
}

populate_from_iptables();
do_log ("got " . scalar(@banned_arr) . " banned ips from iptables");

my $last_expire_whitelist = time();
my $last_recalc = time();
my $last_unbanned = time();

#my $access_log = "/home/lj-admin/cachingapache2/logs/access_log";
#my $access_log = "/home/lj-admin/caching-nginx/logs/access.log";
my $access_log = "/www_log/apache_logs/access_log /home/lj-admin/caching-nginx/logs/access.log /home/lj-admin/caching-nginx/logs/error.log";
#my $access_log = "/www_log/apache_logs/access_log";

my $current_second;
my $previous_second;

my $ln = 0;
my $fh;
open($fh, "tail -F $access_log |") || die "unable to open [$access_log]\n";
while (my $l = <$fh>) {
  $ln++;

  if (($ln % 10000) == 0) {
    do_log("processed line $ln");
  }

  my $now = time();

  # remove expired iptables DROP rules,
  # not faster than one rule in 10 seconds,
  # so it does not slow down when access logs
  # are filled at a high rate.
  #
  # the oldest (first in @banned_arr) rule
  # is removed if:
  #
  #   * total number of rules is greater
  #     than a constant (empirical for now,
  #     depends on hardware and attack speed)
  #
  #   * oldest rule is older than one day
  #
  if ($last_unbanned + 10 < $now &&
    (
      scalar (@banned_arr) > 10500 ||
      $banned->{$banned_arr[0]} + 86400 < $now
    )) {
    my $unban_ip = shift(@banned_arr);
    unban($unban_ip);
    do_log("unbanned: $unban_ip");
    $last_unbanned = $now;
  }

  # ban really fast attackers each second,
  # where fast is defined as more than N*4 accesses
  # where N is the number of different urls accessed
  # during the given second.
  #
  if ($current_second && $now > $current_second) {
    $previous_second = $current_second;
    $current_second = $now;

    foreach my $i (keys %{$last_ips->{$previous_second}}) {
      next if $banned->{$i};
      next if $whitelist_ips->{$i};

      # count total access during given second ($k)
      my $num_accesses_this_second = 0;
      foreach my $iu (keys %{$last_ips->{$previous_second }->{$i}}) {
        $num_accesses_this_second = $num_accesses_this_second + $last_ips->{$previous_second }->{$i}->{$iu};
      }

      # count total different urls during given second ($k)
      my $different_urls_for_ip = scalar(keys %{$last_ips->{$previous_second }->{$i}});
      if ($num_accesses_this_second > ($different_urls_for_ip * 4)) {
        do_log("banned: $i (second $previous_second: $different_urls_for_ip urls, $num_accesses_this_second accesses)");
        ban($i);
      }
    }
  }
  elsif (!$current_second) {
    $current_second = $now;
  }

  my $ip;
  my $datetime;
  my $req;
  my $url;
  my $http_ver;
  my $http_response_code;

  # 189.158.156.100 - - [14/Dec/2009:11:33:54 +0300] "GET / HTTP/1.1" 499 0 "-" "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US)" "-"
  if ($l =~ /^([\d]+?\.[\d]+?\.[\d]+?\.[\d]+?)\s.+?\s.+?\s\[(.+?)\]\s\"([^\s]+?)\s+?([^\s]+?)\s+?([^\s]+?)\"\s([0-9]+?)\s/o) {
    $ip = $1;
    $datetime = $2;
    $req = $3;
    $url = $4;
    $http_ver = $5;
    $http_response_code = $6;
  }
  
  # 203.90.16.94 - - [14/Dec/2009:11:32:45 +0300] "-" 400 0 "-" "-" "-"
  elsif ($l =~ /^([\d]+?\.[\d]+?\.[\d]+?\.[\d]+?)\s\-\s\-\s\[(.+?)\]\s\"\-\"\s([0-9]+?)\s/o) {
    $ip = $1;
    
    # pseudo url for "cached" nginx requests,
    # will not ban people but will trigger
    # attacked url mechanism
    $url = "UNDEF";
    
    $http_response_code = $3;
  }
  elsif ($l =~ /limiting requests/o) {
    if ($l =~ /([\d]+?\.[\d]+?\.[\d]+?\.[\d]+?)\,.+request: \"([^\s]+?)\s+?([^\s]+?)\s+?([^\s]+?)\"/o) {
      $ip = $1;
      $req = $2;
      $url = $3;
      $http_ver = $4;
      $http_response_code = 503;
#      print $ip . " : " . $url . "\n";
    }
  }

  if ($ip && $url && $http_response_code) {
#    print "$ip $url $http_response_code\n";
    
    # we might have already banned ip
    # but we might get logs which contain
    # accesses registered before ip was banned
    # (because of buffering and when
    # site load is high)
    next if $banned->{$ip};
    
    # count every url for previous time period
    $last_urls->{$now}->{$url} = 0 unless $last_urls->{$now}->{$url};
    $last_urls->{$now}->{$url} = $last_urls->{$now}->{$url} + 1;

    # count every ip and every url which ip has accessed
    # for previous time period
    $last_ips->{$now}->{$ip} = {} unless $last_ips->{$now}->{$ip};
    $last_ips->{$now}->{$ip}->{$url} = $last_ips->{$now}->{$ip}->{$url} = 0
      unless $last_ips->{$now}->{$ip}->{$url};
    $last_ips->{$now}->{$ip}->{$url} = $last_ips->{$now}->{$ip}->{$url} + 1;

    # do calculations every time period
    # (purge old urls and ips,
    # find attacked urls and attacking ips)
    if ($now - $last_recalc > $scan_time_period) {

      # turls contains number of seconds
      # during which there were accesses,
      # (for every url)
      my $turls = {};
      foreach my $k (sort keys %{$last_urls}) {
        if ($k > $now - $scan_time_period) {
          foreach my $u (keys %{$last_urls->{$k}}) {
            $turls->{$u} = 0 unless $turls->{$u};
#            $turls->{$u} = $turls->{$u} + $last_urls->{$k}->{$u}; # total accesses
            $turls->{$u} = $turls->{$u} + 1; # speed per second
          }
        }
        else {
          # purge old records
          delete($last_urls->{$k});
        }
      }

      # tips contains number of seconds
      # during which there were accesses
      # (for every ip)
      #
      # tips_bas contains total number of accesses
      # for every ip for previous time period
      my $tips = {};
      my $tips_abs = {};
      foreach my $k (sort keys %{$last_ips}) {
        if ($k > $now - $scan_time_period) {
          foreach my $i (keys %{$last_ips->{$k}}) {

            # for those which are banned below
            next if $banned->{$i};
            
            $tips->{$i} = 0 unless $tips->{$i};
            $tips_abs->{$i} = 0 unless $tips_abs->{$i};
            $tips->{$i} = $tips->{$i} + 1; # speed per second
            
            foreach my $iu (keys %{$last_ips->{$k}->{$i}}) {
              $tips_abs->{$i} = $tips_abs->{$i} + $last_ips->{$k}->{$i}->{$iu};
            }
          }
        }
        else {
          # purge records for older time periods
          delete($last_ips->{$k});
        }
      }

      # dump time period summary
      do_log( "recalc: " . ($now - $last_recalc) . " secs;" .
        " urls: " . scalar(keys %{$turls}) .
        " ips: " . scalar(keys %{$tips}));
      foreach my $u (keys %{$turls}) {
        if ($turls->{$u} > $block_url_speed_limit) {
          if (!$banned_urls->{$u}) {
            do_log("speedy url: $u ($turls->{$u})");
            add_attacked_url($u, $now);
          }
          $banned_urls->{$u} = $now;
        }
        else {
          # dump somewhat leaders (just to understand)
          if ($turls->{$u} > $att_url_speed_limit) {
            do_log("\t$u: " . $turls->{$u});
          }
        }
      }
      foreach my $i (keys %{$tips}) {
        next if $whitelist_ips->{$i};
        
        # ip $i had accessed us $tips->{$i} seconds
        # of 30 (scan_time_period) and asked totally ($tips_abs->{$i})
        # more than 2 urls pers access
        if ($tips->{$i} > $block_ip_speed_limit &&
          $tips_abs->{$i} > ($tips->{$i} * 2)) {
          
          my $name = qx/host $i/;
          $name =~ s/[\r\n]//goi;
          if (
            $name =~ /googlebot\.com\.$/oi ||
            $name =~ /mail\.ru\.$/oi ||
            $name =~ /yahoo\.com\.$/oi ||
            $name =~ /yandex\.ru\.$/oi
            ) {
            next;
          }
          
          if (!$banned->{$i}) {
            do_log("banned: $i, " . $tips->{$i} . " (" . $tips_abs->{$i} . ")");
            ban($i);
          }
        }
        else {
          if ($tips->{$i} > $att_ip_speed_limit) {
            do_log("\t$i: " . $tips->{$i} . " ($tips_abs->{$i})");
          }
        }
      }

      # expire attacked url if it was not detected
      # as attacked for some time
      foreach my $bu (keys %{$banned_urls}) {
        if ($now - $banned_urls->{$bu} > $attacked_url_cache_time) {
          delete ($banned_urls->{$bu});
          do_log("removed from speedy urls: $bu");
          remove_attacked_url($bu, $now);
        }
      }

      $last_recalc = $now;
    }

    # do not ban/unban while we do not have
    # any attacked urls (except for SPEEDY ips above)
    next if scalar(keys %{$banned_urls}) < 1;
    
    # expire old good ips (which were good
    # for more than 5 minutes) once a minute
    if ($now - $last_expire_whitelist > 60) {
      my $expired = 0;
      foreach my $good_ip (keys %{$good_ips}) {
        if ($now - $good_ips->{$good_ip}->{'when'} > 180) {
          delete($good_ips->{$good_ip});
          $expired = $expired + 1;
        }
      }
      if ($expired) {
        do_log("expired whitelisted ips: $expired");
      }
      $last_expire_whitelist = $now;
    }

    # do not check whitelisted ips
    # for accesses to attacked urls
    if ($good_ips->{$ip}) {
      next;
    }

    # remember for some time someone is good
    # if he accessed some non-ddos url
    if (!$banned_urls->{$url}) {
      # whitelist only 200 responses
      # (do not whitelist 503, etc.)
#      if ($http_response_code eq 200) {
        do_log("whitelist: $ip $url");
        $good_ips->{$ip}->{'when'} = time();
        $good_ips->{$ip}->{'url'} = $url;
        unban($ip);
        next;
#      }
    }

    # if ip is not already in iptables (banned) --
    # increment its count of accesses of attacked urls
    # (if count is more than 15 -- block it)
    if (!$banned->{$ip}) {
      $bad_ips->{$ip} = 0 unless $bad_ips->{$ip};
      $bad_ips->{$ip} = $bad_ips->{$ip} + 1;

      if ($bad_ips->{$ip} > 15) {
        ban($ip);
        do_log("banned: $ip");
      }
    }

  }
}
