#! /usr/bin/perl -T
# vim: set filetype=perl ts=4 sw=4 sts=4 et:
# NEEDGROUP osh-accountCreate
# SUDOERS %osh-accountCreate ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountCreate --type normal *
# FILEMODE 0700
# FILEOWN 0 0

#>HEADER
use common::sense;
use Getopt::Long  qw(:config no_auto_abbrev no_ignore_case);
use Sys::Hostname ();
use JSON;
use POSIX ();

use File::Basename;
use lib dirname(__FILE__) . '/../../lib/perl';
use OVH::Bastion;
use OVH::Bastion::Helper;

# Fetch command options
my $fnret;
my ($result, @optwarns);
my (
    $forceKeyFrom,    $type,         $account,      $realmFrom, $uid,
    @pubKeys,         $comment,      $alwaysActive, $uidAuto,   $oshOnly,
    $maxInactiveDays, $immutableKey, $ttl
);
eval {
    local $SIG{__WARN__} = sub { push @optwarns, shift };
    $result = GetOptions(
        "force-key-from=s"    => sub { $forceKeyFrom //= $_[1] },    # only to be used by the install script
        "type=s"              => sub { $type         //= $_[1] },
        "from=s"              => sub { $realmFrom    //= $_[1] },
        "uid=s"               => sub { $uid          //= $_[1] },
        "account=s"           => sub { $account      //= $_[1] },
        "always-active"       => sub { $alwaysActive //= $_[1] },
        "pubKey=s"            => \@pubKeys,
        "comment=s"           => sub { $comment         //= $_[1] },
        'uid-auto'            => sub { $uidAuto         //= $_[1] },
        'osh-only'            => sub { $oshOnly         //= $_[1] },
        'max-inactive-days=i' => sub { $maxInactiveDays //= $_[1] },
        'immutable-key'       => sub { $immutableKey    //= $_[1] },
        'ttl=i'               => sub { $ttl             //= $_[1] },
    );
};
if ($@) { die $@ }

OVH::Bastion::Helper::check_spurious_args();

if (!$result) {
    local $" = ", ";
    HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns");
}

if (!$account || !$type) {
    HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account' or 'type'");
}

#<HEADER

#>PARAMS:TYPE
osh_debug("Checking type");
if (not grep { $type eq $_ } qw{ normal realm }) {
    HEXIT('ERR_INVALID_PARAMETER', "Expected type 'normal' or 'realm'");
}

#<PARAMS:TYPE

# take a lock here, do it before checking for account existence,
# because another parallel creation of the same account might be
# occurring, in which case we'd still hit a race condition
# additionally, the lock type "passwd" is used by all helpers
# that may modify /etc/passwd or /etc/group.
$fnret = OVH::Bastion::Helper::get_lock_fh(category => "passwd");
$fnret or HEXIT($fnret);
my $lock_fh = $fnret->value;
$fnret = OVH::Bastion::Helper::acquire_lock($lock_fh);
$fnret or HEXIT($fnret);

#>PARAMS:ACCOUNT
osh_debug("Checking account");
$fnret = OVH::Bastion::is_account_valid(account => $account);
$fnret or HEXIT($fnret);

# get returned untainted value
$account = $fnret->value->{'account'};

$fnret = OVH::Bastion::is_account_existing(account => $account);
$fnret->is_err and HEXIT($fnret);
$fnret->is_ok  and HEXIT('KO_ALREADY_EXISTING', msg => "The account $account already exists");

$fnret = OVH::Bastion::is_group_existing(group => $account);
$fnret->is_err and HEXIT($fnret);
$fnret->is_ok  and HEXIT('KO_ALREADY_EXISTING', msg => "The group $account already exists");

if ($type eq 'realm') {
    $account = "realm_$account";
    $fnret   = OVH::Bastion::is_account_valid(account => $account, accountType => "realm");
    $fnret or HEXIT($fnret);

    $fnret = OVH::Bastion::is_account_existing(account => $account, accountType => "realm");
    $fnret->is_err and HEXIT($fnret);
    $fnret->is_ok  and HEXIT('KO_ALREADY_EXISTING', msg => "The realm $account already exists");

    $fnret = OVH::Bastion::is_group_existing(group => $account);
    $fnret->is_err and HEXIT($fnret);
    $fnret->is_ok  and HEXIT('KO_ALREADY_EXISTING', msg => "The group $account already exists");
}

#<PARAMS:ACCOUNT

#>PARAMS:UID
if (not $uidAuto and not defined $uid) {
    HEXIT('ERR_MISSING_PARAMETER', msg => "Missing one of 'uid-auto' or 'uid' argument");
}
if (defined $uid and $uidAuto) {
    HEXIT('ERR_INCOMPATIBLE_PARAMETERS', msg => "Incompatible parameters 'uid' and 'uid-auto' specified");
}
if (defined $uid) {
    $fnret = OVH::Bastion::is_valid_uid(uid => $uid, type => 'user');
    $fnret or HEXIT($fnret);
    $uid = $fnret->value;
    getpwuid($uid) and HEXIT('ERR_UID_COLLISION', msg => "This UID ($uid) is already taken");

    $fnret = OVH::Bastion::is_valid_uid(uid => $uid, type => 'group');
    $fnret or HEXIT($fnret);
    getgrgid($uid) and HEXIT('ERR_GID_COLLISION', msg => "This GID ($uid) is already taken");
}
elsif ($uidAuto) {
    $fnret = OVH::Bastion::get_next_available_uid(available_gid => 1, available_gid_ttyrec => 1);
    $fnret or HEXIT($fnret);
    $uid = $fnret->value();
}

#<PARAMS:UID

if (defined $maxInactiveDays && $maxInactiveDays < 0) {
    HEXIT('ERR_INVALID_PARAMETER', msg => "Expected a >= 0 amount of days for --max-inactive-days");
}

#>PARAMS
my $ttygroup = "$account-tty";
$fnret = OVH::Bastion::is_group_existing(group => $ttygroup);
$fnret and HEXIT('ERR_TTY_GROUP_ALREADY_EXISTS', msg => "The TTY group for this account ($ttygroup) already exists!");

#<PARAMS

#>RIGHTSCHECK
if ($self eq 'root') {
    osh_debug "Real root, skipping checks of permissions";
}
else {
    # need to perform another security check
    if ($type eq 'realm') {
        $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-realmCreate");
        $fnret or HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self");
    }
    else {
        $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-accountCreate");
        $fnret or HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self");
    }
}

#<RIGHTSCHECK

#>CODE
$fnret = OVH::Bastion::load_configuration();
$fnret or HEXIT($fnret);
my $config = $fnret->value;

my $ttygid = $uid + $config->{'ttyrecGroupIdOffset'};
getgrgid($ttygid) and HEXIT('ERR_GID_COLLISION', msg => "This GID ($ttygid) is already taken");

if ($uid < $config->{'accountUidMin'} or $uid > $config->{'accountUidMax'}) {
    HEXIT('ERR_UID_INVALID_RANGE',
        msg => "UID must be < " . $config->{'accountUidMin'} . " and > " . $config->{'accountUidMax'});
}

my @vettedKeys;
foreach my $key (@pubKeys) {
    $fnret = OVH::Bastion::is_valid_public_key(pubKey => $key, way => 'ingress');
    $fnret or HEXIT($fnret);

    $key = $fnret->value->{'typecode'} . ' ' . $fnret->value->{'base64'};
    if ($fnret->value->{'comment'}) {
        $key .= ' ' . $fnret->value->{'comment'};
    }
    push @vettedKeys, $key;
}

my $prefix             = $fnret->value->{'prefix'};
my @userProvidedIpList = ();
if ($prefix) {
    my ($fromString) = $prefix =~ m{from=["']([^"']+)["']};
    if ($fromString) {
        @userProvidedIpList = split /,/, $fromString;
    }
}

$fnret = OVH::Bastion::get_from_for_user_key(
    userProvidedIpList => \@userProvidedIpList,
    forcedList         => ($forceKeyFrom ? [$forceKeyFrom] : [])
);
$fnret or HEXIT($fnret);

my $from    = $fnret->value->{'from'};
my $ipList  = $fnret->value->{'ipList'};
my $homedir = "/home/$account";

osh_info "Creating group $account with GID $uid...";
$fnret = OVH::Bastion::sys_groupadd(noisy_stderr => 1, gid => $uid, group => $account);
$fnret->err eq 'OK'
  or HEXIT('ERR_GROUPADD_FAILED',
    msg => "Error while running groupadd with UID $uid and group $account (" . $fnret->msg . ")");
osh_debug('ok, group was created');

osh_info "Creating user $account with UID $uid...";
$fnret = OVH::Bastion::sys_useradd(
    noisy_stderr => 1,
    user         => $account,
    uid          => $uid,
    gid          => $uid,
    shell        => $OVH::Bastion::BASEPATH . '/bin/shell/osh.pl',
    home         => $homedir
);
$fnret->err eq 'OK'
  or HEXIT('ERR_USERADD_FAILED', msg => "Error while running useradd for $account UID/GID $uid (" . $fnret->msg . ")");
osh_debug('user created');

chmod 0750, $homedir;

mkdir $homedir . "/.ssh" if (!-d "$homedir/.ssh");
chmod 0750, $homedir . "/.ssh";
chown $uid, $uid, "$homedir/.ssh";

my $akfile = $homedir . '/' . OVH::Bastion::AK_FILE;
if (!OVH::Bastion::touch_file($akfile)) {
    HEXIT('ERR_CANNOT_CREATE_FILE', msg => "Failed to create authorized_keys file");
}
chmod 0640, $akfile;
chown $uid, $uid, $akfile;

osh_info "Creating tty group of account...";
$fnret = OVH::Bastion::sys_groupadd(noisy_stderr => 1, group => $ttygroup, gid => $ttygid);
$fnret->err eq 'OK'
  or HEXIT('ERR_GROUPADD_FAILED',
    msg => "Error while running groupadd with UID $ttygid and group $ttygroup (" . $fnret->msg . ")");
osh_debug('ok, group was created');

$fnret = OVH::Bastion::add_user_to_group(
    user        => $account,
    group       => $ttygroup,
    groupType   => 'tty',
    accountType => ($type eq 'realm' ? 'realm' : 'normal')
);
$fnret or HEXIT($fnret);

# adding account to bastion-users group
$fnret = OVH::Bastion::add_user_to_group(
    user        => $account,
    group       => "bastion-users",
    accountType => ($type eq 'realm' ? 'realm' : 'normal')
);
$fnret or HEXIT($fnret);

if ($type ne 'realm') {
    osh_info "Adding account to potential supplementary groups...";
    if ($config->{'accountCreateSupplementaryGroups'}) {
        foreach my $suppGroup (@{$config->{'accountCreateSupplementaryGroups'}}) {
            $fnret = OVH::Bastion::add_user_to_group(user => $account, group => $suppGroup, groupType => 'osh');
            if ($fnret) {
                osh_info "Account added to group $suppGroup";
            }
            else {
                osh_warn "Couldn't add account $account to group $suppGroup";
            }
        }
    }
}

osh_info "Creating needed files and directories with proper permissions in home...";
my $ttyrecdir = $homedir . "/ttyrec";
mkdir $ttyrecdir;
if (!chown $uid, $uid, $ttyrecdir) {
    HEXIT('ERR_CANNOT_CHOWN', msg => "Couldn't chown ttyrec directory ($!)");
}
if (!chmod 0700, $ttyrecdir) {
    HEXIT('ERR_CANNOT_CHMOD', msg => "Couldn't chmod ttyrec directory ($!)");
}

osh_debug('applying an acl for group ' . $ttygroup);
OVH::Bastion::sys_setfacl(target => $ttyrecdir, clear => 1, perms => "g:$ttygroup:rX")
  or HEXIT('ERR_SETFACL_FAILED', msg => "Error setting ACL on $ttyrecdir");

OVH::Bastion::sys_setfacl(target => $ttyrecdir, default => 1, perms => "g:$ttygroup:rX")
  or HEXIT('ERR_SETFACL_FAILED', msg => "Error setting default ACL on $ttyrecdir");

OVH::Bastion::sys_setfacl(target => $homedir, clear => 1, perms => "g:$ttygroup:x,g:osh-auditor:x")
  or HEXIT('ERR_SETFACL_FAILED', msg => "Error setting ACL on $homedir");

OVH::Bastion::sys_setfacl(target => "$homedir/.ssh", clear => 1, perms => "g:osh-auditor:x")
  or HEXIT('ERR_SETFACL_FAILED', msg => "Error setting ACL on $homedir/.ssh");

osh_info "Creating some more directories...";
mkdir "/home/allowkeeper/$account";
OVH::Bastion::touch_file("/home/allowkeeper/$account/allowed.ip");
OVH::Bastion::touch_file("/home/allowkeeper/$account/allowed.private");

osh_info "Applying proper ownerships...";
$fnret = OVH::Bastion::execute(
    cmd => [
        'chown',                      'allowkeeper:allowkeeper',
        "/home/allowkeeper/$account", "/home/allowkeeper/$account/allowed.ip",
        "/home/allowkeeper/$account/allowed.private"
    ],
    noisy_stderr => 1
);
$fnret->err eq 'OK'
  or HEXIT('ERR_CHMOD_FAILED', msg => "Error while running chmod on allowkeeper (" . $fnret->msg . ")");

$fnret = OVH::Bastion::execute(cmd => ['chmod', '-R', 'o+rX', "/home/allowkeeper/$account"], noisy_stderr => 1);
$fnret->err eq 'OK'
  or HEXIT('ERR_CHMOD_FAILED', msg => "Error while running chmod -R on allowkeeper (" . $fnret->msg . ")");

if (ref $config->{'accountCreateDefaultPersonalAccesses'} eq 'ARRAY' && $type eq 'normal') {
    foreach my $defAccess (@{$config->{'accountCreateDefaultPersonalAccesses'}}) {
        my (undef, $user, $ip, undef, $port) = $defAccess =~ m{^(([^@]+)@)?([0-9./]+)(:(\d+))?$};
        next unless $ip;
        my @command = qw{ sudo -n -u allowkeeper -- };
        push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountModifyPersonalAccess';
        push @command, '--target',  'any';
        push @command, '--action',  'add';
        push @command, '--account', $account;
        push @command, '--ip',      $ip;

        if ($user) {
            push @command, '--user', ($user eq 'ACCOUNT' ? $account : $user);
        }
        $port and push @command, '--port', $port;
        $fnret = OVH::Bastion::execute(cmd => \@command, noisy_stdout => 1, noisy_stderr => 1, is_helper => 1);
        $fnret->err eq 'OK' or osh_warn("Couldn't add private access to account to $defAccess (" . $fnret->msg . ")");
    }
}

# store some metadata
my $creation_time = time();
my %metadata      = (
    by              => $self,
    bastion_version => $OVH::Bastion::VERSION,
    datetime_utc    => POSIX::strftime("%a %Y-%m-%d %H:%M:%S UTC", gmtime($creation_time)),
    datetime_local  => POSIX::strftime("%a %Y-%m-%d %H:%M:%S %Z",  localtime($creation_time)),
    timestamp       => $creation_time,
    comment         => $comment,
);
$fnret = OVH::Bastion::account_config(account => $account, key => "creation_info", value => encode_json(\%metadata));
if (!$fnret) {
    osh_warn("Couldn't set creation_info account metadata, continuing anyway");
    warn_syslog("While creating account '$account', couldn't set creation_info metadata: " . $fnret->msg);
}

# also store the bare creation timestamp by itself, it's used by osh.pl for TTL accounts
$fnret = OVH::Bastion::account_config(account => $account, key => "creation_timestamp", value => time());
if (!$fnret) {
    osh_warn("Couldn't store creation timestamp (" . $fnret->msg . "), continuing anyway");
    warn_syslog("While creating account '$account', couldn't store creation timestamp: " . $fnret->msg);
}

if ($ttl) {
    $fnret = OVH::Bastion::duration2human(seconds => $ttl);
    osh_info sprintf("Setting this account TTL (will expire in %s)", $fnret->value->{'human'});
    $fnret = OVH::Bastion::account_config(account => $account, key => "account_ttl", value => $ttl);
    if (!$fnret) {
        osh_warn("Couldn't store account TTL (" . $fnret->msg . "), this account will NOT expire, continuing anyway");
        warn_syslog("Couldn't store account TTL ("
              . $fnret->msg
              . ") while creating account '$account', this account will NOT expire, continuing anyway");
    }
}

if ($alwaysActive || $type eq 'realm') {
    $fnret = OVH::Bastion::account_config(
        account => $account,
        key     => OVH::Bastion::OPT_ACCOUNT_ALWAYS_ACTIVE,
        value   => "yes",
        public  => 1
    );
    if (!$fnret) {
        osh_warn("Couldn't store always_active flag (" . $fnret->msg . "), continuing anyway");
        warn_syslog("Couldn't store always_active flag ("
              . $fnret->msg
              . ") while creating account '$account', continuing anyway");
    }
}

$fnret = OVH::Bastion::add_user_to_group(
    user        => "keyreader",
    group       => $account,
    accountType => 'group',
    groupType   => 'regular'
);
$fnret or HEXIT($fnret);
osh_debug('user keyreader added to group');

my $finalPrefix = $realmFrom ? sprintf('from="%s"', $realmFrom) : $from;
$finalPrefix .= ' ' if $finalPrefix;

osh_info "Adding provided public key in authorized_keys file...";
if (open(my $fh_keys, '>>', $akfile)) {
    foreach my $key (@vettedKeys) {
        print $fh_keys $finalPrefix . $key . "\n";
    }
    close($fh_keys);
}
else {
    HEXIT("ERR_CANNOT_ADD_KEY", msg => "Couldn't open $akfile when trying to add provided public key");
}

# push this flag to prevent ssh/telnet usage
if ($oshOnly) {
    $fnret =
      OVH::Bastion::account_config(account => $account, key => OVH::Bastion::OPT_ACCOUNT_OSH_ONLY, value => "yes");
    $fnret or HEXIT($fnret);
}

# specific expiration policy. Note that 0 is a valid value (means "never").
if (defined $maxInactiveDays) {
    $fnret = OVH::Bastion::account_config(
        account => $account,
        %{OVH::Bastion::OPT_ACCOUNT_MAX_INACTIVE_DAYS()}, value => $maxInactiveDays
    );
    $fnret or HEXIT($fnret);
}

# chown to root so user can no longer touch it
if ($immutableKey) {
    chown 0, -1, $akfile;
}

osh_info "Generating account personal bastion key...";
$fnret = OVH::Bastion::generate_ssh_key(
    folder => "$homedir/.ssh",
    prefix => 'private',
    name   => $account,
    gid    => $uid,
    uid    => $uid,
    algo   => OVH::Bastion::config('defaultAccountEgressKeyAlgorithm')->value,
    size   => OVH::Bastion::config('defaultAccountEgressKeySize')->value,
);
$fnret or HEXIT($fnret);

osh_info "Account successfully created!";
if ($realmFrom) {
    osh_info "Realm will be able to connect from the following IPs: $realmFrom";
}
elsif (scalar(@$ipList) > 0) {
    osh_info "Account will be able to connect from the following IPs: " . join(', ', @$ipList);
}

# allowed to sudo for the account
osh_info("Configuring sudoers for this account");
$fnret = OVH::Bastion::execute(
    cmd          => [$OVH::Bastion::BASEPATH . '/bin/sudogen/generate-sudoers.sh', 'create', 'account', $account],
    must_succeed => 1,
    noisy_stdout => 1
);
$fnret or HEXIT('ERR_CANNOT_CREATE_SUDOERS', msg => "An error occurred while creating sudoers for this account");

my $bastionName    = $config->{'bastionName'};
my $bastionCommand = $config->{'bastionCommand'};
$bastionCommand =~ s/USER|ACCOUNT/$account/g;
$bastionCommand =~ s/CACHENAME|BASTIONNAME/$bastionName/g;
my $hostname = Sys::Hostname::hostname();
$bastionCommand =~ s/HOSTNAME/$hostname/g;

if ($type eq 'realm') {
    osh_info "Realm has been created.";
}
else {
    osh_info "==> alias $bastionName='$bastionCommand'";
    osh_info("To test his access, ask this user to set the above alias in their .bash_aliases, ",
        "then run `$bastionName --osh info'");
}

OVH::Bastion::syslogFormatted(
    severity => 'info',
    type     => 'account',
    fields   => [
        ['action',        'create'],
        ['account',       $account],
        ['account_uid',   $uid],
        ['public_key',    @vettedKeys ? $vettedKeys[0] : undef],
        ['always_active', ($alwaysActive ? 'true' : 'false')],
        ['uid_auto',      ($uidAuto      ? 'true' : 'false')],
        ['osh_only',      ($oshOnly      ? 'true' : 'false')],
        ['immutable_key', ($immutableKey ? 'true' : 'false')],
        ['comment',       $comment],
    ]
);

HEXIT('OK');
