Stage 7: command-line helpers dsv, duser, dfw, dsys (0.5.0)

- das-helpers replaces the dsv package and adds shared functions in
  /usr/lib/das/helpers.sh (bilingual messages, sudo re-exec, prompts).
- dsv new creates a runit service (command, user, directory, environment,
  svlogd log) and checks it stays up; dsv rm deletes one not from a package.
- duser: list, add (installer groups, --admin, key, password), del, passwd,
  lock/unlock (key logins too), key add|list|rm, groups +/-.
- dfw: allow/deny/status/reload for the tcp_ports and udp_ports sets of the
  inet das table, kept in /etc/nftables.d/dfw.nft and checked with nft -c.
- dsys: info, hostname, timezone, locale, keymap.
- xtools-minimal on both images.
- nftables.conf no longer suggests restarting the nftables service: its
  finish script flushes the whole ruleset, fail2ban's and Docker's included.
- das-server-install 0.2.0 mentions the helpers when it finishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
das
2026-10-20 06:58:25 +03:00
co-authored by Claude Opus 5.5
parent 84768962fc
commit acb7532a4f
21 changed files with 1342 additions and 99 deletions
+396
View File
@@ -0,0 +1,396 @@
#!/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