#!/usr/bin/perl
#----------------------------------------------------------------------
# Synchronizes mysql grants from a schema template
#----------------------------------------------------------------------
use strict;
use warnings;
use English;
use DBI;
use Getopt::Long;
use Config::Tiny;
use YAML::XS;
use Readonly;

Readonly my $GRANTS_SCHEMA => '/etc/mysql/grants.yml';
Readonly my $DB_CFG => "/etc/default/ngcp-db";
Readonly my $DEFAULT_DBHOST => "127.0.0.1";
Readonly my $DEFAULT_DBPORT => "3306";
Readonly my $TEMP_GRANT_USER => 'ngcp-sync-db';
Readonly my $TEMP_GRANT_HOST => 'localhost';

my $grants = {};
my $dbh;
my $debug = 0;
my $log_offset = 0;
my $password_length = 20;

my $check_only = 0;
my $recreate_user = 0;

my $custom_db_host;
my $custom_db_port;

my $no_warnings = 0;

sub Usage {
    print <<USAGE;
==
    Synchronizes mysql grants from a schema template
==
$PROGRAM_NAME [options]
Options:
    --help|-h|-?     -- this help
    --verbose|-v     -- verbose mode
    --check|-c       -- check only
    --quiet|-q       -- quiet mode
    --recreate-user  -- recreate user
                        (useful when all user hosts need to be cleared)
    --db-host        -- use custom db host
    --db-port        -- use custom db port
    --no-warnings    -- suppress warning messages such as
                        "Cannot apply grant: Table 'x' doesn't exist

USAGE
    exit 0;
}

GetOptions("h|?|help"       => \&Usage,
           "v|verbose"      => \$debug,
           "c|check"        => \$check_only,
           "q|quiet"        => sub { $debug = -1 },
           "recreate-user"  => \$recreate_user,
           "db-host=s"      => \$custom_db_host,
           "db-port=s"      => \$custom_db_port,
           "no-warnings"    => \$no_warnings,
          ) or die Usage();

sub pwgen {
    my @list = ("a".."z",0..9,"A".."Z");
    my @randoms;
    for (1..$password_length) {
        push @randoms, $list[int(rand($#list))];
    }

    return join "", @randoms;
}

sub connect_db {
    my ($dbhost, $dbport) = @_;
    my $dbcredentials = "/etc/mysql/sipwise_extra.cnf";

    $dbh = DBI->connect("DBI:mysql:database=mysql;host=$dbhost;port=$dbport;"
                        . "mysql_read_default_file=${dbcredentials}",
                        "", "",
                        { PrintError => 0 })
        or die "Can't connect to MySQL database 'mysql': $DBI::errstr\n";
    log_debug("connected to $dbhost:$dbport using '${dbcredentials}'");
    $dbh->do("SET sql_log_bin=0")
        or die "Cannot set sql_log_bin=0: $DBI::errstr\n";

    return;
}

sub logger {
    my $str = shift || '';
    my $mode = shift || 0;
    return if $debug == -1;
    return if $mode == 1 && $debug <= 0;
    my $offset = $log_offset*2;
    $offset -= 2 if $debug < 1;
    $offset = 0 if $offset < 0;
    my $prefix = $mode < 2 ? "-->" : "Warning:";
    printf { $mode == 2 ? *STDERR : *STDOUT } "%s%s %s\n",
        $prefix, " "x$offset, $str;

    return;
}

sub log_info  { logger(shift, 0); }
sub log_debug { logger(shift, 1); }
sub log_warn  { $no_warnings || logger(shift, 2); }

sub get_hostname {

    my $hostname = qx(ngcp-hostname);
    chomp $hostname;

    return $hostname;
}

sub apply_grants {
    my ($ref, $ptr, $key, $idx, $data, $as) = @_;
    $idx ||= 0;
    $data ||= [];

    if ($ref =~ s/^(.+\..+\..+)\s+as\s+(\S+)\s*$/$1/) {
        $as = $2 || die "Missing as 'hostname'";
    }

    my $rc = 0;

    unless ($key) {
        my @path = split /\./, $ref;
        die "Malformed grants ref $ref" unless $#path == 2;
        die "Index $idx is out of allowed range" if $idx < 0 || $idx > 2;
        $key ||= $path[$idx];
    }

    $ptr ||= $grants;

    if ($key =~ s/\*/.+/g) {
        foreach my $v (sort { $a cmp $b } keys %$ptr) {
            if ($v =~ /^$key$/) {
                $rc += apply_grants($ref, $ptr, $v, $idx, [ @$data ] , $as);
            }
        }
    } else {
        unless (defined $ptr->{$key}) {
            unless ($as) {
                die sprintf "Undefined key %s in %s with idx=%d in ref %s",
                    $key, join('.', @$data), $idx, $ref
            } else {
                return 0;
            }
        }
        if (ref $ptr->{$key} eq 'HASH') {
            if ($idx == 1 && $recreate_user && !$as) { # local user
                if (!$check_only && apply_drop_users($key)) {
                    flush_privs();
                }
            }
            $rc += apply_grants($ref, $ptr->{$key}, undef, $idx+1,
                                [ @$data, $key ], $as);
        } elsif (ref $ptr->{$key} eq 'ARRAY') {
            push @$data, $key;
            my ($top, $user, $host) = @$data;
            $host = $as if $as;
            $log_offset = 1;
            my $new_user = 0;
            log_debug(sprintf "[%s]%s", join('.', @$data), $as ? " as $as" : '');
            normalise_grants($ptr->{$key}, $user, $host);
            return 0 unless check_grants($ptr->{$key}, $user, $host);
            log_info(sprintf "[%s]%s", join('.', @$data), $as ? " as $as" : '') unless $debug;
            $log_offset = 2;
            log_info(sprintf "revoke all from: %s\@%s", $user, $host);
            unless ($check_only) {
                $dbh->do("REVOKE ALL PRIVILEGES, GRANT OPTION FROM '$user'\@'$host'");
                if ($DBI::errstr) {
                    if ($DBI::errstr =~
                      /revoke all privileges for one or more of the requested users/) {
                        $new_user = 1;
                    } elsif ($DBI::errstr !~ /There is no such grant defined/) {
                        print "USER: $user HOST: $host\n";
                        die sprintf "Cannot revoke privileges from %s\@%s: %s\n",
                            $user, $host, $DBI::errstr;
                    }
                }
            }
            $rc++;
            for (my $i=0;$i<=$#{$ptr->{$key}};$i++) {
                my $grant = $ptr->{$key}[$i];
                $log_offset = 2;
                my ($s_grant, $s_suffix) = split_grant_suffix($grant);
                log_info(sprintf "grant %s to %s\@%s %s",
                            $s_grant, $user, $host, $s_suffix);
                next if $check_only;
                $dbh->do("GRANT $s_grant TO '$user'\@'$host' $s_suffix");
                if ($DBI::errstr &&
                    $DBI::errstr !~ /Table\s+'\S+\.\S+'\s+doesn't\s+exist/ &&
                    $DBI::errstr !~ /FUNCTION or PROCEDURE\s+\S+\s+does not exist/) {
                    die "Cannot grant privileges: $DBI::errstr\n";
                } elsif ($DBI::errstr) {
                    $log_offset = 0;
                    log_warn("Cannot apply grant: ".$DBI::errstr);
                    $log_offset = 2;
                }
            }
            if ($new_user && !$check_only) {
                create_protected_user($user, $host);
            }
        } else {
            die "Unparsable grants structure element: $key";
        }
    }

    return $rc;
}

sub apply_host_grants {

    my $rc = 0;

    foreach my $grant_host (sort { $a cmp $b } keys %{$grants->{hosts}}) {
        if (my $ref = $grants->{hosts}{$grant_host}) {
            (my $grant_host_rx = $grant_host) =~ s/\*/.+/g;
            my $hostname = get_hostname();
            $grant_host_rx = $hostname if $grant_host_rx eq "self";
            if ($hostname =~ /^$grant_host_rx$/) {
                log_debug("host: $hostname ($grant_host)");
                $ref = ref $ref ? $ref : [ $ref ];
                map { $rc += apply_grants($_) } @$ref;
                $log_offset = 0;
            }
        }
    }

    return $rc;
}

sub apply_copy_grants {

    my $rc = 0;

    foreach my $grant_host (sort { $a cmp $b } keys %{$grants->{copy}}) {
        if (my $ref = $grants->{copy}{$grant_host}) {
            (my $grant_host_rx = $grant_host) =~ s/\*/.+/g;
            my $hostname = get_hostname();
            $grant_host_rx = $hostname if $grant_host_rx eq "self";
            if ($hostname =~ /^$grant_host_rx$/) {
                log_debug("copy: $hostname ($grant_host)");
                $ref = ref $ref ? $ref : [ $ref ];
                map { $rc += apply_grants($_) } @$ref;
                $log_offset = 0;
            }
        }
    }

    return $rc;
}

sub apply_drop_users {
    my $forced_user = shift; # to drop a specific user

    my $rc = 0;

    my $drops = $grants->{drop};
    if ($forced_user) {
        $drops = { $forced_user => '*' };
    }

    foreach my $user (sort { $a cmp $b } keys %$drops) {
        my $ref = $drops->{$user};
        $ref = ref $ref ? $ref : [ $ref ];
        foreach my $host_rx (@$ref) {
            my $usel_sql = 'SELECT Host FROM mysql.user WHERE User = ?';
            my $is_not = 0;
            my $is_rx  = 0;
            if ($host_rx =~ s/^\!//) {
                $is_not = 1;
            }
            if ($host_rx =~ /\*/) {
                $host_rx =~ s/\*/%/g;
                $is_rx = 1;
            }
            $usel_sql .= $is_not
                            ? $is_rx
                                ? ' AND Host NOT LIKE ?'
                                : ' AND Host != ?'
                            : $is_rx
                                ? ' AND Host LIKE ?'
                                : ' AND Host = ?';
            my $users = $dbh->selectall_arrayref($usel_sql, undef, $user, $host_rx)
                or die "Cannot select user $user -- $host_rx: $DBI::errstr\n";
            foreach my $host_ref (@{$users}) {
                my $host = $host_ref->[0];
                $dbh->do("DROP USER '$user'\@'$host'")
                    or die "Cannot drop user $user -- $host: $DBI::errstr\n";
                log_info(sprintf "drop: %s\@%s", $user, $host);
                $rc++;
            }
        }
    }

    return $rc;
}

sub create_protected_user {
    my ($user, $host) = @_;
    my ($random_pass) = $dbh->selectrow_array("SELECT PASSWORD(?)",
                                              undef, pwgen());
    die "Cannot generate password: $DBI::errstr\n" if $DBI::err;
    unless ($random_pass =~ /^\*(\S+)\s*$/) {
        die "Cannot parse generated password: $random_pass";
    }
    $random_pass = '!'.$1;

    my ($temp_user_count) = $dbh->selectrow_array(<<SQL, undef, $user, $host);
SELECT COUNT(User)
  FROM user
 WHERE User = ?
   AND Host = ?
SQL
    die "Cannot select grant temp user: $DBI::errstr\n" if $DBI::err;

    unless ($temp_user_count) {
        $dbh->do("CREATE USER '$user'\@'$host'");
        die "Cannot create grant temp user: $DBI::errstr\n" if $DBI::err;
    }

    $dbh->do("SET PASSWORD FOR '$user'\@'$host' = PASSWORD(?)",
             undef, $random_pass);
    die sprintf "Cannot update %s@%s with generated password, %s\n",
        $user, $host, $DBI::errstr if $DBI::err;

    return;
}

sub split_grant_suffix {
    my $grant = shift;

    my $suffix = "";
    if ($grant =~ s/^(.+on\s+\S+\.\S+)\s+(to\s+\S+\@\S+\s*.*?)*((with|require).+)$/$1/i) {
        $suffix = $3 if $3;
    }

    return ($grant, $suffix);
}

sub grants_helper {
    my $grants = shift;

    my $user = $TEMP_GRANT_USER;
    my $host = $TEMP_GRANT_HOST;

    $dbh->do("REVOKE ALL PRIVILEGES, GRANT OPTION FROM '$user'\@'$host'");

    foreach my $grant (@$grants) {
        $grant = normalise_grant_str($grant);
        my ($s_grant, $s_suffix) = split_grant_suffix($grant);
        $dbh->do("GRANT $s_grant TO '$user'\@'$host' $s_suffix");
        if ($DBI::errstr &&
            $DBI::errstr !~ /Table\s+'\S+\.\S+'\s+doesn't\s+exist/ &&
            $DBI::errstr !~ /FUNCTION or PROCEDURE\s+\S+\s+does not exist/) {
            die "Cannot grant privileges: $DBI::errstr\n";
        } elsif ($DBI::errstr) {
            $log_offset = 0;
            log_warn("Cannot apply grant: ".$DBI::errstr);
            $log_offset = 2;
        }
    }

    my $temp_grants = $dbh->selectall_arrayref(
        "SHOW GRANTS FOR '$user'\@'$host'");

    for (my $i=0;$i<=$#$temp_grants;$i++) {
        if ($temp_grants->[$i][0] =~ /grant usage/i) {
            splice(@$temp_grants, $i, 1);
            last;
        }
    }

    die "Error in checking grants" if $#$temp_grants < 0;

    @$grants = ( map { $_->[0] } @$temp_grants );

    return;

}

sub normalise_grant_str {
    my $grant = shift;
    my $suffix = "";
    ($grant, $suffix) = split_grant_suffix($grant);
    $grant = lc $grant;
    $grant =~ s/(^\s+|\s+$)//g;
    $grant =~ s/^grant\s+//i;
    $grant =~ s/^(.+)\s+to\s+.+$/$1/i;
    $grant =~ s/`//g;
    $grant =~ s/,\s+/,/g;
    $grant =~ s/all\s+on/all privileges on/;

    if ($suffix) {
        $grant  =~ s/\s+$//;
        $suffix =~ s/\s+/ /;
        $suffix = lc $suffix;
        $grant = sprintf "%s %s", $grant, $suffix;
    }

    return $grant;
}

sub normalise_grants {
    my ($grants, $user, $host) = @_;
    grants_helper($grants);
    foreach my $grant (@$grants) {
        $grant = normalise_grant_str($grant);
    }

    return;
}

sub check_grants {
    my ($grants, $user, $host) = @_;

    my $current_grants = $dbh->selectall_arrayref(
        "SHOW GRANTS FOR '$user'\@'$host'");
    if ($DBI::errstr
        && ($DBI::errstr !~ /There is no such grant defined/ &&
            $DBI::errstr !~ /fetch[()]+ without execute[()]+/)) {
            die sprintf "Cannot select grants for %s\@%s: %s\n",
                $user, $host, $DBI::errstr;
    }

    return 0 if not $current_grants and scalar @$grants == 0;

    for (my $i=0;$i<=$#$current_grants;$i++) {
        if ($current_grants->[$i][0] =~ /grant usage/i) {
            splice(@$current_grants, $i, 1);
            last;
        }
    }

    return 1 if scalar @$current_grants != scalar @$grants;

    foreach my $c_ref (@{$current_grants}) {
        my $grant = $c_ref->[0];
        next if $grant =~ /grant usage/i;
        $grant = normalise_grant_str($grant);

        my $rc = 1;
        foreach my $check (@$grants) {
            if ($check eq $grant) {
                $rc = 0;
                last;
            }
        }
        return $rc if $rc;
    }

    return 0;
}

sub flush_privs {
    log_debug("flush privileges");
    $dbh->do("FLUSH PRIVILEGES")
        or die "Cannot flush privileges: $DBI::errstr\n";

    return;
}

sub main {
    $grants = YAML::XS::LoadFile($GRANTS_SCHEMA);

    my ($dbhost, $dbport);
    if (my $db_cfg = Config::Tiny->read($DB_CFG)) {
        ($dbhost, $dbport) = @{$db_cfg->{_}}{qw(PAIR_DBHOST PAIR_DBPORT)};
    } else {
        log_warn(sprintf "Cannot open %s: %s, using host=%s port=%s",
            $DB_CFG, $ERRNO,
            $custom_db_host // $DEFAULT_DBHOST,
            $custom_db_port // $DEFAULT_DBPORT);
        $dbhost = $custom_db_host // $DEFAULT_DBHOST;
        $dbport = $custom_db_port //  $DEFAULT_DBPORT;
    }
    $dbhost = $custom_db_host // $dbhost;
    $dbport = $custom_db_port // $dbport;

    connect_db($dbhost, $dbport);

    if ($check_only) {
        log_info("** running in 'check only' mode, no changes are made to the users/grants **");
    }

    $dbh->begin_work or die "Cannot start transaction: $DBI::errstr\n";

    eval {
        my $rc = 0;

        create_protected_user($TEMP_GRANT_USER, $TEMP_GRANT_HOST);

        foreach my $proc (@{$grants->{order}}) {
            SWITCH: for ($proc) {
                /^drop$/ && do {
                    if (!$check_only && apply_drop_users()) {
                        flush_privs();
                    }
                };
                /^hosts$/ && do {
                    $rc += apply_host_grants();
                };
                /^copy$/ && do {
                    $rc += apply_copy_grants();
                };
            } # SWITCH
        }

        $dbh->do("DROP USER IF EXISTS '$TEMP_GRANT_USER'\@'$TEMP_GRANT_HOST'");
        die "Cannot drop temp user: $DBI::errstr\n" if $DBI::err;

        if ($rc) {
            flush_privs();
        }
    };
    if ($@) {
        $dbh->rollback if $dbh;
        $dbh->disconnect if $dbh;
        die "Error: $@";
    }

    $dbh->commit or die "Cannot commit transaction: $DBI::errstr\n";
    $dbh->disconnect;

    if ($recreate_user) {
        log_info(<<MSG);
Warning: Recreated users are without passwords,
         please consider running 'ngcp-sync-db-creds' to update passwords for them.
MSG
    }

    return;
}

main();

exit 0;

# vim: ts=4 sw=4 et

__END__

=pod

=head1 NAME

ngcp-sync-db-grants - synchronizes mysql grants from a schema template

=head1 SYNOPSIS

B<ngcp-sync-db-grants> [I<options>...]

=head1 DESCRIPTION

B<This program> synchronizes mysql grants from a schema template.

=head1 OPTIONS

=over 8

=item B<--recreate-user>

Drop all appearances of a user before applying grants.
This option is useful to clean up the particular
user's hosts that are not covered by the schema.

=item B<--verbose>

Verbose mode

=back

=head1 EXIT STATUS

=over 8

=item B<exit code 0>
Everything is ok

=item B<exit code != 0>
Something is wrong, an error message raises

=back

=head1 DIAGNOSTICS

TODO

=head1 BUGS AND LIMITATIONS

Please report problems you notice to the Sipwise
Development Team <support@sipwise.com>.

=head1 AUTHOR

Kirill Solomko <ksolomko@sipwise.com>

=head1 LICENSE

Copyright (C) 2016 Sipwise GmbH, Austria

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.

=cut
