#!/usr/bin/perl
#----------------------------------------------------------------------
# Synchronizes passwords from constants.yml with MySQL
#----------------------------------------------------------------------
use strict;
use warnings;
use English qw( -no_match_vars );
use DBI qw(:sql_types);
use Config::Tiny;
use YAML::XS;
use Getopt::Long;
use IPC::System::Simple qw(system capturex);
use Readonly;
use Term::ReadPassword;
#----------------------------------------------------------------------
Readonly my $CONSTANTS_YML => "/etc/ngcp-config/constants.yml";
Readonly my $SIPWISE_EXTRA_CNF => "/etc/mysql/sipwise_extra.cnf";
Readonly my $DB_CFG => "/etc/default/ngcp-db";
Readonly my $PW_UNDEF => "PW_UNDEF";
Readonly my $DEFAULT_DBHOST => "127.0.0.1";
Readonly my $DEFAULT_DBPORT => "3306";

my $yml             = {};
my $dbh;
my $password_length = 20;
my $init_passwords  = 0;
my $debug           = 0;
my $test_mode       = 0;
my $error           = 0;
my $mysql_root      = 0;

my $custom_db_host;
my $custom_db_port;

my $no_warnings = 0;

sub Usage {
    print <<USAGE;
==
    Synchronizes passwords from constants.yml with MySQL
==
$PROGRAM_NAME [options]
Options:
    -help|-h|-?         -- this help
    -root|-r            -- use mysql root user without password as DB credentials
    -init-passwords|-i  -- generate new passwords (constants.yml is updated)
    -test|-t            -- test mode (no updates)
    -verbose|-v         -- verbose mode
    --db-host           -- use custom db host
    --db-port           -- use custom db port
    --no-warnings       -- suppress warning messages
USAGE
    exit 0;
}

GetOptions("h|?|help"         => \&Usage,
           "i|init-passwords" => \$init_passwords,
           "r|root"           => \$mysql_root,
           "t|test"           => \$test_mode,
           "v|verbose"        => \$debug,
           "db-host=s"        => \$custom_db_host,
           "db-port=s"        => \$custom_db_port,
           "no-warnings"      => \$no_warnings);
#----------------------------------------------------------------------
sub logger {
    my $str = shift || '';
    my $mode = shift || 0;
    return if $debug == -1;
    return if $mode == 1 && $debug <= 0;
    my $prefix = $mode < 2 ? "-->" : "Warning:";
    printf { $mode == 2 ? *STDERR : *STDOUT } "%s %s\n", $prefix, $str;

    return;
}

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

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 get_nodename {
    my @lines = capturex( [ 0 ], "/usr/sbin/ngcp-nodename");
    my $l = shift @lines;
    chomp $l;
    log_debug("nodename => $l");

    return $l;
}

sub connect_db {
    my ($dbhost, $dbport) = @_;
    my $dsn = "DBI:mysql:database=mysql;host=${dbhost};port=${dbport}";
    my $dbuser = '';
    my $dbpass = '';
    my $dbauthmsg = "'$SIPWISE_EXTRA_CNF'";

    if ($mysql_root) {
      $dbuser = 'root';
      $dbauthmsg = "user '$dbuser'";
    } else {
      $dsn .= ";mysql_read_default_file=$SIPWISE_EXTRA_CNF";
    }

    if ($dbh = DBI->connect($dsn, $dbuser, $dbpass, { PrintError => 0 })) {
      log_debug("connected to $dbhost:$dbport using $dbauthmsg");
    } else {
      warn "Cannot connect to MySQL database 'mysql' using $dbauthmsg: $DBI::errstr\n";
      if ($mysql_root) {
        while (1) {
            $dbpass = read_password("MySQL $dbauthmsg password: ", undef, 1);

            $dbh = DBI->connect($dsn, $dbuser, $dbpass, { PrintError => 0 })
                or warn "Cannot connect to MySQL database 'mysql' using $dbauthmsg with provided password: $DBI::errstr\n";
            last if $dbh;
        }
        log_debug("connected to $dbhost:$dbport using $dbauthmsg with provided password.");
      } else {
        exit 1;
      }
    }

    $dbh->do("SET sql_log_bin=0")
        or die "Cannot set sql_log_bin=0: $DBI::errstr\n";

    return;
}

sub get_user_pass {
    my $h_ref = shift
        || die "No data to work with, check file $CONSTANTS_YML";
    my $opts = shift;
    my $yml_ref = shift;
    my $deep = 0;

    my @data;
    foreach my $ref (keys %{$h_ref}) {
        die "Malformed u/p pair for entry $ref"
            unless defined $h_ref->{$ref}{u} && defined $h_ref->{$ref}{p};
        my $u_ref = \$h_ref->{$ref}{u};
        my $p_ref = \$h_ref->{$ref}{p};
        #log_debug(sprintf "%s%s.%s", " "x(($deep+1)*2), $ref, ${$u_ref});
        if ($init_passwords) {
            ${$p_ref} = pwgen();
        }
        push @data, { user => ${$u_ref}, pass => ${$p_ref} };
    }

    return \@data;
}

sub adjust_replication_master_info {
    my ($user, $pass) = @_;

    my $ch = $dbh->prepare("show slave status")
        or die "Cannot prepare: $DBI::errstr\n";

    $ch->execute() or die "Cannot execute: $DBI::errstr\n";

    my $fields = $ch->{NAME};
    my $vals   = $ch->fetchall_arrayref();

    $ch->finish;

    my %data = ();
    for (my $i=0;$i<scalar @{$fields};$i++) {
        $data{$fields->[$i]} = $vals->[0][$i];
    }

    if (defined $data{Master_Server_Id}) {
        $dbh->do("STOP SLAVE");
        die "Cannot stop slave: $DBI::errstr\n" if $DBI::err;

        log_info("* updating replication password for user $user");

        $dbh->do("CHANGE MASTER TO MASTER_PASSWORD=?", undef, $pass);

        $dbh->do("START SLAVE");
        die "Cannot start slave: $DBI::errstr\n" if $DBI::err;
    }

    return;
}

sub sync_user {
    my ($user, $pass) = @_;

    my ($mysql_version_raw) = $dbh->selectrow_array("SELECT VERSION()");
    my $mysql_version = $mysql_version_raw =~ s/^(\d+\.\d+).+$/$1/r;
    my $sth_sel;

    if ($mysql_version >= 10.4) {
        $sth_sel = $dbh->prepare(<<SQL);
SELECT Host,
       JSON_EXTRACT(Priv, '\$.authentication_string') = PASSWORD(?) as matched
  FROM global_priv
 WHERE User = ?
   AND JSON_EXTRACT(Priv, '\$.plugin') = 'mysql_native_password'
GROUP by matched, Host
SQL
    } else {
        $sth_sel = $dbh->prepare(<<SQL);
SELECT Host, Password = PASSWORD(?) as matched
  FROM user
 WHERE User = ?
GROUP by matched, Host
SQL
    }

    my $sth_upd = $dbh->prepare(<<SQL);
SET PASSWORD FOR ?@? = PASSWORD(?)
SQL

    $sth_sel->execute($pass, $user)
        or die "Cannot execute: $DBI::errstr\n";

    my ($count, $changed) = (0,0);
    while (my ($host, $matched) = $sth_sel->fetchrow_array()) {
        $count++;
        next if $matched;
        if ($debug) {
            log_debug(sprintf "%s@%s => %s", $user, $host, $pass);
        } else {
            log_info(sprintf "%s@%s", $user, $host);
        }
        unless ($test_mode) {
            $sth_upd->execute($user, $host, $pass)
                or die "Cannot update: $DBI::errstr\n";
            $changed++;
            if ($user eq 'replicator') {
                adjust_replication_master_info($user, $pass);
            }
        }
    }
    unless ($count) {
        log_debug(sprintf "%s => does not exist in mysql, skipped (check grants.yml and run ngcp-sync-db-grants)", $user);
    }

    $sth_sel->finish;
    $sth_upd->finish;

    return $changed;
}

sub sync_mysql_data {

    my $rc = 0;

    my $data = get_user_pass($yml->{credentials}{mysql});

    foreach my $pair (@{$data}) {
        if (defined $pair->{user} and defined $pair->{pass}) {
            unless ($pair->{pass}) {
                die sprintf "Empty password %s for user %s",
                    @{$pair}{qw(pass user)};
            }
            if ($pair->{pass} eq $PW_UNDEF) {
                die sprintf "Undefined password %s for user %s",
                    @{$pair}{qw(pass user)};
            }
            $rc += sync_user(@{$pair}{qw(user pass)});
        }
    }

    return $rc;
}

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

    return;
}

sub main {
    eval {
        $yml = YAML::XS::LoadFile($CONSTANTS_YML);
    };
    die "Can't read constants file: $EVAL_ERROR\n" if $EVAL_ERROR;

    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;

    if ($init_passwords and not $test_mode and not -w $CONSTANTS_YML) {
        die "$CONSTANTS_YML is not writable";
    }

    log_info("[TEST MODE]") if $test_mode;

    get_nodename();

    connect_db($dbhost, $dbport);

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

    eval {

        my $rc = sync_mysql_data();

        if ($rc) {
            flush_privs();
        } else {
            log_debug("nothing to update");
        }
    };
    if ($@) {
        $dbh->rollback if $dbh;
        $dbh->disconnect if $dbh;
        die "Error: $@";
    }

    $dbh->commit if $dbh;
    $dbh->disconnect if $dbh;

    return unless $init_passwords;

    return if $test_mode;

    log_info("writing new passwords into $CONSTANTS_YML ... ");
    YAML::XS::DumpFile($CONSTANTS_YML, $yml);
    log_info("done");

    return;
}
#----------------------------------------------------------------------
main();

exit $error;

__END__

=pod

=head1 NAME

ngcp-sync-db-creds - synchronizes passwords from constants.yml with MySQL

=head1 SYNOPSIS

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

=head1 DESCRIPTION

B<This program> reads constants.yml file, parses it and synchronizes all
required passwords with MySQL.

=head1 OPTIONS

=over 8

=item B<--root>

Use mysql root user without password as DB credentials

=item B<--init-passwords>

New passwords are generated (passwords for "mysql" is not generated to avoid replication problems)

=item B<--test>

No real updates, only for checks

=item B<--verbose>

Verbose mode where all changes are written to STDOUT

=item B<--help>

Print this help message.

=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 CONFIGURATION

/etc/ngcp-config/constants.yml

=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
