#!/bin/bash
# duser - manage user accounts on Das OS: create them with the right groups,
# set passwords, lock them, add SSH keys.
set -euo pipefail

# shellcheck source=helpers.sh
. "${DAS_HELPERS:-/usr/lib/das/helpers.sh}"

# Groups of every new user, as the desktop installer gives them; only the ones
# that exist on this system are used
user_groups="users audio video input storage network optical scanner lp kvm plugdev bluetooth"
# Administrators also get sudo, Docker (root-equivalent) and the system logs
admin_groups="wheel docker socklog"

uid_min=$(awk '$1 == "UID_MIN" { print $2 }' /etc/login.defs 2>/dev/null)
uid_max=$(awk '$1 == "UID_MAX" { print $2 }' /etc/login.defs 2>/dev/null)
uid_min=${uid_min:-1000}
uid_max=${uid_max:-60000}

usage() {
  if [[ -n $uk ]]; then
    cat <<'EOF'
Використання: duser КОМАНДА [КОРИСТУВАЧ] [...]

  list                    користувачі: адміністратор, вхід, SSH-ключі, групи
  add [КОРИСТУВАЧ]        створити; чого не вказано, те запитає:
      --admin             адміністратор (sudo, docker, журнали)
      --key КЛЮЧ|ФАЙЛ     публічний SSH-ключ
      --no-password       без пароля, вхід лише за ключем
      --password-stdin    пароль зі стандартного входу (для скриптів)
  del КОРИСТУВАЧ [--home] видалити (--home: разом із домашнім каталогом)
  passwd [КОРИСТУВАЧ]     змінити пароль (без імені — свій)
  lock КОРИСТУВАЧ         заборонити вхід (і за паролем, і за ключем)
  unlock КОРИСТУВАЧ       дозволити вхід знову
  key КОРИСТУВАЧ [list]   SSH-ключі користувача
  key КОРИСТУВАЧ add [КЛЮЧ|ФАЙЛ|-]
                          додати ключ (рядок, файл .pub або stdin)
  key КОРИСТУВАЧ rm НОМЕР видалити ключ за номером зі списку
  groups КОРИСТУВАЧ [+ГРУПА|-ГРУПА...]
                          показати групи, додати (+) чи прибрати (-);
                          +wheel робить адміністратором

Зміни груп діють із наступного входу користувача.
EOF
  else
    cat <<'EOF'
Usage: duser COMMAND [USER] [...]

  list                    users: administrator, login, SSH keys, groups
  add [USER]              create one; asks for what is not given:
      --admin             an administrator (sudo, docker, system logs)
      --key KEY|FILE      a public SSH key
      --no-password       no password, key login only
      --password-stdin    read the password from stdin (for scripts)
  del USER [--home]       delete (--home: with the home directory)
  passwd [USER]           change the password (without a name: your own)
  lock USER               forbid logins (by password and by key)
  unlock USER             allow logins again
  key USER [list]         the user's SSH keys
  key USER add [KEY|FILE|-]
                          add a key (a line, a .pub file or stdin)
  key USER rm NUMBER      remove a key by its number in the list
  groups USER [+GROUP|-GROUP...]
                          show the groups, add (+) or remove (-) some;
                          +wheel makes an administrator

Group changes apply from the user's next login.
EOF
  fi
}

# A regular (human) account, not a system one
check_user() {
  local uid
  uid=$(id -u "$1" 2>/dev/null) || die "немає користувача $1" "no user $1"
  ((uid >= uid_min && uid <= uid_max)) ||
    die "$1 — системний обліковий запис, duser його не змінює" "$1 is a system account, duser leaves it alone"
}

in_group() {
  id -nG "$1" | tr ' ' '\n' | grep -qx "$2"
}

# existing GROUPS...: the given groups that exist, comma-separated
existing() {
  local g out=()
  for g in "$@"; do
    getent group "$g" >/dev/null && out+=("$g")
  done
  local IFS=,
  echo "${out[*]}"
}

# login_state USER: how the user can log in, from /etc/shadow
login_state() {
  local hash expire today
  IFS=: read -r _ hash _ _ _ _ _ expire _ < <(getent shadow "$1")
  today=$(($(date +%s) / 86400))
  if [[ -n $expire ]] && ((expire <= today)); then
    say заблоковано locked
  elif [[ $hash == \$* ]]; then
    say пароль password
  else
    # "!", "*" or a "!"-locked hash: SSH keys still work
    say "лише ключ" "key only"
  fi
}

keys_file() {
  echo "$(getent passwd "$1" | cut -d: -f6)/.ssh/authorized_keys"
}

# The keys of a file, one per line, without comments and blank lines
key_lines() {
  [[ -r $1 ]] || return 0
  grep -v '^[[:space:]]*\(#\|$\)' "$1" || true
}

cmd_list() {
  local name uid home admin keys
  pad "$(say КОРИСТУВАЧ USER)" 16
  pad UID 6
  pad "$(say АДМІН ADMIN)" 6
  pad "$(say ВХІД LOGIN)" 12
  pad "$(say КЛЮЧІ KEYS)" 6
  say ГРУПИ GROUPS
  while IFS=: read -r name _ uid _ _ home _; do
    ((uid >= uid_min && uid <= uid_max)) || continue
    admin=$(say ні no)
    in_group "$name" wheel && admin=$(say так yes)
    keys=$(key_lines "$home/.ssh/authorized_keys" | wc -l)
    pad "$name" 16
    pad "$uid" 6
    pad "$admin" 6
    pad "$(login_state "$name")" 12
    pad "$keys" 6
    id -nG "$name" | sed "s/^$name //; s/ /,/g"
  done < <(getent passwd | sort -t: -k3n)
}

# add_key USER KEY|FILE|-: appends a validated key to the user's authorized_keys
add_key() {
  local user=$1 src=$2 key file tmp group
  if [[ $src == - ]]; then
    key=$(cat)
  elif [[ -f $src ]]; then
    key=$(cat "$src")
  else
    key=$src
  fi
  key=$(printf '%s\n' "$key" | grep -v '^[[:space:]]*$' | head -n 1)
  tmp=$(mktemp)
  printf '%s\n' "$key" >"$tmp"
  if ! ssh-keygen -l -f "$tmp" >/dev/null 2>&1; then
    rm -f "$tmp"
    die "це не публічний SSH-ключ (потрібен рядок на кшталт 'ssh-ed25519 AAAA... ім'я')" \
      "this is not a public SSH key (expected a line like 'ssh-ed25519 AAAA... name')"
  fi
  rm -f "$tmp"

  file=$(keys_file "$user")
  # Same key type and body, whatever the comment
  if key_lines "$file" | awk -v k="$(awk '{ print $1, $2 }' <<<"$key")" '$1 " " $2 == k { found = 1 } END { exit !found }'; then
    say "$user: цей ключ уже є" "$user: this key is already there"
    return 0
  fi
  group=$(id -gn "$user")
  install -d -m 700 -o "$user" -g "$group" "$(dirname "$file")"
  printf '%s\n' "$key" >>"$file"
  chown "$user:$group" "$file"
  chmod 600 "$file"
  say "$user: ключ додано" "$user: key added"
}

# set_password USER: asks on a terminal, reads one line from stdin otherwise
set_password() {
  local password
  if interactive; then
    passwd "$1"
  else
    IFS= read -r password || die "пароль не передано" "no password given"
    [[ -n $password ]] || die "порожній пароль" "empty password"
    # -c: Void's PAM stack for chpasswd does not hash passwords itself
    printf '%s:%s\n' "$1" "$password" | chpasswd -c SHA512
    say "$1: пароль змінено" "$1: password changed"
  fi
}

cmd_add() {
  local name="" admin="" key="" password=ask groups
  while (($#)); do
    case $1 in
      --admin) admin=1 ;;
      --key) key=${2:?}; shift ;;
      --no-password) password=none ;;
      --password-stdin) password=stdin ;;
      -*) die "невідомий параметр $1 (duser help)" "unknown option $1 (duser help)" ;;
      *) [[ -z $name ]] || die "зайвий аргумент $1" "extra argument $1"; name=$1 ;;
    esac
    shift
  done

  if [[ -z $name ]]; then
    interactive || die "вкажіть ім'я користувача" "name the user"
    ask name "Ім'я користувача (латиницею)" "User name"
  fi
  [[ $name =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] ||
    die "ім'я '$name': малі латинські літери, цифри, '_', '-', до 32 символів" \
      "name '$name': lowercase Latin letters, digits, '_', '-', up to 32 characters"
  ! id "$name" >/dev/null 2>&1 || die "користувач $name уже є" "user $name already exists"

  if [[ -z $admin ]] && interactive && [[ $password != stdin ]]; then
    confirm "Адміністратор (sudo, docker, журнали)?" "Administrator (sudo, docker, system logs)?" && admin=1
  fi
  if [[ -z $key ]] && interactive && [[ $password != stdin ]]; then
    ask key "Публічний SSH-ключ: рядок або файл .pub (Enter — без ключа)" \
      "Public SSH key: a line or a .pub file (Enter for none)"
  fi
  [[ $password == ask ]] && ! interactive && password=none

  # shellcheck disable=SC2086 # word lists
  groups=$(existing $user_groups ${admin:+$admin_groups})
  useradd -m -s /bin/bash -G "$groups" "$name"
  say "$name: створено, групи $groups" "$name: created, groups $groups"

  # From here on the account exists: on a failure, say so instead of leaving
  # it half set up silently
  # shellcheck disable=SC2064 # $name is fixed by now
  trap "[[ \$? -eq 0 ]] || warn '$name створено не повністю; виправте через duser або видаліть: duser del $name --home' \
    '$name is only partly set up; fix it with duser or delete it: duser del $name --home'" EXIT
  [[ -n $key ]] && add_key "$name" "$key"
  case $password in
    ask | stdin) set_password "$name" ;;
    none)
      [[ -n $key ]] || warn "$name не має ні пароля, ні ключа і поки не зможе увійти: duser passwd $name" \
        "$name has neither a password nor a key and cannot log in yet: duser passwd $name"
      ;;
  esac
  trap - EXIT
}

# The current user (the one who ran sudo)
me() {
  echo "${SUDO_USER:-$(id -un)}"
}

cmd_del() {
  local name=${1:-} home="" others
  [[ -n $name ]] || die "вкажіть користувача" "name the user"
  [[ ${2:-} == --home ]] && home=1
  check_user "$name"
  [[ $name != "$(me)" ]] || die "не можна видалити себе" "you cannot delete yourself"
  if in_group "$name" wheel; then
    others=$(getent group wheel | cut -d: -f4 | tr ',' '\n' | grep -vx "$name" || true)
    [[ -n $others ]] || die "$name — єдиний адміністратор" "$name is the only administrator"
  fi
  if interactive; then
    confirm "Видалити користувача $name?" "Delete user $name?" || return 1
    if [[ -z $home ]] && confirm "Видалити й домашній каталог $(getent passwd "$name" | cut -d: -f6)?" \
      "Delete the home directory $(getent passwd "$name" | cut -d: -f6) too?"; then
      home=1
    fi
  fi
  # userdel refuses while the user has running processes, which is right.
  # Void creates no mail spools, so drop userdel's warning about it.
  local out
  if ! out=$(userdel ${home:+-r} "$name" 2>&1); then
    echo "$out" >&2
    exit 1
  fi
  grep -v 'mail spool' <<<"$out" >&2 || true
  say "$name: видалено" "$name: deleted"
}

cmd_passwd() {
  local name=${1:-}
  if [[ -z $name || $name == "$(id -un)" ]] && [[ $EUID -ne 0 ]]; then
    # Your own password: no sudo, passwd asks for the current one
    exec passwd
  fi
  need_root passwd "$@"
  name=${name:-$(me)}
  check_user "$name"
  set_password "$name"
}

cmd_lock() {
  local name=${1:-}
  [[ -n $name ]] || die "вкажіть користувача" "name the user"
  check_user "$name"
  [[ $name != "$(me)" ]] || die "не можна заблокувати себе" "you cannot lock yourself out"
  # -L disables the password; an expired account also stops SSH key logins
  usermod -L -e 1 "$name"
  say "$name: вхід заборонено (поточні сеанси тривають; завершити: pkill -KILL -u $name)" \
    "$name: logins forbidden (open sessions go on; to end them: pkill -KILL -u $name)"
}

cmd_unlock() {
  local name=${1:-} hash
  [[ -n $name ]] || die "вкажіть користувача" "name the user"
  check_user "$name"
  # (usermod -e '' complains "no changes" when the account never expired)
  chage -E -1 "$name"
  # Re-enable the password only when there is one under the "!"
  hash=$(getent shadow "$name" | cut -d: -f2)
  [[ $hash == \!\$* ]] && usermod -U "$name"
  say "$name: вхід дозволено" "$name: logins allowed"
}

cmd_key() {
  local name=${1:-} action=${2:-list} file n i line
  [[ -n $name ]] || die "вкажіть користувача" "name the user"
  check_user "$name"
  file=$(keys_file "$name")
  case $action in
    list | ls)
      if [[ -z $(key_lines "$file") ]]; then
        say "$name: SSH-ключів немає" "$name: no SSH keys"
        return 0
      fi
      i=0
      while IFS= read -r line; do
        i=$((i + 1))
        printf '%2d  %s\n' "$i" "$(ssh-keygen -l -f - <<<"$line" 2>/dev/null || echo "? $line")"
      done < <(key_lines "$file")
      ;;
    add)
      local src=${3:-}
      if [[ -z $src ]]; then
        interactive || die "вкажіть ключ, файл або -" "give a key, a file or -"
        ask src "Публічний SSH-ключ: рядок або файл .pub" "Public SSH key: a line or a .pub file"
      fi
      add_key "$name" "$src"
      ;;
    rm | del)
      n=${3:-}
      [[ $n =~ ^[1-9][0-9]*$ ]] || die "вкажіть номер ключа (duser key $name)" "give the key number (duser key $name)"
      line=$(key_lines "$file" | sed -n "${n}p")
      [[ -n $line ]] || die "немає ключа номер $n" "no key number $n"
      # Remove that exact line, keeping comments and the other keys
      awk -v k="$line" '$0 != k' "$file" >"$file.new"
      chown --reference="$file" "$file.new"
      chmod 600 "$file.new"
      mv "$file.new" "$file"
      say "$name: ключ $n видалено" "$name: key $n removed"
      ;;
    *) die "невідома дія $action (list, add, rm)" "unknown action $action (list, add, rm)" ;;
  esac
}

cmd_groups() {
  local name=${1:-} arg g
  [[ -n $name ]] || die "вкажіть користувача" "name the user"
  check_user "$name"
  shift
  for arg in "$@"; do
    g=${arg:1}
    case $arg in
      +?*)
        getent group "$g" >/dev/null || die "немає групи $g" "no group $g"
        gpasswd -a "$name" "$g" >/dev/null
        ;;
      -?*)
        if [[ $g == wheel && $name == "$(me)" ]]; then
          die "не можна позбавити себе прав адміністратора" "you cannot drop your own administrator rights"
        fi
        gpasswd -d "$name" "$g" >/dev/null
        ;;
      *) die "'$arg': потрібно +ГРУПА або -ГРУПА" "'$arg': expected +GROUP or -GROUP" ;;
    esac
  done
  echo "$name: $(id -nG "$name" | sed "s/^$name //; s/ /, /g")"
}

cmd=${1:-list}
[[ $# -gt 0 ]] && shift

case $cmd in
  list | ls)
    # /etc/shadow is root-only
    need_root list
    cmd_list
    ;;
  add | new) need_root add "$@"; cmd_add "$@" ;;
  del | rm | remove) need_root del "$@"; cmd_del "$@" ;;
  passwd | password) cmd_passwd "$@" ;;
  lock) need_root lock "$@"; cmd_lock "$@" ;;
  unlock) need_root unlock "$@"; cmd_unlock "$@" ;;
  key | keys) need_root key "$@"; cmd_key "$@" ;;
  groups | group) need_root groups "$@"; cmd_groups "$@" ;;
  -h | --help | help) usage ;;
  *)
    usage >&2
    exit 1
    ;;
esac
