#!/usr/bin/perl
use strict;
use warnings;
use feature 'state';

=head1 NAME

portblocker

=head1 SYNOPSIS

    ## без параметров: посмотреть статус
    $ portblocker
    rbac2: open (ports: 3314)
    rbac2-galera: restricted (ports: 17401, 17501, 17601)

    W: must run as root to check if the rules are enforced in iptables config

    ## ограничить rbac2
    $ sudo portblocker rbac2 {restrict|close|-write}
    RESTRICTED rbac2 (ports: 3314)

    ## открыть rbac2
    $ sudo portblocker rbac2 {open|+write}
    OPENED rbac2 (ports: 3314)

    ## выдать статус в формате JSON -- полезно, если хочется встроить в какую-то автоматизацию
    $ sudo portblocker --json
    {"instances":[{"name":"rbac2","status":"open","ports":[3314]},{"name":"rbac2-galera","status":"restricted","ports":[17401,17501,17601]}],"enforced":"yes|no|unknown"}

=head1 DESCRIPTION

portblocker - обёртка над iptables, чтобы ограничивать доступ к определённым сервисам.

Слово "portblocker" двусмысленное, относится и к системе в целом, и к этому скрипту.

=head2 СИСТЕМА

На машине есть сервисы. Например, mysql.rbac2 или redis-sentinel.

Сервис слушает на каком-то порте или портах. Доступом к нескольким из этих портов хочется управлять,
чтобы до туда могли достучаться только клиенты с определённых других машин.

"Инстанс" для portblocker -- один или несколько портов, доступом к которым хочется управлять одновременно.
Инстансы описываются файлами в /etc/portblocker/.

Важно: один порт может принадлежать только одному инстансу, иначе "всё сломается": portblocker
и portblocker-enforce будут падать при чтении конфигов. Как понять, что это случилось: запустить
portblocker, он упадёт с ошибками про multiple conffiles и collision.

Для каждого порта в инстансе есть список "особенных" хостов, с которых доступ к нему разрешён.
Хосты в списке могут быть определены IP-адресами, хостнеймами или группами в кондукторе.

Инстанс может быть в двух состояниях:
Ограничен (restricted): доступ к портам есть только с "особенных" хостов
Открыт (open): доступ к портам есть со всех хостов, ограничения не действуют

Какие из инстансов в каком состоянии, хранится в файле /var/spool/portblocker/enabled-conffiles.yaml.
Если в /etc/portblocker/ появляется файл про новый инстанс, статус по умолчанию для него
назначается "ограничен" (restricted).

Возможные действия над инстансами:
1. посмотреть список с состояниями
2. открыть инстанс
3. ограничить инстанс

=head2 СКРИПТЫ

В пакете два скрипта:

portblocker - пользовательский интерфейс -- в норме надо запускать только его.

portblocker-enforce - внутренний, обычно его запускать не надо, он сам запускается из cron +
portblocker его сразу запускает при изменениях. Скрипт делает так, чтобы правила в iptables
соответствовали конфигам и состояниям открытости/ограниченности инстансов.

=cut

use Getopt::Long qw( :config pass_through );
use JSON;
use Sys::Syslog qw( :standard :macros );

use Yandex::Shell;

use PortBlocker;

my %opts;
Getopt::Long::GetOptions(
    'json' => \$opts{json},
    'help|usage' => \&usage,
) or die "Invalid options";

my $running_as_root = ( $> == 0 );
my $enabled_conffiles_hash = get_enabled_conffiles_hash();

unless (@ARGV) {
    my $status = {
        instances => [],
        enforced  => 'unknown',
    };

    for my $conffile ( sort keys %$enabled_conffiles_hash ) {
        push @{ $status->{instances} }, {
            name   => conffile_to_instance($conffile) || '',
            status => ( $enabled_conffiles_hash->{$conffile} ? 'restricted' : 'open' ),
            ports  => get_conffile_ports($conffile) || [],
        };
    }

    if ($running_as_root) {
        my $cmd = 'portblocker-enforce --set --simulate';

        my $simulation_result = eval { yash_qx($cmd) };
        if ($@) {
            warn "Error running $cmd: $@";
        } else {
            $status->{enforced} = $simulation_result ne '' ? 'no' : 'yes';
        }
    }

    if ( $opts{json} ) {
        print to_json($status), "\n";
    } else {
        for my $instance ( @{ $status->{instances} } ) {
            my $ports_display = join( ', ', @{ $instance->{ports} } );
            print $instance->{name}, ': ', $instance->{status}, " (ports: $ports_display)\n";
        }

        print "\n";

        if ( $status->{enforced} && $status->{enforced} eq 'yes' ) {
            print "Rules are properly enforced in iptables\n";
        } elsif ( $status->{enforced} && $status->{enforced} eq 'no' ) {
            print "W: Rules are not properly enforced in iptables, run portblocker-enforce --set\n";
        } else {
            print "W: Cannot determine if rules are properly enforced in iptables (not running as root?)\n";
        }
    }

    exit 0;
}

my ( $instance, $action ) = @ARGV;
unless ( $instance && $action ) {
    print "Usage:\n";
    print "  $0     # no arguments, or:\n";
    print "  $0 <instance> <action>\n";
    print "Run $0 --help for more information\n";
    exit 1;
}

my %action_aliases = ( qr(^close) => 'restrict', qr(^\-write) => 'restrict', qr(^\+write) => 'open' );
map { ($action =~ /$_/) && do { $action = $action_aliases{$_} } } keys %action_aliases;
warn "started with $instance $action\n" if $ENV{DEBUG};

if ( $action eq 'restrict' || $action eq 'open' ) {
    unless ($running_as_root) {
        die "Must run as root to $action\n";
    }

    my $conffile = instance_to_conffile($instance);
    die "Unknown instance $instance\n" unless defined $enabled_conffiles_hash->{$conffile};

    openlog 'portblocker', 'ndelay,pid', LOG_USER;
    syslog LOG_NOTICE, "$action $conffile";

    $enabled_conffiles_hash->{$conffile} = $action eq 'restrict' ? 1 : 0;

    save_enabled_conffiles_hash($enabled_conffiles_hash);
    yash_system('portblocker-enforce --set');

    my $action_display = $action eq 'restrict' ? 'RESTRICTED' : 'OPENED';
    my $ports_display = join( ', ', @{ get_conffile_ports($conffile) || [] } );
    print "$action_display $instance (ports: $ports_display)\n";

    exit 0;
}

die "Invalid action $action\n";

sub usage {
    my $base_cmd = "podselect -section NAME -section SYNOPSIS -section DESCRIPTION $0 | pod2text-utf8";

    if ( my $pager = $ENV{PAGER} ) {
        system("$base_cmd | $pager");
    } else {
        system($base_cmd);
    }

    exit(0);
}

sub conffile_to_instance {
    my ($conffile) = @_;
    $conffile =~ s/\.conf$//g;
    return $conffile;
}

sub instance_to_conffile {
    my ($instance) = @_;
    unless ( $instance =~ /\.conf$/ ) {
        $instance .= '.conf';
    }
    return $instance;
}
