package Net::OpenVPN::Manager::Plugin::Iptables;

use namespace::autoclean;
use Moose;
use AnyEvent::Util qw(fork_call);
use IPTables::ChainMgr;
use Net::IP qw(ip_is_ipv4 ip_is_ipv6 ip_splitprefix);
use Net::OpenVPN::Manager::Plugin;
use MooseX::Types::Moose qw(Str Int ArrayRef HashRef);
use MooseX::Types::Structured qw(Dict Optional);
use Digest::MD5 qw(md5_base64);
use JSON;

with 'Net::OpenVPN::Manager::Startable';
with 'Net::OpenVPN::Manager::Addressable';
with 'Net::OpenVPN::Manager::Disconnectable';
with 'Net::OpenVPN::Manager::Reloadable';

has 'chain_prefix' => (
    is => 'ro',
    isa => 'Str',
    default => "openvpn-users-",
);

has 'user_chain_prefix' => (
    is => 'ro',
    isa => 'Str',
    default => "user-",
);

has 'generate_profiles' => (
    is => 'ro',
    isa => 'Bool',
    default => 1,
);

has 'hash_chain_name' => (
    is => 'ro',
    isa => 'Bool',
    default => 0,
);

has 'final_drop' => (
    is => 'ro',
    isa => 'Bool',
    default => 1,
);

has 'profile_attrs' => (
    is => 'ro',
    isa => 'ArrayRef[Str]',
    traits => ['Array'],
    default => sub { [] },
    handles => {
        all_profile_attrs => 'elements',
    },
);

has 'default_profiles' => (
    is => 'ro',
    isa => 'ArrayRef[Str]',
    traits => ['Array'],
    default => sub { [] },
    handles => {
        all_default_profiles => 'elements',
    },
);

has 'profiles' => (
    is => 'ro',
    traits => ['Hash'],
    isa => HashRef[
        ArrayRef|Dict["desc" => Optional[Str], "prio" => Optional[Int], "entries" => ArrayRef, "hidden" => Optional[Int]]],
    default => sub { {} },
    handles => {
        get_profile => 'get',
        profile_names => 'keys',
        has_profile => 'exists',
    }
);


sub _chain_name {
    my ($self, $name) = @_;
    return $self->hash_chain_name || length($name) > 28 ? md5_base64($name) : $name;
}

sub _main_chain_name {
    my ($self, $side) = @_;

    my $name = $self->chain_prefix.$side;
    return $self->_chain_name($name);
}

sub _profile_chain_name {
    my ($self, $profile, $side) = @_;

    my $name = $profile.'-'.$side;
    return $self->_chain_name($name);
}

sub _user_chain_name {
    my ($self, $client, $side) = @_;

    my $name = $self->user_chain_prefix.$client->username.$client->cid.$side;
    return $self->_chain_name($name);
}

sub _create_or_flush {
    my ($self, $name, $ipt_obj) = @_;

    my ($rv) = $ipt_obj->chain_exists('filter', $name);
    if ($rv) {
        $ipt_obj->flush_chain('filter', $name);
    } else {
        my ($rv) = $ipt_obj->create_chain('filter', $name);
    }
}

sub _create_profiles {
    my ($self, $ipt4_obj, $ipt6_obj) = @_;

    foreach my $name ($self->profile_names) {
        my $profile = $self->get_profile($name);
        my $inchain = $self->_profile_chain_name($name, 'in');
        my $outchain = $self->_profile_chain_name($name, 'out');

        $self->_create_or_flush($inchain, $ipt4_obj);
        $self->_create_or_flush($outchain, $ipt4_obj);
        $self->_create_or_flush($inchain, $ipt6_obj);
        $self->_create_or_flush($outchain, $ipt6_obj);

        foreach my $entry (@{$profile->{entries}}) {
            unless (defined $entry->{dst} || defined $entry->{src}) {
                $self->log("Rules must have src or dst defined");
                next;
            }
            if (defined $entry->{dst} && defined $entry->{src}) {
                $self->log("Rules cannot have src and dst defined");
                next;
            }

            my ($ip) = split(/\//, $entry->{dst} || $entry->{src});
            my $default;
            my $ipt_obj;

            if (ip_is_ipv6($ip)) {
                $default = "::/0";
                $ipt_obj = $ipt6_obj;
            } elsif (ip_is_ipv4($ip)) {
                $default = "0.0.0.0/0";
                $ipt_obj = $ipt4_obj;
            } else {
                $self->log("Rule does not contain valid ipv4 nor ipv6 address : $ip");
                next;
            }

            $ipt_obj->add_ip_rule(
                defined $entry->{src} ? $entry->{src} : $default,
                defined $entry->{dst} ? $entry->{dst} : $default,
                -1,
                'filter',
                defined $entry->{src} ? $outchain : $inchain,
                $entry->{target} || 'ACCEPT',
                $entry->{extras} || {}
            );
        }
    }
}

sub _get_user_profiles {
    my ($self, $client) = @_;
    my @profiles;

    # defaults
    foreach my $profile ($self->all_default_profiles) {
        push(@profiles, $profile) if $self->has_profile($profile);
    }

    # user profiles
    foreach my $attr ($self->all_profile_attrs) {
        foreach my $profile (@{$client->get_attr($attr)}) {
            push(@profiles, $profile) if $self->has_profile($profile);
        }
    }

    return sort { ($self->get_profile($a)->{prio} || 100) <=> ($self->get_profile($b)->{prio} || 100) } @profiles;
}

sub start {
    my ($self) = @_;

    my $cv = AE::cv;

    $self->manager->add_dispatcher(sub {
        my $env = shift;

        '/api/iptables/...' => sub {
            '' => sub {
                $self->manager->middleware_apikey;
            },
            'GET + /profiles + ?hidden~' => sub {
                my $hidden = $_[1];
		my @profiles = grep { !$self->get_profile($_)->{hidden} || $hidden  } $self->profile_names;
                [200, ['Content-type' => 'application/json'], [encode_json({ profiles => [@profiles]})]]
            },
            'GET + /profile/*' => sub {
                my $name = $_[1];
                unless ($self->has_profile($name)) {
                    [404, ['Content-type' => 'application/json'], [encode_json({ error => "profile does not exist" })]]
                } else {
                    [200, ['Content-type' => 'application/json'], [encode_json({ name => $name, content => $self->get_profile($name) })]]
                }
            },
        },
    });

    fork_call {
        my $rv;
        my $ipt4_obj = IPTables::ChainMgr->new();
        my $ipt6_obj = IPTables::ChainMgr->new('use_ipv6' => 1);

        # create main chains and jumps
        foreach (('in', 'out')) {
            my $chain = $self->_main_chain_name($_);

            # v4
            ($rv) = $ipt4_obj->chain_exists('filter', $chain);
            unless ($rv) {
                ($rv) = $ipt4_obj->create_chain('filter', $chain);
                $ipt4_obj->add_ip_rule('0.0.0.0/0', '0.0.0.0/0', -1, 'filter', 'FORWARD', $chain, { "intf_$_" => $self->manager->netdev });
                if ($self->final_drop) {
                    $ipt4_obj->add_ip_rule('0.0.0.0/0', '0.0.0.0/0', -1, 'filter', 'FORWARD', 'REJECT', { "intf_$_" => $self->manager->netdev });
                }
            }

            # v6
            ($rv) = $ipt6_obj->chain_exists('filter', $chain);
            unless ($rv) {
                ($rv) = $ipt6_obj->create_chain('filter', $chain);
                $ipt6_obj->add_ip_rule('::/0', '::/0', -1, 'filter', 'FORWARD', $chain, { "intf_$_" => $self->manager->netdev });
                if ($self->final_drop) {
                    $ipt6_obj->add_ip_rule('::/0', '::/0', -1, 'filter', 'FORWARD', 'REJECT', { "intf_$_" => $self->manager->netdev });
                }
            }
        }

        $self->_create_profiles($ipt4_obj, $ipt6_obj) if $self->generate_profiles;

        return $rv;
    } sub {
        if ($@) {
            $self->log($@);
            $cv->send(PLUG_ERROR);
            return;
        }
        $cv->send(PLUG_OK);
    };

    return $cv;
}

sub address {
    my ($self, $client, $addr, $prio) = @_;

    my $cv = AE::cv;
    my $ipt_obj = IPTables::ChainMgr->new('use_ipv6' => ip_is_ipv6($addr));
    my $default = ip_is_ipv6($addr) ? '::/0' : '0.0.0.0/0';
    my @profiles = $self->_get_user_profiles($client);
    my $inmainchain = $self->_main_chain_name('in');
    my $outmainchain = $self->_main_chain_name('out');
    my $inchain = $self->_user_chain_name($client, 'in');
    my $outchain = $self->_user_chain_name($client, 'out');

    $self->log("profiles ".join(",", @profiles), $client);

    fork_call {
        $self->_create_or_flush($inchain, $ipt_obj);
        $self->_create_or_flush($outchain, $ipt_obj);

        $ipt_obj->add_ip_rule($addr, $default, -1, 'filter', $inmainchain, $inchain);
        $ipt_obj->add_ip_rule($default, $addr, -1, 'filter', $outmainchain, $outchain);

        foreach my $profile (reverse @profiles) {
            my $inprofilechain = $self->_profile_chain_name($profile, 'in');
            my $outprofilechain = $self->_profile_chain_name($profile, 'out');
            $ipt_obj->add_jump_rule('filter', $inchain, 1, $inprofilechain);
            $ipt_obj->add_jump_rule('filter', $outchain, 1, $outprofilechain);
        }

        if ($self->final_drop) {
            $ipt_obj->add_ip_rule($default, $default, -1, 'filter', $inchain, 'REJECT');
            $ipt_obj->add_ip_rule($default, $default, -1, 'filter', $outchain, 'REJECT');
        }
    } sub {
        if ($@) {
            $self->log($@);
            $cv->send(PLUG_ERROR);
            return;
        }
        $cv->send(PLUG_OK);
    };

    return $cv;
}

sub disconnect {
    my ($self, $client) = @_;
    my $cv = AE::cv;
    my $inmainchain = $self->_main_chain_name('in');
    my $outmainchain = $self->_main_chain_name('out');
    my $inchain = $self->_user_chain_name($client, 'in');
    my $outchain = $self->_user_chain_name($client, 'out');

    $self->log("clearing rules and chains", $client);

    fork_call {
        foreach my $addr ($client->all_addresses) {
            my $ipt_obj = IPTables::ChainMgr->new('use_ipv6' => ip_is_ipv6($addr));
            my $default = ip_is_ipv6($addr) ? '::/0' : '0.0.0.0/0';

            $ipt_obj->delete_ip_rule($addr, $default, 'filter', $inmainchain, $inchain);
            $ipt_obj->delete_ip_rule($default, $addr, 'filter', $outmainchain, $outchain);
            $ipt_obj->delete_chain('filter', $inmainchain, $inchain);
            $ipt_obj->delete_chain('filter', $outmainchain, $outchain);
        }
    } sub {
        if ($@) {
            $self->log($@);
            $cv->send(PLUG_ERROR);
            return;
        }
        $cv->send(PLUG_OK);
    };

    return $cv;
}

sub reload {
    my ($self) = @_;
    my $cv = AE::cv;
    my $ipt4_obj = IPTables::ChainMgr->new();
    my $ipt6_obj = IPTables::ChainMgr->new('use_ipv6' => 1);
    $self->log("Regenerate profile chains");

    fork_call {
        $self->_create_profiles($ipt4_obj, $ipt6_obj);
        $self->log("Profile chains regenerated");
    } sub {
        if ($@) {
            $self->log($@);
            $cv->send(PLUG_ERROR);
            return;
        }
        $cv->send(PLUG_OK);
    };

    return $cv;
}

__PACKAGE__->meta->make_immutable;

1;

