#!/usr/bin/env perl
# passwd - change Webmin users password

use strict;
use warnings;
use 5.010;

use File::Basename;
use Getopt::Long;
use Pod::Usage;
use Term::ANSIColor qw(:constants);
use lib (dirname(dirname($0)));
use WebminCore;

sub main
{
    my %opt;
    GetOptions('help|h'       => \$opt{'help'},
               'config|c=s'   => \$opt{'config'},
               'user|u=s'     => \$opt{'user'},
               'password|p=s' => \$opt{'password'},
               'unix'         => \$opt{'unix'},
               'webmin-only|webmin' => \$opt{'webmin'},
               'stdout|o!'    => \$opt{'stdout'});

    # If username passed as regular param
    my $user = scalar(@ARGV) == 1 && $ARGV[0];

    # Show usage
    pod2usage(0) if ($opt{'help'} || (!$opt{'user'} && !$user));

    # Assign defaults
    $opt{'config'} ||= "/etc/webmin";
    $opt{'user'} = $user if ($user && !$opt{'user'});

    # Catch kill signal
    my $sigkill = sub {
        system("stty echo");
        print "\n^C";
        print "\n";
        exit 1;
    };
    $SIG{INT} = \&$sigkill;

    # Run change password command
    change_password(\%opt);

    return 0;
}
exit main(\@ARGV) if !caller(0);

sub change_password
{
    my ($optref) = @_;
    my ($minserv_uconf_file, %lusers, @users, %uinfos, %ulines);
    my $user       = $optref->{'user'};
    my $pass       = $optref->{'password'};
    my $confdif    = $optref->{'config'};
    my $conf       = "$confdif/config";
    my $mconf      = "$confdif/miniserv.conf";
    my $conf_check = sub {
        my ($configs) = @_;
        foreach my $config (@{$configs}) {
            if (!-r $config) {
                say BRIGHT_RED, "Error: ", RESET, "Failed to read Webmin essential config file: ", BRIGHT_YELLOW, $config,
                  RESET, " doesn't exist";
                exit 1;
            }
        }
    };
    my $root             = root($confdif, \&$conf_check);
    my $encrypt_password = sub {
        my ($pass, $gconfig, $config) = @_;
        my $root = root($confdif, \&$conf_check);

        # Use pre-defined encryption (forced by Webmin config)
        if (!$optref->{'stdout'} &&
            ($gconfig->{'md5pass'} == 1 ||
             $gconfig->{'md5pass'} == 2))
        {
            do "$root/acl/md5-lib.pl";

            # Use MD5 encryption
            return &encrypt_md5($pass) if ($gconfig->{'md5pass'}) == 1;

            # Use SHA512 encryption
            return &encrypt_sha512($pass) if ($gconfig->{'md5pass'}) == 2;

        } else {

            # Try detecting system default first
            my $module = 'useradmin';
            if (-d "$root/$module") {
                $ENV{'PERLLIB'}                = "$root";
                $ENV{'WEBMIN_CONFIG'}          = "$confdif";
                $ENV{'FOREIGN_ROOT_DIRECTORY'} = "$root/$module";
                $ENV{'FOREIGN_MODULE_NAME'}    = "$module";
                chdir("$root/$module");
                require "$root/useradmin/user-lib.pl";

                # We need to set third parameter to make sure useradmin's config
                # won't be used for hashing format, as we need to auto detect it
                return &encrypt_password($pass, undef, 'force_system_detection');
            } else {

                # Use old Unix DES
                srand(time() ^ $$);
                return crypt($pass, chr(int(rand(26)) + 65) . chr(int(rand(26)) + 65));
            }
        }
    };

    # Check for main config and miniserv config files
    &$conf_check([$conf, $mconf]);

    # Read and parse configs
    my (%config, %gconfig, %uconfig);
    read_file($mconf, \%config);
    read_file($conf,  \%gconfig);
    $minserv_uconf_file = $config{'userfile'};

    # Check for main user file
    &$conf_check([$minserv_uconf_file]);

    # Read and parse `miniserv.users` config file
    read_file($minserv_uconf_file, \%lusers, undef, undef, ":");
    @users = keys %lusers;
    map {my @uinfo = split(':', "$lusers{$_}"); $uinfos{$_} = \@uinfo} @users;

    # Check if user exists
    if (!defined($uinfos{$user})) {
        my $user_str  = scalar(@users) > 1 ? 'users' : 'user';
        my $user_str2 = scalar(@users) > 1 ? 'are'   : 'is';
        die(BRIGHT_RED, "Error: ", RESET . "Webmin user ",
            BRIGHT_YELLOW, $user, RESET, " doesn't exist. Existing Webmin $user_str on your system $user_str2 — ",
            BRIGHT_YELLOW, join(", ", sort(@users)),
            RESET,         "\n");
    }

    # A Webmin user can either authenticate as a Unix user or have a separate
    # password in miniserv.users. Make this distinction explicit whenever both
    # accounts exist, as silently setting the latter overrides Unix
    # authentication and leaves SSH and Webmin with different passwords.
    my @unix_info = getpwnam($user);
    my $target = choose_password_target(
        $optref, $user, scalar(@unix_info));
    if ($target eq 'unix') {
        change_unix_password($user);

        # Ensure Webmin uses the newly changed Unix password. No restart is
        # needed when the account was already configured this way.
        if (($uinfos{$user}->[0] // '') ne 'x') {
            $uinfos{$user}->[0] = 'x';
            map {$ulines{$_} = join(":", @{ $uinfos{$_} })} keys %uinfos;
            store_webmin_users(
                $confdif, $minserv_uconf_file, \%ulines);
        }
        say "Unix password for user ", BRIGHT_YELLOW, $user, RESET,
            " updated successfully; Webmin will use Unix authentication";
        exit 0;
    }

    # Ask for password on stdin
    my $suc_pre_msg = "";
    my $suc_msg     = 'updated successfully';
    if (!$pass) {
        print "Enter password for user ", BRIGHT_YELLOW, $user, RESET, ":";
        system("stty -echo");
        $pass = <STDIN>;
        system("stty echo");
        print "\nRetype new password:";
        system("stty -echo");
        my $pass2 = <STDIN>;
        system("stty echo");
        print "\n";

        if ($pass ne $pass2) {
            say BRIGHT_RED, "Error: ", RESET, "Passwords do not match";
            exit 1;
        }
        chomp $pass;
        if (!$pass) {
            $suc_pre_msg = BOLD BRIGHT_RED ON_WHITE . 'Warning:' . RESET . " ";
            $suc_msg     = "has been removed, enabling anyone to login without authentication";
        }
    }

    # Update with new password and store timestamp
    $uinfos{$user}->[0] = &$encrypt_password($pass, \%gconfig, \%config);

    # Print the hash and exit
    if ($optref->{'stdout'}) {
        say $uinfos{$user}->[0];
        exit 0;
    }
    $uinfos{$user}->[5] = time() if ($uinfos{$user}->[5]);
    map {$ulines{$_} = join(":", @{ $uinfos{$_} })} keys %uinfos;

    # Write the new user config and restart Webmin
    store_webmin_users($confdif, $minserv_uconf_file, \%ulines);

    # Print user message
    say "${suc_pre_msg}Password for Webmin user ", BRIGHT_YELLOW, $user, RESET, " $suc_msg";

    exit 0;
}

sub choose_password_target
{
    my ($optref, $user, $unix_exists, $interactive) = @_;

    die BRIGHT_RED, "Error: ", RESET,
        "The --unix and --webmin-only options cannot be used together\n"
        if ($optref->{'unix'} && $optref->{'webmin'});
    die BRIGHT_RED, "Error: ", RESET,
        "The --unix and --stdout options cannot be used together\n"
        if ($optref->{'unix'} && $optref->{'stdout'});
    die BRIGHT_RED, "Error: ", RESET,
        "The --unix and --password options cannot be used together; ",
        "the system passwd command reads the password securely\n"
        if ($optref->{'unix'} && defined($optref->{'password'}));
    die BRIGHT_RED, "Error: ", RESET, "Unix user ", BRIGHT_YELLOW,
        $user, RESET, " doesn't exist\n"
        if ($optref->{'unix'} && !$unix_exists);

    return 'webmin' if ($optref->{'stdout'} || !$unix_exists);
    return 'unix' if ($optref->{'unix'});
    return 'webmin' if ($optref->{'webmin'});
    if (defined($optref->{'password'})) {
        print STDERR unix_password_warning($user),
            " The --password option explicitly sets a separate Webmin-only ",
            "password; use --unix without --password to change the Unix ",
            "password instead.\n";
        return 'webmin';
    }

    $interactive = -t STDIN if (!defined($interactive));
    if ($interactive) {
        return prompt_password_target($user);
    }

    print STDERR unix_password_warning($user),
        " Non-interactive input prevents asking which password to change; ",
        "continuing with a separate Webmin-only password. Pass --unix or ",
        "--webmin-only to select explicitly.\n";
    return 'webmin';
}

sub prompt_password_target
{
    my ($user) = @_;
    say unix_password_warning($user);
    say "  1. Change the Unix password and use Unix authentication in Webmin",
        " (recommended)";
    say "  2. Set a separate Webmin-only password in miniserv.users";

    while (1) {
        print "Select password type [1]: ";
        my $choice = <STDIN>;
        die BRIGHT_RED, "\nError: ", RESET,
            "No password type was selected\n" if (!defined($choice));
        chomp $choice;
        $choice = lc($choice);
        return 'unix' if ($choice eq '' || $choice eq '1' ||
                          $choice eq 'u' || $choice eq 'unix');
        return 'webmin' if ($choice eq '2' || $choice eq 'w' ||
                            $choice eq 'webmin');
        say BRIGHT_RED, "Invalid selection.", RESET,
            " Enter 1 for Unix or 2 for Webmin-only.";
    }
}

sub unix_password_warning
{
    my ($user) = @_;
    return BRIGHT_YELLOW . "Warning: " . RESET . "Webmin user " .
        BRIGHT_YELLOW . $user . RESET .
        " is also a Unix user. A separate Webmin password overrides Unix " .
        "authentication, so Webmin and SSH can have different passwords.";
}

sub change_unix_password
{
    my ($user) = @_;
    my $passwd = has_command('passwd');
    die BRIGHT_RED, "Error: ", RESET,
        "The system passwd command could not be found\n" if (!$passwd);
    die BRIGHT_RED, "Error: ", RESET,
        "Changing a Unix password requires an interactive terminal. ",
        "Run ", BRIGHT_YELLOW, "$passwd $user", RESET, " directly instead.\n"
        if (!-t STDIN);

    my $status = system { $passwd } $passwd, $user;
    if ($status == -1) {
        die BRIGHT_RED, "Error: ", RESET,
            "Failed to run $passwd: $!\n";
    }
    elsif ($status & 127) {
        die BRIGHT_RED, "Error: ", RESET,
            "The system passwd command was interrupted\n";
    }
    elsif ($status >> 8) {
        die BRIGHT_RED, "Error: ", RESET,
            "The system passwd command failed\n";
    }
}

sub store_webmin_users
{
    my ($confdif, $minserv_uconf_file, $ulines) = @_;

    # Store original file first
    copy_source_dest($minserv_uconf_file, "$minserv_uconf_file-");

    # Restart Webmin and write new user config file
    system("$confdif/stop >/dev/null 2>&1");
    write_file($minserv_uconf_file, $ulines, ":");
    system("$confdif/start >/dev/null 2>&1");
}

sub root
{
    my ($config, $conf_check) = @_;
    my $mconf = "$config/miniserv.conf";
    $conf_check->([$mconf]);
    open(my $CONF, "<", $mconf);
    my $root;
    while (<$CONF>) {
        if (/^root=(.*)/) {
            $root = $1;
        }
    }
    close($CONF);

    # Does the Webmin root exist?
    if ($root) {
        die BRIGHT_RED, "Error: ", BRIGHT_YELLOW, $root, RESET, " is not a directory\n" unless (-d $root);
    } else {

        # Try to guess where Webmin lives, since config file didn't know.
        die BRIGHT_RED, "Error: ", RESET, "Unable to determine Webmin installation directory\n";
    }

    return $root;
}

1;

=pod

=head1 NAME

 passwd

=head1 DESCRIPTION

 This program allows you to change the password used by a Webmin user.
 When a matching Unix user exists, it can change the Unix password or set a
 separate password in the Webmin password file.

=head1 SYNOPSIS

 webmin passwd [options]

=head1 OPTIONS

=over

=item --help, -h

 Print this usage summary and exit.

 Examples of usage:
  - webmin passwd root
  - webmin passwd --user root
  - webmin passwd --user root --unix
  - webmin passwd --user admin --webmin-only
  - webmin passwd --user admin --webmin-only --password ycwyMQRVAZY
  - webmin passwd --config /usr/local/etc/webmin --user admin --webmin-only --password ycwyMQRVAZY
  - webmin passwd --config /usr/local/etc/webmin --user root --password ycwyMQRVAZY --stdout

=item --config, -c

 Specify the full path to the Webmin configuration directory. Defaults to C</etc/webmin>

=item --user, -u

 Existing Webmin user to change password for

=item --password, -p

Set a new Webmin-only password. Using this option may be insecure because the
password can be exposed in the process list.

=item --unix

Change the matching Unix user's password with the system C<passwd> command,
and configure Webmin to use Unix authentication. This is the recommended mode
when the Webmin username is also a Unix username.

=item --webmin-only, --webmin

Set a separate password in C<miniserv.users>, even if a matching Unix user
exists. This password overrides Unix authentication for the Webmin user.

=back

=head1 LICENSE AND COPYRIGHT

 Copyright 2018 Jamie Cameron <jcameron@webmin.com>
                Joe Cooper <joe@virtualmin.com>
                Ilia Ross <ilia@virtualmin.com>
