#!/usr/bin/env perl

=head1 DESCRIPTION

    iptblocker - тулза для выполнения различных частоиспользуемых действий с файрволом

    [DEBUG=1] iptblocker [--dry-run] [-l|--lock-name <name>] [-c|--comment <comment>] [--force] <command> <command_args>

=head1 COMMANDS

=cut

use strict;
use warnings;
use Getopt::Long; #qw(GetOptionsFromArray :config require_order);
use Pid::File::Flock;
use File::Basename;
use POSIX qw(strftime);
use Data::Dumper;
use Yandex::Log;
use Try::Tiny;

my @SENSITIVE_PORTS = (22, 10022);
my $COMMON_ARGS = "--wait";
my $CHAIN_IN = "IPTBLOCKER-IN";
my $CHAIN_OUT = "IPTBLOCKER-OUT";
my $LOG_FILE_NAME = "commands.log";
my $REQUIRE_LOG = 0;

$Yandex::Log::LOG_ROOT = '/var/log/iptblocker';
my $log;

my %CMDS = (
    "reject-to" => {
        cmd => \&cmd_reject_to,
        require_log => 1
    },
    "clear-reject-to" => {cmd => \&cmd_clear_reject_to},
    "reject-from" => {
        cmd => \&cmd_reject_from,
        require_log => 1
    },
    "clear-reject-from" => {cmd => \&cmd_clear_reject_from},
    "reject-from-any" => {
        cmd => \&cmd_reject_from_any,
        require_log => 1
    },
    "clear-reject-from-any" => {cmd => \&cmd_clear_reject_from_any},
    "fence-host" => {
        cmd => \&cmd_fence_host,
        require_log => 1
    },
    "clear-fence-host" => {cmd => \&cmd_clear_fence_host},
    "check-and-reject" => {cmd => \&cmd_check_and_reject},
    "clear-all" => {cmd => \&cmd_clear_iptblocker},
    "dump" => {cmd => \&cmd_dump},
    "help" => {cmd => \&show_usage},
);

my %O = (
    dry_run => 0,
    lock_name => basename($0),
    force => 0,
    complete => 0,
    help => 0,
    iptables_bin => "/sbin/iptables",
    ip6tables_bin => "/sbin/ip6tables",
);
GetOptions(
    "h|help" => \$O{help},
    "n|dry-run" => \$O{dry_run},
    "l|lock-name=s" => \$O{lock_name},
    "force" => \$O{force},
    "complete" => \$O{complete},
    "iptables-bin=s" => \$O{iptables_bin},
    "ip6tables-bin=s" => \$O{ip6tables_bin},
    "c|comment=s" => \$O{comment}
) or die "Bad options, see $0 help\n";

if ($O{complete}) {
    complete();
    exit(0);
}

# можно переопределять для тестирования
my $IPTABLES_CMD = "$O{iptables_bin} $COMMON_ARGS";
my $IP6TABLES_CMD = "$O{ip6tables_bin} $COMMON_ARGS";

if ($O{comment}) {
    my $comment_arg = " -m comment --comment \"$O{comment}\"";
    $IPTABLES_CMD .= $comment_arg;
    $IP6TABLES_CMD .= $comment_arg;
}

my $command = shift @ARGV;
$command = "help" if ! $command || $O{help};
die "No such command $command, see $0 help\n" if ! $CMDS{$command};

if ($command ne "help" && !$O{dry_run} && $> != 0) {
    die "you must be root to use this tool";
}

log_print("iptblocker: start");
if (exists $CMDS{$command}{require_log} && $CMDS{$command}{require_log}) {
    $REQUIRE_LOG = 1;
    try {
        $log = Yandex::Log->new(log_file_name => $LOG_FILE_NAME);
        $log->out("iptblocker: start");
    } catch {
        undef $log;
        log_print("!!!! CAN'T WRITE LOG TO FILE $Yandex::Log::LOG_ROOT/$LOG_FILE_NAME, CHECK DIRECTORY EXISTENCE AND PERMISSIONS !!!");
    };
}

Pid::File::Flock->new(name => $O{lock_name}) if $command ne "help" && $command ne "dump";
log_print("start command: $command ".(join " ", @ARGV)."; ".($O{comment} ? "with comment: $O{comment}" : "no comment"));

$CMDS{$command}{cmd}->(@ARGV);

log_print("end command");
if ($REQUIRE_LOG) {
    log_print("current rules list:\n".cmd_dump(1));
}
log_print("iptblocker: end");


=head2 reject-to <host> <port>

    отклоняет весь исходящий tcp-трафик на заданные хост и порт с tcp-reset

    в качестве host может быть fqdn - но это плохая идея, если он
    недостаточно статичен (будут каждый раз блокироваться разные ip, какие
    из них потом очистит clear-reject-to - непонятно)

    можно в качестве хоста указать ::/0(ipv6) и 0.0.0.0/0(ipv4) для закрытия
    трафика на порт по всем адресам 

    портов может быть несколько (например: 53,1024:65535)

    список портов напрямую передается iptables, поэтому 80, 80,443, 443,80,
    80:443,8080 - это 4 разных правила, для каждого своя clear-команда

=cut

sub cmd_reject_to {
    my ($host, $ports) = @_;
    die "bad or empty host/port, see $0 help\n" unless $host && valid_multiport($ports);
    prepare_iptblocker();
    my @ret = iptables_insert_rule($CHAIN_OUT, "-d $host -m multiport -p tcp --dports $ports -j REJECT --reject-with tcp-reset");
    # можем добавить v4-only хост, и v6 правило вернет ошибку и наоборот - это норма
    die "can't reject_to $host:$ports, run with DEBUG=1\n" unless any_ok(@ret);
}


=head2 clear-reject-to <host> <port>

    удаляет правила, заданные через reject-to

=cut

sub cmd_clear_reject_to {
    my ($host, $ports) = @_;
    my @ret = iptables_delete_rule($CHAIN_OUT, "-d $host -m multiport -p tcp --dport $ports -j REJECT --reject-with tcp-reset");
    # отсутствующие v4/v6 правила к ошибке удаления не приводят, поэтому any_error
    die "can't clear reject_to $host:$ports, run with DEBUG=1\n" if any_error(@ret);
}


=head2 reject-from <host>

    отклоняет весь входящий tcp-трафик с заданного хоста с tcp-reset
    кроме входящего с этого хоста на ssh-порты

    если нужно закрыть порт от всех хостов - пользуйтесь reject-from-any
    (там сложнее заблокировать лишнего)

=cut

sub cmd_reject_from {
    my ($host) = @_;
    die "bad or empty host, see $0 help\n" unless $host && $host !~ m|/0|;
    prepare_iptblocker();
    # если дописать исключение SENSITIVE_PORTS из диапазонов - можно указывать в этой команде и порты
    my $ports = join ",", @SENSITIVE_PORTS;
    my @ret = iptables_insert_rule($CHAIN_IN, "-s $host -m multiport -p tcp ! --dports $ports -j REJECT --reject-with tcp-reset");
    # можем добавить v4-only хост, и v6 правило вернет ошибку и наоборот - это норма
    die "can't reject_from $host, run with DEBUG=1\n" unless any_ok(@ret);
}


=head2 clear-reject-from <host>

    удаляет правила, заданные через reject-from

=cut

sub cmd_clear_reject_from {
    my ($host) = @_;
    my $ports = join ",", @SENSITIVE_PORTS;
    my @ret = iptables_delete_rule($CHAIN_IN, "-s $host -m multiport -p tcp ! --dports $ports -j REJECT --reject-with tcp-reset");
    # отсутствующие v4/v6 правила к ошибке удаления не приводят, поэтому any_error
    die "can't clear reject_from $host, run with DEBUG=1\n" if any_error(@ret);
}


=head2 reject-from-any <port[,port]>

    отклоняет весь входящий tcp-трафик на заданные локальные порты с tcp-reset

    портов может быть несколько (например: 53,1024:65535)

    список портов напрямую передается iptables, поэтому 80, 80,443, 443,80,
    80:443,8080 - это 4 разных правила, для каждого своя clear-команда

=cut

sub cmd_reject_from_any {
    my $ports = shift @_;
    my %sensitive = map { $_ => 1 } @SENSITIVE_PORTS;
    die "bad or empty ports, see $0 help\n" unless valid_multiport($ports);
    if (scalar(grep { $sensitive{$_} } split /,/, $ports) && ! $O{force}) {
        die "sensitive ports (ssh, etc) are specified, run with --force\n";
    }

    prepare_iptblocker();
    my @ret = iptables_insert_rule($CHAIN_IN, "-m addrtype ! --src-type LOCAL -m multiport -p tcp --dports $ports -j REJECT --reject-with tcp-reset");
    # тут any - потому что хотим добавить и v4, и v6 правило
    die "can't reject_from_any $ports, run with DEBUG=1\n" if any_error(@ret);
}


=head2 clear-reject-from-any <port[,port]>

    удаляет правило, заданное через reject-from-any

=cut

sub cmd_clear_reject_from_any {
    my ($ports) = @_;
    my @ret = iptables_delete_rule($CHAIN_IN, "-m addrtype ! --src-type LOCAL -m multiport -p tcp --dports $ports -j REJECT --reject-with tcp-reset");
    # отсутствующие v4/v6 правила к ошибке удаления не приводят, поэтому any_error
    die "can't clear reject_from_any $ports, run with DEBUG=1\n" if any_error(@ret);
}


=head2 check-and-reject <host> <port> {want-open|want-close} [tries connect_timeout reconnect_timeout]

    извращенный способ помочь приложению не залипать долго и периодически на одном
    и том же tcp-коннекте (mongos 2.6)

    для проверки порт открывается, делаются tries connect() к host:port
    ошибкой считается любое неуспешное подключение, не только таймаут

    если хотим закрыть (want-close) и все tries провалились
        - закрываем
    если хотим открыть (want-open) и хотя бы один connect() затаймаутился
        - тоже закрываем, еще рано открывать
    иначе - порт остается открытым

    action - want-open|want-close
        какого действия хотим добиться в итоге - открыть или закрыть порт

    tries
        количество успешных/неуспешных подключений, чтобы закрыть/открыть порт (default: 10)

    connect_timeout
        максимальное время на попытку подключения (default: 1 sec)

    reconnect_timeout
        время между попытками подключения (default: 1 sec)

=cut

sub cmd_check_and_reject {
    my ($host, $port, $action, $tries, $connect_timeout, $reconnect_timeout) = @_;
    $tries //= 10;
    $connect_timeout //= 1;
    $reconnect_timeout //= 1;
    die "bad or empty host/port, see $0 help\n" unless $host && valid_port($port);
    die "bad action, see $0 help\n" unless $action =~ /^want-(open|close)$/;

    # check команды ругаются на отсутствующие v4/v6 - поэтому any_ok
    my $already_rejected = any_ok(iptables_check_rule($CHAIN_OUT, "-d $host -p tcp --dport $port -j REJECT --reject-with tcp-reset"));
    my $want_rejected = $action eq "want-close";
    if ($want_rejected == $already_rejected) {
        log_print("connections to $host:$port are already " . ($already_rejected ? "rejected" : "accepted"));
        return;
    }

    log_print("clear reject_to $host:$port and check if it can accept tcp connections ...");
    my $check_cmd = "nc.openbsd -v -w $connect_timeout $host $port";
    my @ret;
    cmd_clear_reject_to($host, $port);
    for (my $i = 0; $i < $tries; ++$i) {
        push @ret, run_cmd_safe($check_cmd);
        sleep $reconnect_timeout if $tries > 1;
    }

    if (($action eq "want-open" && any_error(@ret)) or ($action eq "want-close" && all_error(@ret))) {
        log_print("seems like $host:$port is still unreachable, reject connections ...");
        cmd_reject_to($host, $port);
        return;
    }
    log_print("ok, $host:$port reachable");
}


=head2 fence-host

    закрыть (fencing) хост (по-умолчанию - с DROP)
    может использоваться для имитации дц-учений для данного хоста

    разрешены входящие соединения по ssh, разрешен весь трафик внутри хоста
    остальной трафик дропаем

    icmp разрешены все, кроме ping (на всякий случай, чтобы не потерять коннект по ssh, ipv6 RA и вот это все)

=cut

sub cmd_fence_host {
    my $with_reject = "";
    #GetOptionsFromArray(\@_,
    #    "r|with-reject" => \$with_reject,
    #) or die "Bad options, see $0 help\n";

    my $ip4_jump = "DROP";
    my $ip6_jump = "DROP";
    # хотелось проверить, можно ли закрывать как hbf - можно, но пока лишнее усложнение
    #if ($with_reject) {
    #    $ip4_jump = "REJECT --reject-with icmp-host-prohibited";
    #    $ip6_jump = "REJECT --reject-with icmp6-adm-prohibited";
    #}

    prepare_iptblocker();
    ## удаляем старые правила при наличии
    #cmd_clear_fence_host();
    my $sensitive = join(",", @SENSITIVE_PORTS);

    # разрешаем ssh и внутрихостовые соединения
    my @ret = iptables_insert_rule($CHAIN_IN, "-m multiport -p tcp --dports $sensitive -j ACCEPT", 1);
    push @ret, iptables_insert_rule($CHAIN_OUT, "-m multiport -p tcp --sports $sensitive -j ACCEPT", 1);
    push @ret, iptables_insert_rule($CHAIN_IN, "-m addrtype --src-type LOCAL -j ACCEPT", 2);
    push @ret, iptables_insert_rule($CHAIN_OUT, "-m addrtype --dst-type LOCAL -j ACCEPT", 2);
    # any - нужно добавить и v4, и v6 разрешающие правила
    die "can't add accept rules, run with DEBUG=1\n" if any_error(@ret);

    # запрещаем пинги от нас в мир
    # пинги к нам не дропаем, а отвечаем с host-prohibited, чтобы отличать от настоящих выключенных хостов
    push @ret, iptables_insert_rule4($CHAIN_IN, "-p icmp --icmp-type echo-request -j REJECT --reject-with icmp-host-prohibited", 3);
    push @ret, iptables_insert_rule4($CHAIN_OUT, "-p icmp --icmp-type echo-request -j $ip4_jump", 3);
    push @ret, iptables_insert_rule6($CHAIN_IN, "-p icmpv6 --icmpv6-type echo-request -j REJECT --reject-with icmp6-adm-prohibited", 3);
    push @ret, iptables_insert_rule6($CHAIN_OUT, "-p icmpv6 --icmpv6-type echo-request -j $ip6_jump", 3);

    # разрешаем все остальные icmp, чтобы не убить сеть
    push @ret, iptables_insert_rule4($CHAIN_IN, "-p icmp -j ACCEPT", 4);
    push @ret, iptables_insert_rule4($CHAIN_OUT, "-p icmp -j ACCEPT", 4);
    push @ret, iptables_insert_rule6($CHAIN_IN, "-p icmpv6 -j ACCEPT", 4);
    push @ret, iptables_insert_rule6($CHAIN_OUT, "-p icmpv6 -j ACCEPT", 4);

    # запрещаем все остальное
    push @ret, iptables_insert_rule4($CHAIN_IN, "-j $ip4_jump", 5);
    push @ret, iptables_insert_rule4($CHAIN_OUT, "-j $ip4_jump", 5);
    push @ret, iptables_insert_rule6($CHAIN_IN, "-j $ip6_jump", 5);
    push @ret, iptables_insert_rule6($CHAIN_OUT, "-j $ip6_jump", 5);
    # и тут any - блокируем и v4, и v6
    die "can't block all connections, run with DEBUG=1\n" if any_error(@ret);
}


=head2 clear-fence-host

    выключить имитацию упавшего дц

    удаляет сразу все возможные правила, генерируемые fence-host

=cut

sub cmd_clear_fence_host {
    my $sensitive = join(",", @SENSITIVE_PORTS);

    my @ret;
    # убираем дропы от нас и к нам
    push @ret, iptables_delete_rule($CHAIN_IN, "-j DROP");
    push @ret, iptables_delete_rule($CHAIN_OUT, "-j DROP");

    # остатки от --with-reject
    #push @ret, iptables_delete_rule4($CHAIN_IN, "-j REJECT --reject-with icmp-host-prohibited");
    #push @ret, iptables_delete_rule4($CHAIN_OUT, "-j REJECT --reject-with icmp-host-prohibited");
    #push @ret, iptables_delete_rule6($CHAIN_IN, "-j REJECT --reject-with icmp6-adm-prohibited");
    #push @ret, iptables_delete_rule6($CHAIN_OUT, "-j REJECT --reject-with icmp6-adm-prohibited");

    # убираем дропы ping'ов
    push @ret, iptables_delete_rule4($CHAIN_IN, "-p icmp --icmp-type echo-request -j REJECT --reject-with icmp-host-prohibited");
    push @ret, iptables_delete_rule4($CHAIN_OUT, "-p icmp --icmp-type echo-request -j DROP");
    push @ret, iptables_delete_rule6($CHAIN_IN, "-p icmpv6 --icmpv6-type echo-request -j REJECT --reject-with icmp6-adm-prohibited");
    push @ret, iptables_delete_rule6($CHAIN_OUT, "-p icmpv6 --icmpv6-type echo-request -j DROP");

    #push @ret, iptables_delete_rule4($CHAIN_IN, "-p icmp --icmp-type echo-request -j REJECT --reject-with icmp-host-prohibited");
    #push @ret, iptables_delete_rule4($CHAIN_OUT, "-p icmp --icmp-type echo-request -j REJECT --reject-with icmp-host-prohibited");
    #push @ret, iptables_delete_rule6($CHAIN_IN, "-p icmpv6 --icmpv6-type echo-request -j REJECT --reject-with icmp6-adm-prohibited");
    #push @ret, iptables_delete_rule6($CHAIN_OUT, "-p icmpv6 --icmpv6-type echo-request -j REJECT --reject-with icmp6-adm-prohibited");

    # убираем разрешения icmp
    push @ret, iptables_delete_rule4($CHAIN_IN, "-p icmp -j ACCEPT");
    push @ret, iptables_delete_rule4($CHAIN_OUT, "-p icmp -j ACCEPT");
    push @ret, iptables_delete_rule6($CHAIN_IN, "-p icmpv6 -j ACCEPT");
    push @ret, iptables_delete_rule6($CHAIN_OUT, "-p icmpv6 -j ACCEPT");

    # удаляем accept-правила, в последнюю очередь
    push @ret, iptables_delete_rule($CHAIN_IN, "-m addrtype --src-type LOCAL -j ACCEPT");
    push @ret, iptables_delete_rule($CHAIN_OUT, "-m addrtype --dst-type LOCAL -j ACCEPT");
    push @ret, iptables_delete_rule($CHAIN_IN, "-m multiport -p tcp --dports $sensitive -j ACCEPT");
    push @ret, iptables_delete_rule($CHAIN_OUT, "-m multiport -p tcp --sports $sensitive -j ACCEPT");
    # any - отсутствующие правила (drop/reject-with) не приводят к ошибке, все должно быть ок
    die "can't clear fence_host rules, run with DEBUG=1\n" if any_error(@ret);
}


=head2 clear-all

    очистить вообще все правила

=cut

sub cmd_clear_iptblocker {
    my @ret = iptables_delete_rule("INPUT", "-j $CHAIN_IN");
    push @ret, iptables_delete_rule("OUTPUT", "-j $CHAIN_OUT");
    map { push @ret, iptables_delete_chain($_) } ($CHAIN_IN, $CHAIN_OUT);
    die "can't clear iptblocker chains, run with DEBUG=1\n" if any_error(@ret);
}


=head2 dump

    показать все правила

=cut

sub cmd_dump {
    my $internal = shift;

    my $dump_result = "";
    $dump_result .= "ipv4:\n";
    $dump_result .= qx(iptables -S $CHAIN_IN 2>/dev/null | tail -n+2 | head -n-1);
    $dump_result .= qx(iptables -S $CHAIN_OUT 2>/dev/null | tail -n+2 | head -n-1);
    $dump_result .= "ipv6:\n";
    $dump_result .= qx(ip6tables -S $CHAIN_IN 2>/dev/null | tail -n+2 | head -n-1);
    $dump_result .= qx(ip6tables -S $CHAIN_OUT 2>/dev/null | tail -n+2 | head -n-1);

    if (!$internal) {
        print $dump_result;
    }
    return $dump_result;
}


sub prepare_iptblocker {
    my @ret;
    for my $chain ($CHAIN_IN, $CHAIN_OUT) {
        push @ret, iptables_create_chain($chain);
        push @ret, iptables_append_rule($chain, "-j RETURN");
    }
    push @ret, iptables_insert_rule("INPUT", "-j $CHAIN_IN");
    push @ret, iptables_insert_rule("OUTPUT", "-j $CHAIN_OUT");
    die "can't prepare iptblocker chains and rules, run with DEBUG=1\n" if any_error(@ret);
}

sub show_usage {
    system("podselect -section DESCRIPTION -section COMMANDS $0 | pod2text-utf8");
    exit 0;
}

sub iptables_create_chain {
    my ($chain) = @_;
    my ($ip4_ret, $ip6_ret) = (1, 1);
    $ip4_ret = run_cmd_safe("$IPTABLES_CMD -N $chain") if ! chain_exists($IPTABLES_CMD, $chain);
    $ip4_ret = run_cmd_safe("$IP6TABLES_CMD -N $chain") if ! chain_exists($IP6TABLES_CMD, $chain);
    return ($ip4_ret, $ip6_ret);
}

sub iptables_delete_chain {
    my ($chain) = @_;
    my ($ip4_ret, $ip6_ret) = (1, 1);
    if (chain_exists($IPTABLES_CMD, $chain)) {
        $ip4_ret = run_cmd_safe("$IPTABLES_CMD -F $chain") && run_cmd_safe("$IPTABLES_CMD -X $chain");
    }
    if (chain_exists($IP6TABLES_CMD, $chain)) {
        $ip6_ret = run_cmd_safe("$IP6TABLES_CMD -F $chain") && run_cmd_safe("$IP6TABLES_CMD -X $chain");
    }
    return ($ip4_ret, $ip6_ret);
}

sub iptables_insert_rule {
    return (iptables_insert_rule4(@_), iptables_insert_rule6(@_));
}

sub iptables_insert_rule4 {
    my ($chain, $rule, $rulenum) = @_;
    $rulenum //= 1;
    my $ip4_ret = 1;
    $ip4_ret = run_cmd_safe("$IPTABLES_CMD -I $chain $rulenum $rule") if ! iptables_check_rule4($chain, $rule);
    return $ip4_ret;
}

sub iptables_insert_rule6 {
    my ($chain, $rule, $rulenum) = @_;
    $rulenum //= 1;
    my $ip6_ret = 1;
    $ip6_ret = run_cmd_safe("$IP6TABLES_CMD -I $chain $rulenum $rule") if ! iptables_check_rule6($chain, $rule);
    return $ip6_ret;
}

sub iptables_append_rule {
    my ($chain, $rule) = @_;
    my ($ip4_ret, $ip6_ret) = (1, 1);
    $ip4_ret = run_cmd_safe("$IPTABLES_CMD -A $chain $rule") if ! iptables_check_rule4($chain, $rule);
    $ip6_ret = run_cmd_safe("$IP6TABLES_CMD -A $chain $rule") if ! iptables_check_rule6($chain, $rule);
    return ($ip4_ret, $ip6_ret);
}

sub iptables_delete_rule {
    return (iptables_delete_rule4(@_), iptables_delete_rule6(@_));
}
sub iptables_delete_rule4 {
    my ($chain, $rule) = @_;
    my $ip4_ret = 1;
    $ip4_ret = run_cmd_safe("$IPTABLES_CMD -D $chain $rule") if iptables_check_rule4($chain, $rule);
    return $ip4_ret;
}
sub iptables_delete_rule6 {
    my ($chain, $rule) = @_;
    my $ip6_ret = 1;
    $ip6_ret = run_cmd_safe("$IP6TABLES_CMD -D $chain $rule") if iptables_check_rule6($chain, $rule);
    return $ip6_ret;
}

sub iptables_check_rule {
    return (iptables_check_rule4(@_), iptables_check_rule6(@_));
}
sub iptables_check_rule4 {
    my ($chain, $rule) = @_;
    return run_cmd("$IPTABLES_CMD -C $chain $rule");
}
sub iptables_check_rule6 {
    my ($chain, $rule) = @_;
    return run_cmd("$IP6TABLES_CMD -C $chain $rule");
}

sub run_cmd {
    my $exit_code;
    if (! $ENV{DEBUG}) {
        system(join(" ", @_) . " 2>/dev/null");
        $exit_code = $? >> 8;
    } else {
        warn("debug: executing " . join(" ", @_) . "\n");
        system(@_);
        $exit_code = $? >> 8;
    }
    warn("exited with $exit_code\n") if $ENV{DEBUG};
    return $exit_code == 0;
}

sub run_cmd_safe {
    my $exit_code;
    if (! $O{dry_run} && ! $ENV{DEBUG}) {
        system(join(" ", @_) . " 2>/dev/null");
        $exit_code = $? >> 8;
    } elsif (! $O{dry_run} && $ENV{DEBUG}) {
        warn("debug: executing " . join(" ", @_) . "\n");
        system(@_);
        $exit_code = $? >> 8;
    } else {
        warn("dry-run: executing " . join(" ", @_) . "\n");
    }

    warn("exited with $exit_code\n") if $ENV{DEBUG} && !$O{dry_run};
    return $O{dry_run} || ($exit_code == 0);
}

sub chain_exists {
    my ($iptables_cmd, $chain) = @_;
    my @iptables_dump = split /\n/, qx($iptables_cmd -S);
    return scalar(grep { $_ eq "-N $chain" } @iptables_dump);
}

sub valid_multiport {
    my ($port) = @_;
    return defined($port) && $port =~ /^[0-9,:]+$/;
}

sub valid_port {
    my ($port) = @_;
    return defined($port) && $port =~ /^[0-9]+$/;
}

sub log_print {
    if ($O{dry_run}) {
        return;
    }
    my $date = strftime "[%Y-%M-%d %H:%M:%S]", localtime;
    my $log_str = join " ", @_;
    print "$date $log_str"."\n";
    if (defined $log) {
        $log->out($log_str);
    }
}

sub all_ok {
    return scalar(grep { $_ } @_) == scalar(@_);
}

sub any_error {
    return ! all_ok(@_);
}

sub all_error {
    return scalar(grep { ! $_ } @_) == scalar(@_);
}

sub any_ok {
    return ! all_error(@_);
}

# from yandex-du-lm
# более-менее универсальная функция, претендует на Yandex::...
sub complete {
    my $line = $ENV{COMP_LINE} || '';
    my @params = split /\s+/, $line;
    my $is_new_param = $line =~ /\s$/;
    # удаляем команду
    shift @params;
    # находим начало подсказки
    my $startwith = !$is_new_param ? pop(@params) : '';
    $startwith = '' if !defined $startwith;
    # удаляем опции если додсказки зависят от позиции
    @params = grep {!/^-/} @params;

    my @words = _get_complete_words($startwith, \@params);

    print join("", map {"$_\n"} sort grep {/^\Q$startwith\E/} @words), "\n";
}

# скриптозависимая функция - выдаёт список слов
sub _get_complete_words {
    my ($startwith, $params) = @_;
    if (defined $startwith && $startwith =~ /^-/) {
        return qw/--dry-run --lock-name --force/;
    } else {
        return keys %CMDS;
    }
}
