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
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Das
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+240
View File
@@ -0,0 +1,240 @@
#!/bin/bash
# dfw - open and close ports in the Das OS server firewall (nftables).
#
# /etc/nftables.conf defines the "das" table with the sets tcp_ports and
# udp_ports; dfw keeps their elements in /etc/nftables.d/dfw.nft, which that
# file includes, and reloads only the "das" table. SSH is always open.
set -euo pipefail
# shellcheck source=helpers.sh
. "${DAS_HELPERS:-/usr/lib/das/helpers.sh}"
conf=/etc/nftables.conf
ports_file=/etc/nftables.d/dfw.nft
usage() {
if [[ -n $uk ]]; then
cat <<'EOF'
Використання: dfw КОМАНДА [ПОРТ...]
status стан брандмауера й відкриті порти (типова команда)
allow ПОРТ... відкрити порти для вхідних з'єднань
deny ПОРТ... закрити порти, відкриті через allow
reload перечитати /etc/nftables.conf після редагування
ПОРТ: 80, 80/tcp, 53/udp, 8000-8010/tcp або назва служби (http, https).
Без /udp мається на увазі tcp. SSH відкритий завжди.
Порти, які публікують контейнери Docker (-p 8080:80), відкриває сам Docker,
dfw на них не впливає.
EOF
else
cat <<'EOF'
Usage: dfw COMMAND [PORT...]
status firewall state and open ports (the default)
allow PORT... open ports for incoming connections
deny PORT... close ports opened with allow
reload load /etc/nftables.conf again after editing it
PORT: 80, 80/tcp, 53/udp, 8000-8010/tcp or a service name (http, https).
Without /udp it means tcp. SSH is always open.
Ports that Docker containers publish (-p 8080:80) are opened by Docker
itself, dfw does not affect them.
EOF
fi
}
check_config() {
grep -qs 'set tcp_ports' "$conf" ||
die "$conf не з Das OS Server (немає набору tcp_ports); dfw працює лише з ним" \
"$conf is not the Das OS Server one (no tcp_ports set); dfw only works with that"
}
# Opened ports of one protocol from the ports file, one per line
saved_ports() {
[[ -r $ports_file ]] || return 0
sed -n "s/^add element inet das $1_ports { \(.*\) }$/\1/p" "$ports_file" | tr -d ' ' | tr ',' '\n'
}
# parse_port SPEC: prints "PORT PROTO" with PORT a number or a FIRST-LAST range
parse_port() {
local spec=$1 port proto=tcp first last
if [[ $spec == */* ]]; then
port=${spec%/*}
proto=${spec##*/}
else
port=$spec
fi
[[ $proto == tcp || $proto == udp ]] || die "'$spec': протокол tcp або udp" "'$spec': the protocol is tcp or udp"
if [[ $port =~ ^[a-z][a-z0-9-]*$ ]]; then
# A service name from /etc/services
first=$(getent services "$port/$proto" | awk '{ print $2 }')
[[ -n $first ]] || die "'$spec': невідома служба $port/$proto" "'$spec': unknown service $port/$proto"
port=${first%/*}
fi
if [[ $port =~ ^([0-9]+)-([0-9]+)$ ]]; then
first=${BASH_REMATCH[1]}
last=${BASH_REMATCH[2]}
elif [[ $port =~ ^[0-9]+$ ]]; then
first=$port
last=$port
else
die "'$spec': порт — число, діапазон 8000-8010 або назва служби" \
"'$spec': a port is a number, a range like 8000-8010 or a service name"
fi
((first >= 1 && last <= 65535 && first <= last)) ||
die "'$spec': порти від 1 до 65535" "'$spec': ports are 1 to 65535"
if ((first == last)); then
echo "$first $proto"
else
echo "$first-$last $proto"
fi
}
firewall_on() {
[[ -e /etc/runit/runsvdir/default/nftables ]]
}
# save TCP_PORTS UDP_PORTS (newline-separated): writes the ports file, checks
# the whole configuration and loads it, restoring the old file on an error
save() {
local tcp udp backup=""
# sort -n -u would take 80 and 80-90 for duplicates
tcp=$(sort -u <<<"$1" | sort -n | grep . | paste -sd, - | sed 's/,/, /g' || true)
udp=$(sort -u <<<"$2" | sort -n | grep . | paste -sd, - | sed 's/,/, /g' || true)
mkdir -p "${ports_file%/*}"
if [[ -e $ports_file ]]; then
backup=$(mktemp)
cp "$ports_file" "$backup"
fi
{
echo "# Ports opened with dfw; change them with dfw allow/deny"
[[ -z $tcp ]] || echo "add element inet das tcp_ports { $tcp }"
[[ -z $udp ]] || echo "add element inet das udp_ports { $udp }"
} >"$ports_file"
if ! nft -c -f "$conf"; then
if [[ -n $backup ]]; then mv "$backup" "$ports_file"; else rm -f "$ports_file"; fi
die "nftables не прийняв правила, нічого не змінено" "nftables rejected the rules, nothing changed"
fi
rm -f "$backup"
if firewall_on; then
nft -f "$conf"
else
warn "брандмауер вимкнений, зміни збережено; увімкнути: dsv enable nftables" \
"the firewall is off, changes are saved; to turn it on: dsv enable nftables"
fi
}
cmd_allow() {
local spec port proto tcp udp
tcp=$(saved_ports tcp)
udp=$(saved_ports udp)
for spec in "$@"; do
read -r port proto < <(parse_port "$spec")
if [[ $port == 22 && $proto == tcp ]]; then
say "22/tcp (SSH) відкритий завжди" "22/tcp (SSH) is always open"
continue
fi
if grep -qx "$port" <<<"$(if [[ $proto == tcp ]]; then echo "$tcp"; else echo "$udp"; fi)"; then
say "$port/$proto: уже відкритий" "$port/$proto: already open"
continue
fi
if [[ $proto == tcp ]]; then tcp+=$'\n'$port; else udp+=$'\n'$port; fi
say "$port/$proto: відкрито" "$port/$proto: open"
done
save "$tcp" "$udp"
}
cmd_deny() {
local spec port proto tcp udp list
tcp=$(saved_ports tcp)
udp=$(saved_ports udp)
for spec in "$@"; do
read -r port proto < <(parse_port "$spec")
if [[ $port == 22 && $proto == tcp ]]; then
die "SSH не закривається через dfw, щоб не втратити доступ до сервера; правило — у $conf" \
"SSH is not closed with dfw, so as not to lose access to the server; the rule is in $conf"
fi
if [[ $proto == tcp ]]; then list=$tcp; else list=$udp; fi
if ! grep -qx "$port" <<<"$list"; then
say "$port/$proto: не був відкритий через dfw" "$port/$proto: was not opened with dfw"
continue
fi
list=$(grep -vx "$port" <<<"$list" || true)
if [[ $proto == tcp ]]; then tcp=$list; else udp=$list; fi
say "$port/$proto: закрито" "$port/$proto: closed"
done
save "$tcp" "$udp"
}
cmd_status() {
local p state banned ports
if firewall_on && nft list table inet das >/dev/null 2>&1; then
state=$(say "увімкнений" "on")
else
state=$(say "ВИМКНЕНИЙ (dsv enable nftables)" "OFF (dsv enable nftables)")
fi
say "Брандмауер: $state" "Firewall: $state"
say "Вхідні з'єднання закриті, крім:" "Incoming connections are closed except:"
echo " 22/tcp (ssh)"
for p in tcp udp; do
while read -r ports; do
[[ -n $ports ]] && echo " $ports/$p"
done < <(saved_ports "$p")
done
# Docker opens its published ports itself
if command -v docker >/dev/null && ports=$(docker ps --format '{{.Names}}: {{.Ports}}' 2>/dev/null) &&
grep -q -- '->' <<<"$ports"; then
say "Опубліковані контейнерами Docker (відкриті незалежно від dfw):" \
"Published by Docker containers (open regardless of dfw):"
grep -- '->' <<<"$ports" | sed 's/^/ /'
fi
if banned=$(fail2ban-client status sshd 2>/dev/null | sed -n 's/.*Currently banned:[[:space:]]*//p') &&
[[ -n $banned && $banned != 0 ]]; then
say "fail2ban: заблоковано адрес за невдалі входи SSH: $banned" \
"fail2ban: addresses banned for failed SSH logins: $banned"
fi
}
cmd=${1:-status}
[[ $# -gt 0 ]] && shift
case $cmd in
status | list | ls)
need_root status
check_config
cmd_status
;;
allow | open)
[[ $# -gt 0 ]] || die "вкажіть порт: dfw allow 80/tcp" "give a port: dfw allow 80/tcp"
need_root allow "$@"
check_config
cmd_allow "$@"
;;
deny | close)
[[ $# -gt 0 ]] || die "вкажіть порт: dfw deny 80/tcp" "give a port: dfw deny 80/tcp"
need_root deny "$@"
check_config
cmd_deny "$@"
;;
reload)
need_root reload
nft -c -f "$conf"
if firewall_on; then
nft -f "$conf"
say "правила завантажено" "rules loaded"
else
say "правила правильні, але брандмауер вимкнений: dsv enable nftables" \
"the rules are valid, but the firewall is off: dsv enable nftables"
fi
;;
-h | --help | help) usage ;;
*)
usage >&2
exit 1
;;
esac
@@ -0,0 +1,22 @@
# bash completion for dfw
# shellcheck shell=bash
_dfw() {
local cur=${COMP_WORDS[COMP_CWORD]}
if ((COMP_CWORD == 1)); then
mapfile -t COMPREPLY < <(compgen -W "status allow deny reload help" -- "$cur")
return
fi
case ${COMP_WORDS[1]} in
deny)
# The ports opened with dfw
local ports
ports=$(sed -n 's/^add element inet das \(tcp\|udp\)_ports { \(.*\) }$/\2 \1/p' /etc/nftables.d/dfw.nft 2>/dev/null |
awk '{ for (i = 1; i < NF; i++) { p = $i; sub(/,$/, "", p); print p "/" $NF } }')
mapfile -t COMPREPLY < <(compgen -W "$ports" -- "$cur")
;;
allow)
mapfile -t COMPREPLY < <(compgen -W "http https 80/tcp 443/tcp 53/udp" -- "$cur")
;;
esac
}
complete -F _dfw dfw
+429
View File
@@ -0,0 +1,429 @@
#!/bin/bash
# dsv - manage runit services on Das OS, the way systemctl does elsewhere.
#
# A service is a directory /etc/sv/<name>; it is enabled (started at boot) by
# linking it into the default runlevel, which runsvdir watches through
# /var/service. Logs come from the service's own svlogd directory or, for
# services that log to syslog, from socklog.
set -euo pipefail
# shellcheck source=helpers.sh
. "${DAS_HELPERS:-/usr/lib/das/helpers.sh}"
sv_dir=/etc/sv
runlevel=/etc/runit/runsvdir/default
# Outside a running system (a chroot, an image) /var/service may not exist
service_dir=/var/service
[[ -d $service_dir ]] || service_dir=$runlevel
socklog=/var/log/socklog
usage() {
if [[ -n $uk ]]; then
cat <<'EOF'
Використання: dsv КОМАНДА [СЛУЖБА...]
list усі служби: чи ввімкнена, стан, PID, час роботи
status СЛУЖБА... стан служби (sv status)
enable СЛУЖБА... запускати під час завантаження й запустити зараз
disable СЛУЖБА... не запускати під час завантаження й зупинити
start|stop|restart СЛУЖБА...
запустити, зупинити, перезапустити
logs СЛУЖБА [-f] журнал служби (-f: стежити за новими рядками)
new [СЛУЖБА] [ПАРАМЕТРИ]
створити службу для своєї програми; чого не
вказано, те запитає:
--cmd КОМАНДА що запускати (програма не має йти у фон)
--user КОРИСТУВАЧ від чийого імені
--dir КАТАЛОГ робочий каталог
--env ІМ'Я=ЗНАЧЕННЯ змінна середовища, можна кілька разів
--no-log без власного журналу в /var/log/СЛУЖБА
--enable одразу ввімкнути й запустити
rm СЛУЖБА вимкнути й видалити службу, створену через new
Служби — каталоги в /etc/sv. Без команди виконується list.
EOF
else
cat <<'EOF'
Usage: dsv COMMAND [SERVICE...]
list all services: enabled or not, state, PID, uptime
status SERVICE... service state (sv status)
enable SERVICE... start at boot and start now
disable SERVICE... do not start at boot and stop now
start|stop|restart SERVICE...
start, stop, restart
logs SERVICE [-f] the service log (-f: follow new lines)
new [SERVICE] [OPTIONS] create a service for your own program; asks for
what is not given:
--cmd COMMAND what to run (the program must stay in the foreground)
--user USER who runs it
--dir DIR working directory
--env NAME=VALUE an environment variable, may be repeated
--no-log no log of its own in /var/log/SERVICE
--enable enable and start it right away
rm SERVICE disable and delete a service made with new
Services are the directories in /etc/sv. Without a command, runs list.
EOF
fi
}
check_service() {
[[ -d $sv_dir/$1 ]] || die "немає служби '$1' (див. dsv list)" "no service '$1' (see dsv list)"
}
is_enabled() {
[[ -e $runlevel/$1 || -L $runlevel/$1 ]]
}
# Waits up to 10 s for runsvdir to pick up a new link (it rescans every 5 s)
wait_supervised() {
local i
for ((i = 0; i < 20; i++)); do
[[ -e $service_dir/$1/supervise/ok ]] && return 0
sleep 0.5
done
return 1
}
# row NAME ENABLED STATE PID UPTIME: one line of the list
row() {
pad "$1" 24
pad "$2" 9
pad "$3" 6
pad "$4" 8
printf '%s\n' "$5"
}
cmd_list() {
local dir name enabled state pid time line
row "$(say СЛУЖБА SERVICE)" "$(say УВІМКН. ENABLED)" "$(say СТАН STATE)" PID "$(say ПРАЦЮЄ UPTIME)"
for dir in "$sv_dir"/*/; do
name=$(basename "$dir")
enabled=$(say ні no)
state=-
pid=-
time=-
if is_enabled "$name"; then
enabled=$(say так yes)
# "run: /var/service/sshd: (pid 812) 3600s; run: log: ..."
line=$(sv status "$service_dir/$name" 2>/dev/null || true)
state=${line%%:*}
[[ $line =~ \(pid\ ([0-9]+)\) ]] && pid=${BASH_REMATCH[1]}
[[ $line =~ \ ([0-9]+)s ]] && time=$(human_time "${BASH_REMATCH[1]}")
[[ -n $state ]] || state='?'
fi
row "$name" "$enabled" "$state" "$pid" "$time"
done
}
# human_time SECONDS: 3d4h, 2h5m, 7m, 42s
human_time() {
local s=$1
if ((s >= 86400)); then
echo "$((s / 86400))d$((s % 86400 / 3600))h"
elif ((s >= 3600)); then
echo "$((s / 3600))h$((s % 3600 / 60))m"
elif ((s >= 60)); then
echo "$((s / 60))m"
else
echo "${s}s"
fi
}
cmd_status() {
local name rc=0
for name in "$@"; do
check_service "$name"
if is_enabled "$name"; then
sv status "$service_dir/$name" || rc=1
else
say "$name: вимкнена (dsv enable $name)" "$name: disabled (dsv enable $name)"
fi
done
return $rc
}
cmd_enable() {
local name
for name in "$@"; do
check_service "$name"
if is_enabled "$name"; then
say "$name: уже ввімкнена" "$name: already enabled"
continue
fi
ln -s "$sv_dir/$name" "$runlevel/$name"
say "$name: увімкнена" "$name: enabled"
# runsvdir starts it within 5 s. Right after "dsv disable" its runsv is
# still there with the service stopped, so ask for "up" as well.
if [[ $service_dir == /var/service ]] && wait_supervised "$name"; then
sv -v up "$service_dir/$name" || true
fi
done
}
cmd_disable() {
local name
for name in "$@"; do
check_service "$name"
if ! is_enabled "$name"; then
say "$name: уже вимкнена" "$name: already disabled"
continue
fi
# Stop it first: runsv only notices the removed link at runsvdir's next scan
[[ $service_dir == /var/service ]] && sv down "$service_dir/$name" 2>/dev/null || true
rm "$runlevel/$name"
say "$name: вимкнена" "$name: disabled"
done
}
cmd_control() {
local action=$1 name rc=0
shift
for name in "$@"; do
check_service "$name"
if ! is_enabled "$name"; then
say "$name: вимкнена, спершу: dsv enable $name" "$name: disabled, first: dsv enable $name" >&2
rc=1
continue
fi
# -v waits for the new state (up to 7 s) and prints it
sv -v "$action" "$service_dir/$name" || rc=1
done
return $rc
}
# The log directory of a service whose log/run starts svlogd, if any
svlogd_dir() {
local run=$sv_dir/$1/log/run words w
[[ -r $run ]] || return 1
grep -q svlogd "$run" || return 1
read -ra words < <(grep -m1 svlogd "$run")
for w in "${words[@]}"; do
[[ $w == /* && -d $w ]] && echo "$w" && return 0
done
return 1
}
cmd_logs() {
local name=${1:-} follow="" dir
[[ -n $name ]] || die "вкажіть службу: dsv logs СЛУЖБА [-f]" "name a service: dsv logs SERVICE [-f]"
[[ ${2:-} == -f ]] && follow=1
check_service "$name"
if dir=$(svlogd_dir "$name"); then
if [[ -n $follow ]]; then
exec tail -n 50 -F "$dir/current"
fi
cat "$dir"/@*.[su] "$dir/current" 2>/dev/null | tail -n 200
return
fi
# Services without their own log write to syslog (vlogger -t <name>, or the
# daemon itself as "<name>[pid]:" or "<name>-<helper>[pid]:", like
# sshd-session), which socklog sorts into directories by facility.
if ! compgen -G "$socklog/*/current" >/dev/null; then
die "у $name немає власного журналу, а syslog не записується; увімкніть socklog: dsv enable socklog-unix nanoklogd" \
"$name has no log of its own and syslog is not recorded; enable socklog: dsv enable socklog-unix nanoklogd"
fi
local pattern="[[:space:]]${name}(-[a-z]+)?(\[[0-9]+\])?:"
if [[ -n $follow ]]; then
tail -q -n 0 -F "$socklog"/*/current | grep --line-buffered -E "$pattern"
else
# A message can be in several directories (e.g. "everything" and "secure")
cat "$socklog"/*/@*.[su] "$socklog"/*/current 2>/dev/null | grep -E "$pattern" | sort -u | tail -n 200 ||
say "$name: у syslog немає записів (служба може писати лише в консоль runit)" \
"$name: no syslog entries (the service may only write to the runit console)"
fi
}
# check_running NAME: after a start, tells whether the service stays up or
# keeps restarting (runsv restarts a program that exits, once a second)
check_running() {
local line up='^run:[^;]*\) ([0-9]+)s'
sleep 3
line=$(sv status "$service_dir/$1" 2>/dev/null || true)
if [[ $line =~ $up ]] && ((BASH_REMATCH[1] >= 2)); then
say "$1: працює" "$1: running"
else
warn "$1 не працює стабільно (${line%%;*}); див. dsv logs $1" \
"$1 does not stay up (${line%%;*}); see dsv logs $1"
fi
}
cmd_new() {
local name="" run_cmd="" user="" dir="" log=1 enable="" envs=() e home bin
while (($#)); do
case $1 in
--cmd) run_cmd=${2:?}; shift ;;
--user) user=${2:?}; shift ;;
--dir) dir=${2:?}; shift ;;
--env) envs+=("${2:?}"); shift ;;
--no-log) log="" ;;
--enable) enable=1 ;;
-*) die "невідомий параметр $1 (dsv help)" "unknown option $1 (dsv help)" ;;
*) [[ -z $name ]] || die "зайвий аргумент $1" "extra argument $1"; name=$1 ;;
esac
shift
done
if [[ -z $name ]]; then
interactive || die "вкажіть назву служби" "name the service"
ask name "Назва служби" "Service name"
fi
[[ $name =~ ^[A-Za-z0-9][A-Za-z0-9_.@-]*$ ]] ||
die "назва '$name': лише латинські літери, цифри, '.', '_', '-', '@'" \
"name '$name': only Latin letters, digits, '.', '_', '-', '@'"
[[ ! -e $sv_dir/$name ]] || die "служба $name уже є" "service $name already exists"
if [[ -z $run_cmd ]]; then
interactive || die "вкажіть команду: --cmd" "give the command: --cmd"
say "Команда запуску. Програма має працювати на передньому плані (без -d/--daemon)." \
"The command to run. The program must stay in the foreground (no -d/--daemon)."
ask run_cmd "Команда" "Command"
[[ -n $run_cmd ]] || die "команду не вказано" "no command given"
fi
read -r bin _ <<<"$run_cmd"
if [[ $bin == */* ]]; then
[[ -x $bin ]] || die "$bin не існує або не виконуваний" "$bin does not exist or is not executable"
elif ! command -v "$bin" >/dev/null; then
die "команду $bin не знайдено; вкажіть повний шлях" "command $bin not found; give the full path"
fi
if [[ -z $user ]] && interactive; then
ask user "Від імені користувача" "Run as user" "${SUDO_USER:-root}"
fi
user=${user:-root}
id -u "$user" >/dev/null 2>&1 || die "немає користувача $user" "no user $user"
home=$(getent passwd "$user" | cut -d: -f6)
if [[ -z $dir ]] && interactive; then
ask dir "Робочий каталог" "Working directory" "$home"
fi
dir=${dir:-$home}
[[ -d $dir ]] || die "немає каталогу $dir" "no directory $dir"
if ((${#envs[@]} == 0)) && interactive; then
ask e "Змінні середовища ІМ'Я=ЗНАЧЕННЯ через пробіл (Enter — без них)" \
"Environment variables NAME=VALUE separated by spaces (Enter for none)"
read -ra envs <<<"$e"
fi
for e in "${envs[@]}"; do
[[ $e =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || die "'$e' не має вигляду ІМ'Я=ЗНАЧЕННЯ" "'$e' is not NAME=VALUE"
done
# Build it aside, then move it in place in one step
local tmp=$sv_dir/.$name.new
rm -rf "$tmp"
mkdir -p "$tmp/env"
# chpst does not set these for the new user
printf '%s\n' "$home" >"$tmp/env/HOME"
printf '%s\n' "$user" >"$tmp/env/USER"
for e in "${envs[@]}"; do
printf '%s\n' "${e#*=}" >"$tmp/env/${e%%=*}"
done
cat >"$tmp/run" <<EOF
#!/bin/sh
# Created by dsv new. After editing: dsv restart $name
# Environment variables: one file per variable in $sv_dir/$name/env
exec 2>&1
cd '$dir' || exit 1
exec chpst -u $user -e $sv_dir/$name/env $run_cmd
EOF
chmod 755 "$tmp/run"
if [[ -n $log ]]; then
mkdir -p "$tmp/log" "/var/log/$name"
cat >"$tmp/log/run" <<EOF
#!/bin/sh
# Log of $name, read with: dsv logs $name
mkdir -p /var/log/$name
exec svlogd -tt /var/log/$name
EOF
chmod 755 "$tmp/log/run"
fi
mv "$tmp" "$sv_dir/$name"
say "$name: створена в $sv_dir/$name" "$name: created in $sv_dir/$name"
if [[ -z $enable ]] && interactive; then
confirm "Увімкнути й запустити зараз?" "Enable and start it now?" && enable=1
fi
if [[ -n $enable ]]; then
cmd_enable "$name"
[[ $service_dir == /var/service ]] && check_running "$name"
else
say "Запустити: dsv enable $name" "To start it: dsv enable $name"
fi
}
cmd_rm() {
local name=${1:-} i
[[ -n $name ]] || die "вкажіть службу" "name a service"
check_service "$name"
# Services from packages come back with the next update; disable them instead
# (xbps-query -o succeeds even when no package owns the file)
if [[ -n $(xbps-query -o "$sv_dir/$name/run" 2>/dev/null) ]]; then
die "$name встановлена пакетом, її можна лише вимкнути: dsv disable $name" \
"$name comes from a package, it can only be disabled: dsv disable $name"
fi
if interactive && ! confirm "Видалити службу $name?" "Delete service $name?"; then
return 1
fi
if is_enabled "$name"; then
cmd_disable "$name"
# runsvdir stops its runsv at the next scan; wait for it before deleting
for ((i = 0; i < 20; i++)); do
pgrep -x -f "runsv $name" >/dev/null || break
sleep 0.5
done
fi
rm -rf "${sv_dir:?}/$name"
say "$name: видалена" "$name: deleted"
[[ -d /var/log/$name ]] && say "Журнал залишився в /var/log/$name" "The log is kept in /var/log/$name"
return 0
}
cmd=${1:-list}
[[ $# -gt 0 ]] && shift
case $cmd in
list | ls)
need_root list "$@"
cmd_list
;;
status)
[[ $# -gt 0 ]] || die "вкажіть службу" "name a service"
need_root status "$@"
cmd_status "$@"
;;
enable | disable | start | stop | restart)
[[ $# -gt 0 ]] || die "вкажіть службу" "name a service"
need_root "$cmd" "$@"
case $cmd in
enable) cmd_enable "$@" ;;
disable) cmd_disable "$@" ;;
start) cmd_control up "$@" ;;
stop) cmd_control down "$@" ;;
restart) cmd_control restart "$@" ;;
esac
;;
logs | log)
# socklog's logs are readable by root and the socklog group only
[[ -r $socklog/messages/current ]] || need_root logs "$@"
cmd_logs "$@"
;;
new | create)
need_root new "$@"
cmd_new "$@"
;;
rm | remove | delete)
need_root rm "$@"
cmd_rm "$@"
;;
-h | --help | help)
usage
;;
*)
usage >&2
exit 1
;;
esac
@@ -0,0 +1,41 @@
# bash completion for dsv
# shellcheck shell=bash
_dsv() {
local cur=${COMP_WORDS[COMP_CWORD]} cmd=${COMP_WORDS[1]:-}
if ((COMP_CWORD == 1)); then
mapfile -t COMPREPLY < <(compgen -W "list status enable disable start stop restart logs new rm help" -- "$cur")
return
fi
case $cmd in
enable)
# services that are not enabled yet
local all=() s
for s in /etc/sv/*/; do
s=${s%/}
s=${s##*/}
[[ -e /etc/runit/runsvdir/default/$s ]] || all+=("$s")
done
mapfile -t COMPREPLY < <(compgen -W "${all[*]}" -- "$cur")
;;
disable | start | stop | restart)
mapfile -t COMPREPLY < <(compgen -W "$(ls /etc/runit/runsvdir/default 2>/dev/null)" -- "$cur")
;;
new)
case ${COMP_WORDS[COMP_CWORD - 1]} in
--user) mapfile -t COMPREPLY < <(compgen -u -- "$cur") ;;
--dir) mapfile -t COMPREPLY < <(compgen -d -- "$cur") ;;
--cmd) mapfile -t COMPREPLY < <(compgen -c -- "$cur") ;;
--env) ;;
*) mapfile -t COMPREPLY < <(compgen -W "--cmd --user --dir --env --no-log --enable" -- "$cur") ;;
esac
;;
status | logs | log | rm)
if [[ $cmd == logs* ]] && ((COMP_CWORD == 3)); then
mapfile -t COMPREPLY < <(compgen -W "-f" -- "$cur")
else
mapfile -t COMPREPLY < <(compgen -W "$(ls /etc/sv 2>/dev/null)" -- "$cur")
fi
;;
esac
}
complete -F _dsv dsv
+247
View File
@@ -0,0 +1,247 @@
#!/bin/bash
# dsys - system settings on Das OS: host name, time zone, language and
# console keyboard, in place of hostnamectl, timedatectl and localectl.
set -euo pipefail
# shellcheck source=helpers.sh
. "${DAS_HELPERS:-/usr/lib/das/helpers.sh}"
zoneinfo=/usr/share/zoneinfo
libc_locales=/etc/default/libc-locales
keymaps=/usr/share/kbd/keymaps
usage() {
if [[ -n $uk ]]; then
cat <<'EOF'
Використання: dsys [КОМАНДА] [ЗНАЧЕННЯ]
info огляд системи (типова команда)
hostname [ІМ'Я] показати або змінити ім'я хоста
timezone [ЗОНА] показати або змінити часовий пояс (Europe/Kyiv,
достатньо й частини назви: Kyiv)
timezone list [ФІЛЬТР] усі часові пояси
locale [МОВА] показати або змінити мову системи (uk_UA, en_US)
locale list [ФІЛЬТР] доступні мови; * — уже зібрані
keymap [РОЗКЛАДКА] показати або змінити розкладку консолі (us, ua-utf)
keymap list [ФІЛЬТР] доступні розкладки консолі
Мову й розкладку графічного сеансу кожен користувач задає в налаштуваннях
Xfce і в das-welcome; dsys змінює типові для системи й консолі.
EOF
else
cat <<'EOF'
Usage: dsys [COMMAND] [VALUE]
info system overview (the default)
hostname [NAME] show or change the host name
timezone [ZONE] show or change the time zone (Europe/Kyiv,
part of the name will do: Kyiv)
timezone list [FILTER] all time zones
locale [LANGUAGE] show or change the system language (uk_UA, en_US)
locale list [FILTER] available languages; * marks the built ones
keymap [MAP] show or change the console keyboard map (us, ua-utf)
keymap list [FILTER] available console keyboard maps
In the graphical session every user picks the language and keyboard in the
Xfce settings and in das-welcome; dsys changes the system and console defaults.
EOF
fi
}
current_hostname() {
cat /etc/hostname 2>/dev/null || hostname
}
current_timezone() {
local tz
tz=$(readlink /etc/localtime 2>/dev/null || true)
echo "${tz#*zoneinfo/}"
}
current_locale() {
sed -n 's/^LANG=//p' /etc/locale.conf 2>/dev/null | tr -d '"'
}
rc_value() {
sed -n "s/^$1=//p" /etc/rc.conf 2>/dev/null | tr -d '"' | tail -n 1
}
# A graphical session keeps its X authority under the old host name
session_warning() {
if pgrep -x lightdm >/dev/null || [[ -n ${DISPLAY:-} ]]; then
say "Перезавантажте комп'ютер: графічний сеанс не відкриватиме нових вікон зі старим іменем." \
"Reboot: the graphical session will not open new windows under the old name."
fi
}
# 3 д 4 год 5 хв / 3 d 4 h 5 min
uptime_text() {
local s d h m
read -r s _ </proc/uptime
s=${s%.*}
d=$((s / 86400))
h=$((s % 86400 / 3600))
m=$((s % 3600 / 60))
if ((d > 0)); then
say "$d д $h год $m хв" "$d d $h h $m min"
elif ((h > 0)); then
say "$h год $m хв" "$h h $m min"
else
say "$m хв" "$m min"
fi
}
cmd_info() {
local ntp line
pad "$(say "Ім'я хоста:" "Host name:")" 16; current_hostname
pad "$(say "Система:" "System:")" 16; echo "$(uname -sr), $(uname -m)"
pad "$(say "Працює:" "Up:")" 16; uptime_text
pad "$(say "Час:" "Time:")" 16; date '+%Y-%m-%d %H:%M:%S %Z'
pad "$(say "Часовий пояс:" "Time zone:")" 16; current_timezone
if ntp=$(chronyc -n tracking 2>/dev/null); then
if grep -q '^Leap status *: Normal' <<<"$ntp"; then
ntp=$(say "синхронізовано (chrony)" "synchronized (chrony)")
else
ntp=$(say "не синхронізовано (chrony)" "not synchronized (chrony)")
fi
else
ntp=$(say "не працює (dsv enable chronyd)" "not running (dsv enable chronyd)")
fi
pad "$(say "Синхронізація:" "Time sync:")" 16; echo "$ntp"
pad "$(say "Мова:" "Language:")" 16; current_locale
pad "$(say "Консоль:" "Console:")" 16
echo "$(say розкладка keymap) $(rc_value KEYMAP || true), $(say шрифт font) $(rc_value FONT || true)"
say "Мережа:" "Network:"
ip -brief address 2>/dev/null | while read -r line; do
[[ $line == lo\ * ]] || echo " $line"
done
}
cmd_hostname() {
local new=${1:-} old
if [[ -z $new ]]; then
current_hostname
return
fi
[[ $new =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$ ]] ||
die "'$new': малі латинські літери, цифри й дефіс (не на початку й не в кінці)" \
"'$new': lowercase Latin letters, digits and hyphens (not first or last)"
need_root hostname "$new"
old=$(current_hostname)
echo "$new" >/etc/hostname
# The installers add "127.0.1.1 <name>"; keep the line in step
if grep -qw -- "$old" /etc/hosts; then
sed -i "/^[^#]/s/\([[:space:]]\)${old//./\\.}\([[:space:]]\|$\)/\1$new\2/g" /etc/hosts
fi
grep -qw -- "$new" /etc/hosts || printf '127.0.1.1\t%s\n' "$new" >>/etc/hosts
hostname "$new"
say "Ім'я хоста: $new" "Host name: $new"
session_warning
}
# All time zones, from tzdata's table plus UTC
timezones() {
{ awk '!/^#/ { print $3 }' "$zoneinfo/zone1970.tab"; echo UTC; } | sort -u
}
cmd_timezone() {
local zone=${1:-} matches line
if [[ -z $zone ]]; then
current_timezone
return
fi
if [[ $zone == list ]]; then
timezones | grep -i -- "${2:-}" || true
return
fi
if ! timezones | grep -qx -- "$zone"; then
# Kyiv -> Europe/Kyiv, when only one zone matches
matches=$(timezones | grep -i -- "$zone" || true)
if [[ $(grep -c . <<<"$matches") -eq 1 ]]; then
zone=$matches
elif [[ -n $matches ]]; then
say "Кілька часових поясів підходять, уточніть:" "Several time zones match, be more specific:" >&2
head -n 20 <<<"$matches" | while read -r line; do echo " $line"; done >&2
(($(grep -c . <<<"$matches") <= 20)) || echo " ..." >&2
exit 1
else
die "невідомий часовий пояс '$zone' (dsys timezone list)" "unknown time zone '$zone' (dsys timezone list)"
fi
fi
need_root timezone "$zone"
ln -sfn "$zoneinfo/$zone" /etc/localtime
say "Часовий пояс: $zone, зараз $(date '+%H:%M %Z')" "Time zone: $zone, now $(date '+%H:%M %Z')"
}
# Every UTF-8 locale glibc can build, with * for the enabled ones
locales() {
sed -n 's/^\(#\?\)\([^ ]*\.UTF-8\) UTF-8[[:space:]]*$/\2 \1/p' "$libc_locales" |
awk '{ print ($2 == "#" ? " " : "* ") $1 }'
}
cmd_locale() {
local lang=${1:-} re
if [[ -z $lang ]]; then
current_locale
return
fi
if [[ $lang == list ]]; then
locales | grep -i -- "${2:-}" || true
return
fi
[[ $lang == *.* ]] || lang+=.UTF-8
re=${lang//./\\.}
locales | grep -q "^. $re\$" ||
die "невідома мова '$lang' (dsys locale list)" "unknown language '$lang' (dsys locale list)"
need_root locale "$lang"
if grep -q "^#$re " "$libc_locales"; then
sed -i "s/^#\($re \)/\1/" "$libc_locales"
say "Збираю $lang..." "Building $lang..."
xbps-reconfigure -f glibc-locales >/dev/null
fi
# Keep the other settings, Void's default file also has LC_COLLATE=C
if grep -q '^LANG=' /etc/locale.conf 2>/dev/null; then
sed -i "s/^LANG=.*/LANG=$lang/" /etc/locale.conf
else
echo "LANG=$lang" >>/etc/locale.conf
fi
say "Мова системи: $lang (діє з наступного входу)" "System language: $lang (from the next login)"
}
cmd_keymap() {
local map=${1:-}
if [[ -z $map ]]; then
rc_value KEYMAP
return
fi
if [[ $map == list ]]; then
find "$keymaps" -name '*.map.gz' -printf '%f\n' | sed 's/\.map\.gz$//' | sort -u | grep -i -- "${2:-}" || true
return
fi
[[ -n $(find "$keymaps" -name "$map.map.gz" -print -quit) ]] ||
die "невідома розкладка '$map' (dsys keymap list)" "unknown keyboard map '$map' (dsys keymap list)"
need_root keymap "$map"
set_rc KEYMAP "$map"
# The default console font has no Cyrillic letters
if [[ $map == @(ua|ru|by|bg|mk|sr)* && -z $(rc_value FONT) ]]; then
set_rc FONT cyr-sun16
fi
loadkeys "$map" >/dev/null 2>&1 || true
say "Розкладка консолі: $map" "Console keyboard map: $map"
}
cmd=${1:-info}
[[ $# -gt 0 ]] && shift
case $cmd in
info | status) cmd_info ;;
hostname | host) cmd_hostname "$@" ;;
timezone | tz) cmd_timezone "$@" ;;
locale | lang) cmd_locale "$@" ;;
keymap | keyboard) cmd_keymap "$@" ;;
-h | --help | help) usage ;;
*)
usage >&2
exit 1
;;
esac
@@ -0,0 +1,22 @@
# bash completion for dsys
# shellcheck shell=bash
_dsys() {
local cur=${COMP_WORDS[COMP_CWORD]}
if ((COMP_CWORD == 1)); then
mapfile -t COMPREPLY < <(compgen -W "info hostname timezone locale keymap help" -- "$cur")
return
fi
((COMP_CWORD == 2)) || return
case ${COMP_WORDS[1]} in
timezone | tz)
mapfile -t COMPREPLY < <(compgen -W "list UTC $(awk '!/^#/ { print $3 }' /usr/share/zoneinfo/zone1970.tab 2>/dev/null)" -- "$cur")
;;
locale | lang)
mapfile -t COMPREPLY < <(compgen -W "list $(sed -n 's/^#\?\([^ ]*\)\.UTF-8 UTF-8[[:space:]]*$/\1/p' /etc/default/libc-locales 2>/dev/null)" -- "$cur")
;;
keymap | keyboard)
mapfile -t COMPREPLY < <(compgen -W "list $(find /usr/share/kbd/keymaps -name '*.map.gz' -printf '%f\n' 2>/dev/null | sed 's/\.map\.gz$//')" -- "$cur")
;;
esac
}
complete -F _dsys dsys
+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
@@ -0,0 +1,42 @@
# bash completion for duser
# shellcheck shell=bash
_duser_users() {
local min max
min=$(awk '$1 == "UID_MIN" { print $2 }' /etc/login.defs 2>/dev/null)
max=$(awk '$1 == "UID_MAX" { print $2 }' /etc/login.defs 2>/dev/null)
getent passwd | awk -F: -v min="${min:-1000}" -v max="${max:-60000}" '$3 >= min && $3 <= max { print $1 }'
}
_duser() {
local cur=${COMP_WORDS[COMP_CWORD]} cmd=${COMP_WORDS[1]:-}
if ((COMP_CWORD == 1)); then
mapfile -t COMPREPLY < <(compgen -W "list add del passwd lock unlock key groups help" -- "$cur")
return
fi
case $cmd in
add)
if [[ ${COMP_WORDS[COMP_CWORD - 1]} == --key ]]; then
mapfile -t COMPREPLY < <(compgen -f -- "$cur")
else
mapfile -t COMPREPLY < <(compgen -W "--admin --key --no-password --password-stdin" -- "$cur")
fi
;;
del | passwd | lock | unlock | key | groups)
if ((COMP_CWORD == 2)); then
mapfile -t COMPREPLY < <(compgen -W "$(_duser_users)" -- "$cur")
elif [[ $cmd == del ]]; then
mapfile -t COMPREPLY < <(compgen -W "--home" -- "$cur")
elif [[ $cmd == key ]] && ((COMP_CWORD == 3)); then
mapfile -t COMPREPLY < <(compgen -W "list add rm" -- "$cur")
elif [[ $cmd == key && ${COMP_WORDS[3]} == add ]]; then
mapfile -t COMPREPLY < <(compgen -f -- "$cur")
elif [[ $cmd == groups ]]; then
# +GROUP and -GROUP
local sign=${cur:0:1}
[[ $sign == [+-] ]] || sign=+
mapfile -t COMPREPLY < <(compgen -P "$sign" -W "$(getent group | cut -d: -f1)" -- "${cur#[+-]}")
fi
;;
esac
}
complete -F _duser duser
+75
View File
@@ -0,0 +1,75 @@
# Shared functions of the Das OS helpers (dsv, duser, dfw, dsys).
# Sourced, not run: each helper sets "set -euo pipefail" itself.
# shellcheck shell=bash
self=${0##*/}
if [[ ${LC_ALL:-} == uk* || ${LC_MESSAGES:-} == uk* || ${LANG:-} == uk* ]]; then
uk=1
else
uk=""
fi
# say UKRAINIAN ENGLISH: prints the message in the user's language
say() {
if [[ -n $uk ]]; then echo "$1"; else echo "$2"; fi
}
warn() {
say "$self: $1" "$self: $2" >&2
}
die() {
warn "$1" "$2"
exit 1
}
# need_root ARGS...: runs the helper again through sudo with the same arguments
need_root() {
if [[ $EUID -ne 0 ]]; then
exec sudo "$0" "$@"
fi
}
# Questions are only asked on a terminal; scripts must pass every argument
interactive() {
[[ -t 0 ]]
}
# ask VAR UKRAINIAN ENGLISH [DEFAULT]: reads a line into VAR; the prompt shows
# the default, which an empty answer keeps
ask() {
local -n _answer=$1
local prompt
prompt=$(say "$2" "$3")
if [[ -n ${4:-} ]]; then
prompt+=" [$4]"
fi
read -r -e -p "$prompt: " _answer || exit 1
[[ -n $_answer ]] || _answer=${4:-}
}
# confirm UKRAINIAN ENGLISH: a yes/no question, "no" by default
confirm() {
local reply
read -r -p "$(say "$1 [т/Н]" "$2 [y/N]") " reply || return 1
[[ ${reply,,} == @(y|yes|т|так) ]]
}
# pad TEXT WIDTH: TEXT padded with spaces to WIDTH characters. printf pads by
# bytes, ${#s} counts characters, so Cyrillic columns stay aligned.
pad() {
local width=$(($2 - ${#1}))
((width > 0)) || width=0
printf '%s%*s ' "$1" "$width" ''
}
# set_rc VAR VALUE: sets VAR in /etc/rc.conf, uncommenting Void's example line
set_rc() {
local rc=${RC_CONF:-/etc/rc.conf}
if grep -q "^#\?$1=" "$rc"; then
sed -i "s|^#\?$1=.*|$1=\"$2\"|" "$rc"
else
echo "$1=\"$2\"" >>"$rc"
fi
}