diff --git a/README.md b/README.md index 180c47c..f1712ac 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ## Das OS ### _A light, friendly Void Linux–based desktop with Xfce and a Ukrainian touch, plus a console server edition_ -[![Void Linux](https://img.shields.io/badge/Void_Linux-x86__64_glibc-green)](https://voidlinux.org) [![Xfce](https://img.shields.io/badge/Xfce-4.20-orange)](https://xfce.org) [![runit](https://img.shields.io/badge/init-runit-blue)](https://docs.voidlinux.org/config/services/) [![Go](https://img.shields.io/badge/Go-1.24+-blue)](https://go.dev) [![Docker](https://img.shields.io/badge/Docker-29-blue)](https://www.docker.com) [![Version](https://img.shields.io/badge/version-0.4.0-lightgrey)](#) +[![Void Linux](https://img.shields.io/badge/Void_Linux-x86__64_glibc-green)](https://voidlinux.org) [![Xfce](https://img.shields.io/badge/Xfce-4.20-orange)](https://xfce.org) [![runit](https://img.shields.io/badge/init-runit-blue)](https://docs.voidlinux.org/config/services/) [![Go](https://img.shields.io/badge/Go-1.24+-blue)](https://go.dev) [![Docker](https://img.shields.io/badge/Docker-29-blue)](https://www.docker.com) [![Version](https://img.shields.io/badge/version-0.5.0-lightgrey)](#) 🇺🇦 [Українська версія](README_ua.md) @@ -15,7 +15,7 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow - [System update](#Update) with a snapshot taken before it: a window, a tray icon and a terminal mode - [Welcome window](#Welcome) at the first login: language, home folders, updates and help - [Server image](#Server): a console live ISO with a text installer, SSH, firewall, fail2ban and Docker -- [Service manager](#Services) `dsv`: enable, start and read the logs of runit services, like systemctl +- [Command-line helpers](#Helpers) for everyday admin tasks: services (`dsv`), users (`duser`), firewall (`dfw`) and system settings (`dsys`) - [Das OS look](#Look): light and dark themes, generated window frames, wallpapers and logo - [Desktop defaults](#Defaults) for Xfce that don't overwrite stock files - [Own package repository](#Repository) built by xbps-src from in-tree templates @@ -129,17 +129,19 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow - It uses the same GPT and Btrfs layout as the desktop installer, so snapshots, the *Das OS snapshots* boot menu and `snapper-rollback` work the same way. GRUB is installed for UEFI (also at the fallback path) or BIOS, on the serial console too when the installer was started there. - The administrator gets sudo through `wheel` and is in the `docker` and `socklog` groups. root has no password. - The installed system runs `sshd`, `chronyd`, `nftables`, `fail2ban`, `docker`, `socklog` and the chosen network service. -- nftables lets in only SSH, ICMP and DHCPv6 (`/etc/nftables.conf`). The rules live in their own table `inet das`, so reloading them keeps the Docker and fail2ban rules. Ports that containers publish pass through Docker's own rules. +- nftables lets in only SSH, ICMP and DHCPv6 (`/etc/nftables.conf`), more ports are opened with `dfw`. The rules live in their own table `inet das`, so reloading them keeps the Docker and fail2ban rules. Ports that containers publish pass through Docker's own rules. - fail2ban bans addresses that keep failing SSH logins, reading the socklog log. sshd doesn't accept root password logins. -- Tools: Docker with Compose, lazydocker, lazygit, Midnight Commander, tmux, htop, Python 3, curl, `das-update --cli` and `dsv`. There are no graphical libraries. +- Tools: Docker with Compose, lazydocker, lazygit, Midnight Commander, tmux, htop, Python 3, curl, `das-update --cli`, the [helpers](#Helpers) and xtools (`xi`, `xq`, `xrs`). There are no graphical libraries, and no firmware for NVIDIA graphics or sound cards (`/etc/xbps.d/das-server.conf` keeps updates from bringing it back). -#### Service manager -**_`dsv` manages runit services the way systemctl does on systemd distributions._** -- `dsv list` shows every service in `/etc/sv`: whether it is enabled, its state, PID and uptime. -- `dsv enable NAME` starts a service at boot (a link in the default runlevel) and starts it now. `dsv disable NAME` does the opposite. -- `dsv start|stop|restart|status NAME` wrap `sv` and print the new state. -- `dsv logs NAME [-f]` shows the service's own svlogd log or, for services that log to syslog, their lines collected by socklog. -- It runs itself through sudo when it needs root and has bash completion. It is on the desktop too. +#### Command-line helpers +**_Short commands for tasks that systemd distributions handle with systemctl, hostnamectl and the like, and that Void leaves to manual editing._** +- They answer in Ukrainian or English, run themselves again through sudo when they need root and have bash completion. In a terminal they ask for whatever is missing, and with all arguments given they work in scripts. +- `dsv` manages runit services: `list` (enabled or not, state, PID, uptime), `enable` and `disable` (at boot and right now), `start`, `stop`, `restart`, `status` and `logs [-f]` (the service's own svlogd log or its lines in socklog). +- `dsv new` creates a service for your own program: the command, user, working directory and environment variables go to `/etc/sv/NAME`, with a log in `/var/log/NAME`. It then starts the service and checks that it stays up. `dsv rm` deletes such a service and refuses to touch services from packages. +- `duser` manages accounts: `add` with the same groups as the desktop installer (`--admin` adds `wheel`, `docker` and `socklog`), a password and an SSH key; `passwd`, `lock` and `unlock` (a locked account can't log in with a key either), `key add|list|rm` for `authorized_keys`, `groups NAME +GROUP -GROUP` and `del`. It won't delete or lock out the current user, and it won't delete the only administrator. +- `dfw` opens ports in the server firewall: `dfw allow 80/tcp`, `dfw allow https 8000-8010/tcp`, `dfw deny 80/tcp`. The ports are kept in `/etc/nftables.d/dfw.nft` and checked with `nft -c` before loading, and only the `inet das` table is reloaded. `dfw status` also lists the ports Docker containers publish and the addresses fail2ban has banned. SSH always stays open. +- `dsys` replaces hostnamectl, timedatectl and localectl: `info` (time sync, language, console, addresses), `hostname`, `timezone` (part of the name will do: `dsys timezone Kyiv`), `locale` (builds the glibc locale if needed) and `keymap` (with a Cyrillic console font for Cyrillic layouts). +- They come in the `das-helpers` package and are on the desktop too. Both images also include `xtools-minimal` from Void for package work: `xi` installs, `xrs` searches, `xq` shows package details and `xcheckrestart` finds programs to restart after an update. #### Das OS look **_A modern minimal style with a blue and yellow eight-petal star from Ukrainian embroidery._** @@ -163,7 +165,7 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow - `srcpkgs/das-tools` contains `das-welcome` and the update window and tray icon (Go, GTK 3 through gotk3) with their menu and autostart entries. Its subpackage `das-update` is the update command and polkit action, without GTK. - `srcpkgs/das-snapshots` contains the snapper configuration, `das-snapper-setup` and the dracut module for booting snapshots. - `srcpkgs/das-installer` contains the Calamares settings and branding, `srcpkgs/das-server-installer` the console installer. The steps both share (`mount-source`, `postinstall`) are in `srcpkgs/das-install-common`. They exist only on the live ISOs. -- `srcpkgs/das-server` is the server metapackage with the nftables, fail2ban, sshd and Docker defaults. `srcpkgs/dsv` contains the service manager. +- `srcpkgs/das-server` is the server metapackage with the nftables, fail2ban, sshd and Docker defaults. `srcpkgs/das-helpers` contains `dsv`, `duser`, `dfw` and `dsys`. - `srcpkgs/calamares`, `srcpkgs/adw-gtk3` and `srcpkgs/font-jetbrains-mono` package upstream releases that Void does not have. - `make packages` lays the templates over a shallow clone of void-packages and builds them with `xbps-src` into `out/hostdir/binpkgs`. The ISO build installs them from there. @@ -185,6 +187,6 @@ make clean # remove build output, keep the download cache ``` The ISOs appear in `out/iso/dasos--x86_64.iso` and `out/iso/dasos-server--x86_64.iso`. Write them to a USB stick with `dd` or Ventoy. -**Note:** Das OS is an early version (0.4.0). The developer profile, smaller ISOs and signed repository hosting are planned for upcoming stages. Until the repository is hosted, an installed system gets updates from Void only. Das OS is based on Void Linux but is not affiliated with it. +**Note:** Das OS is an early version (0.5.0). The developer profile and signed repository hosting are planned for upcoming stages. Until the repository is hosted, an installed system gets updates from Void only. Das OS is based on Void Linux but is not affiliated with it. ###### _Made by -=:dAs:=-_ diff --git a/README_ua.md b/README_ua.md index d930885..592c2d6 100644 --- a/README_ua.md +++ b/README_ua.md @@ -1,7 +1,7 @@ ## Das OS ### _Легкий і дружній десктоп на базі Void Linux з Xfce та українським колоритом, а також консольна серверна версія_ -[![Void Linux](https://img.shields.io/badge/Void_Linux-x86__64_glibc-green)](https://voidlinux.org) [![Xfce](https://img.shields.io/badge/Xfce-4.20-orange)](https://xfce.org) [![runit](https://img.shields.io/badge/init-runit-blue)](https://docs.voidlinux.org/config/services/) [![Go](https://img.shields.io/badge/Go-1.24+-blue)](https://go.dev) [![Docker](https://img.shields.io/badge/Docker-29-blue)](https://www.docker.com) [![Version](https://img.shields.io/badge/version-0.4.0-lightgrey)](#) +[![Void Linux](https://img.shields.io/badge/Void_Linux-x86__64_glibc-green)](https://voidlinux.org) [![Xfce](https://img.shields.io/badge/Xfce-4.20-orange)](https://xfce.org) [![runit](https://img.shields.io/badge/init-runit-blue)](https://docs.voidlinux.org/config/services/) [![Go](https://img.shields.io/badge/Go-1.24+-blue)](https://go.dev) [![Docker](https://img.shields.io/badge/Docker-29-blue)](https://www.docker.com) [![Version](https://img.shields.io/badge/version-0.5.0-lightgrey)](#) 🇬🇧 [English version](README.md) @@ -15,7 +15,7 @@ Void Linux швидкий, простий і незалежний: runit зам - [Оновлення системи](#Update) зі знімком перед ним: вікно, значок у треї та консольний режим - [Вікно привітання](#Welcome) під час першого входу: мова, теки, оновлення та довідка - [Серверний образ](#Server): консольний live ISO з текстовим інсталятором, SSH, файрволом, fail2ban і Docker -- [Керування службами](#Services) `dsv`: вмикати, запускати й читати журнали служб runit, як systemctl +- [Консольні помічники](#Helpers) для щоденного адміністрування: служби (`dsv`), користувачі (`duser`), брандмауер (`dfw`) і системні налаштування (`dsys`) - [Оформлення Das OS](#Look): світла й темна теми, згенеровані рамки вікон, шпалери та логотип - [Налаштування за замовчуванням](#Defaults) для Xfce, які не перезаписують стандартні файли - [Власний репозиторій пакетів](#Repository), який xbps-src збирає з шаблонів у дереві проєкту @@ -129,17 +129,19 @@ Void Linux швидкий, простий і незалежний: runit зам - Розмітка GPT і Btrfs та сама, що в десктопному інсталяторі, тож знімки, меню *Das OS snapshots* і `snapper-rollback` працюють так само. GRUB встановлюється для UEFI (також за запасним шляхом) або BIOS, а якщо інсталятор запущено з послідовної консолі, то й на неї. - Адміністратор отримує sudo через `wheel` і входить у групи `docker` та `socklog`. У root немає пароля. - Встановлена система запускає `sshd`, `chronyd`, `nftables`, `fail2ban`, `docker`, `socklog` і вибрану мережеву службу. -- nftables пропускає лише SSH, ICMP і DHCPv6 (`/etc/nftables.conf`). Правила лежать у власній таблиці `inet das`, тож їх перезавантаження не чіпає правил Docker і fail2ban. Порти, які публікують контейнери, проходять через власні правила Docker. +- nftables пропускає лише SSH, ICMP і DHCPv6 (`/etc/nftables.conf`), інші порти відкриває `dfw`. Правила лежать у власній таблиці `inet das`, тож їх перезавантаження не чіпає правил Docker і fail2ban. Порти, які публікують контейнери, проходять через власні правила Docker. - fail2ban блокує адреси, з яких раз у раз не вдається увійти по SSH, читаючи журнал socklog. sshd не приймає вхід root за паролем. -- Інструменти: Docker з Compose, lazydocker, lazygit, Midnight Commander, tmux, htop, Python 3, curl, `das-update --cli` і `dsv`. Графічних бібліотек немає. +- Інструменти: Docker з Compose, lazydocker, lazygit, Midnight Commander, tmux, htop, Python 3, curl, `das-update --cli`, [помічники](#Helpers) і xtools (`xi`, `xq`, `xrs`). Графічних бібліотек немає, як і прошивок для відеокарт NVIDIA та звукових карт (`/etc/xbps.d/das-server.conf` не дає оновленням повернути їх). -#### Керування службами -**_`dsv` керує службами runit так, як systemctl у дистрибутивах із systemd._** -- `dsv list` показує всі служби з `/etc/sv`: чи ввімкнена, стан, PID і час роботи. -- `dsv enable НАЗВА` запускає службу під час завантаження (посилання в типовому рівні запуску) і одразу запускає її. `dsv disable НАЗВА` робить навпаки. -- `dsv start|stop|restart|status НАЗВА` — обгортки над `sv`, які показують новий стан. -- `dsv logs НАЗВА [-f]` показує власний журнал служби (svlogd) або, для служб, що пишуть у syslog, їхні рядки, зібрані socklog. -- Сам перезапускається через sudo, коли потрібні права root, і має автодоповнення bash. Є й на десктопі. +#### Консольні помічники +**_Короткі команди для задач, які в дистрибутивах із systemd розв'язують systemctl, hostnamectl тощо, а у Void доводиться робити вручну._** +- Вони відповідають українською або англійською, коли потрібні права root, самі перезапускаються через sudo, і мають автодоповнення bash. У терміналі вони запитують те, чого не вказано, а з усіма аргументами працюють у скриптах. +- `dsv` керує службами runit: `list` (чи ввімкнена, стан, PID, час роботи), `enable` і `disable` (під час завантаження й одразу), `start`, `stop`, `restart`, `status` і `logs [-f]` (власний журнал служби svlogd або її рядки в socklog). +- `dsv new` створює службу для власної програми: команда, користувач, робочий каталог і змінні середовища записуються в `/etc/sv/НАЗВА`, журнал ведеться в `/var/log/НАЗВА`. Потім служба запускається, і `dsv` перевіряє, що вона працює стабільно. `dsv rm` видаляє таку службу, а служби з пакетів не чіпає. +- `duser` керує обліковими записами: `add` з тими ж групами, що дає десктопний інсталятор (`--admin` додає `wheel`, `docker` і `socklog`), паролем і SSH-ключем; `passwd`, `lock` і `unlock` (заблокований запис не ввійде й за ключем), `key add|list|rm` для `authorized_keys`, `groups ІМ'Я +ГРУПА -ГРУПА` і `del`. Не дасть видалити чи заблокувати поточного користувача та видалити єдиного адміністратора. +- `dfw` відкриває порти в серверному брандмауері: `dfw allow 80/tcp`, `dfw allow https 8000-8010/tcp`, `dfw deny 80/tcp`. Порти зберігаються в `/etc/nftables.d/dfw.nft` і перевіряються через `nft -c` перед завантаженням, а перезавантажується лише таблиця `inet das`. `dfw status` показує ще й порти, опубліковані контейнерами Docker, та адреси, заблоковані fail2ban. SSH лишається відкритим завжди. +- `dsys` замінює hostnamectl, timedatectl і localectl: `info` (синхронізація часу, мова, консоль, адреси), `hostname`, `timezone` (досить частини назви: `dsys timezone Kyiv`), `locale` (збирає локаль glibc, якщо треба) і `keymap` (з кириличним шрифтом консолі для кириличних розкладок). +- Вони входять у пакет `das-helpers` і є й на десктопі. Обидва образи також містять `xtools-minimal` з Void для роботи з пакетами: `xi` встановлює, `xrs` шукає, `xq` показує відомості про пакет, а `xcheckrestart` знаходить програми, які треба перезапустити після оновлення. #### Оформлення Das OS **_Сучасний мінімалізм із синьо-жовтою восьмипелюстковою зіркою з української вишивки._** @@ -163,7 +165,7 @@ Void Linux швидкий, простий і незалежний: runit зам - `srcpkgs/das-tools` містить `das-welcome`, вікно оновлення і значок у треї (Go, GTK 3 через gotk3) з пунктами меню й автозапуском. Його підпакет `das-update` — сама команда оновлення й дія polkit, без GTK. - `srcpkgs/das-snapshots` містить конфігурацію snapper, `das-snapper-setup` і dracut-модуль для завантаження знімків. - `srcpkgs/das-installer` містить налаштування й брендинг Calamares, `srcpkgs/das-server-installer` — консольний інсталятор. Спільні для обох кроки (`mount-source`, `postinstall`) лежать у `srcpkgs/das-install-common`. Вони є лише на live-ISO. -- `srcpkgs/das-server` — серверний метапакет із налаштуваннями nftables, fail2ban, sshd і Docker. `srcpkgs/dsv` містить керування службами. +- `srcpkgs/das-server` — серверний метапакет із налаштуваннями nftables, fail2ban, sshd і Docker. `srcpkgs/das-helpers` містить `dsv`, `duser`, `dfw` і `dsys`. - `srcpkgs/calamares`, `srcpkgs/adw-gtk3` і `srcpkgs/font-jetbrains-mono` пакують релізи, яких немає у Void. - `make packages` накладає шаблони на неглибокий клон void-packages і збирає їх через `xbps-src` в `out/hostdir/binpkgs`. Збирання ISO встановлює їх звідти. @@ -185,6 +187,6 @@ make clean # видалити результати збиранн ``` ISO з'являються в `out/iso/dasos-<версія>-x86_64.iso` і `out/iso/dasos-server-<версія>-x86_64.iso`. Записати їх на флешку можна через `dd` або Ventoy. -**Примітка:** Das OS — рання версія (0.4.0). Профіль розробника, менші ISO та підписаний репозиторій на хостингу заплановані на наступні етапи. Поки репозиторій не розміщено на хостингу, встановлена система отримує оновлення лише з Void. Das OS базується на Void Linux, але не пов'язана з ним офіційно. +**Примітка:** Das OS — рання версія (0.5.0). Профіль розробника та підписаний репозиторій на хостингу заплановані на наступні етапи. Поки репозиторій не розміщено на хостингу, встановлена система отримує оновлення лише з Void. Das OS базується на Void Linux, але не пов'язана з ним офіційно. ###### _Made by -=:dAs:=-_ diff --git a/TODO.md b/TODO.md index cf6e287..161880e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,6 @@ # Das OS — що зробити далі -Стан на 2026-10-19: версія 0.4.0, етапи 1–6 готові (live ISO, власний репозиторій, оформлення, інсталятор Calamares зі знімками Btrfs, Go-утиліти `das-update` і `das-welcome`, серверний образ з консольним інсталятором). Далі: рішення щодо розміру ISO, потім профіль розробника (етап 7). +Стан на 2026-10-20: версія 0.5.0, етапи 1–7 готові (live ISO, власний репозиторій, оформлення, інсталятор Calamares зі знімками Btrfs, Go-утиліти `das-update` і `das-welcome`, серверний образ з консольним інсталятором, менші ISO, консольні помічники). Далі: профіль розробника (етап 8). ## Етап 5. Go-утиліти (готово, лишилися перевірки) - [x] `das-update`: вікно, значок у треї (лише коли є оновлення), консольний режим `--cli`, знімок snapper pre/post, встановлення через pkexec, підказка про відкат. @@ -12,12 +12,12 @@ - [x] Live ISO 0.4.0: екран не блокується під час встановлення. - [ ] Вікно автентифікації polkit (`xfce-polkit`) англійською: перекласти або замінити агент. - [ ] Повідомити авторів xdg-user-dirs про помилку з `XDG_DATA_DIRS`. -- [ ] Пункт «Профіль розробника» в `das-welcome` (разом з етапом 7). +- [ ] Пункт «Профіль розробника» в `das-welcome` (разом з етапом 8). -## Етап 6. Серверний образ (готово, лишився розмір ISO) +## Етап 6. Серверний образ (готово) Live ISO без графіки з консольним інсталятором: `dasos-server-<версія>-x86_64.iso`. - [x] Профіль `server/` поруч із `live/`; `build-iso.sh [desktop|server]`, цілі `make iso-server` і `make qemu-server`. Kernel cmdline `console=tty0 console=ttyS0,115200`, вхід і на послідовній консолі. -- [x] Метапакет `das-server`: openssh, chrony, dhcpcd, NetworkManager, docker + docker-compose, nftables, fail2ban, socklog-void, python3, curl, lazydocker, lazygit, mc, tmux, htop, das-snapshots, das-update, dsv. +- [x] Метапакет `das-server`: openssh, chrony, dhcpcd, NetworkManager, docker + docker-compose, nftables, fail2ban, socklog-void, python3, curl, lazydocker, lazygit, mc, tmux, htop, das-snapshots, das-update, das-helpers, xtools-minimal. - [x] Налаштування: nftables «закрито все, крім SSH» (лише своя таблиця `inet das`, правила Docker і fail2ban не чіпає), jail sshd через nftables з розбором журналу socklog, sshd `PermitRootLogin prohibit-password`, журнали Docker з ротацією (`local`). - [x] `dsv`: `list`, `status`, `enable`, `disable`, `start`, `stop`, `restart`, `logs [-f]`, автодоповнення bash; є й на десктопі. - [x] `das-update` окремим підпакетом без GTK (вікно й трей — `/usr/libexec/das/das-update-gui` у `das-tools`). @@ -25,24 +25,36 @@ Live ISO без графіки з консольним інсталятором: - [x] Спільний пакет `das-install-common` (`mount-source`, `postinstall`) для обох інсталяторів; `das-snapper-setup` вмикає `dbus`, без нього snapper не працює. - [x] `scripts/run-qemu.sh --serial`. - [x] Перевірено в QEMU: встановлення через TUI на UEFI (з SSH-ключем) і BIOS (з паролем, NetworkManager, `ua-utf`), вхід по SSH лише за ключем, `docker run hello-world`, опублікований порт nginx, fail2ban бачить невдалі входи, `das-update --cli` зі знімками pre/post, `dsv`. -- [ ] Перевірити `das-server-install --config` (код той самий, що й у TUI, окремо не запускався). -- [ ] Розмір ISO: огляд зроблено (нижче), чекає рішення, що саме зменшувати. +- [x] `das-server-install --config` перевірено (0.5.0, UEFI). +- [x] Розмір ISO: див. нижче. -### Розмір ISO (0.4.0): десктоп 1,96 ГБ, сервер 1,57 ГБ -З чого складається: `initrd` 233 МБ і squashfs 1584 / 1215 МБ (xz). Прошивки в Void уже стиснуті zstd, тож xz їх майже не зменшує. -- [ ] `initrd` live-образу: 157 МБ у ньому — прошивки GPU (NVIDIA 109 МБ, amdgpu 28 МБ) для раннього KMS. Без драйверів `nouveau`/`amdgpu`/`radeon`/`i915`/`xe` у live-initrd: ≈ −150 МБ в обох ISO. Драйвери все одно завантажаться з кореневої системи. -- [ ] Сервер: без `linux-firmware-nvidia`, `sof-firmware`, `alsa-firmware` (GPU і звук): ≈ −160 МБ. Через `mklive -g` і `ignorepkg` у `das-server`, щоб оновлення не повертали їх. +### Розмір ISO: 0.4.0 → 0.5.0 +Десктоп 1,96 → 1,72 ГБ, сервер 1,57 → 1,14 ГБ. +- [x] `initrd` live-образу без драйверів `amdgpu`/`radeon`/`nouveau`/`i915`/`xe` і їхніх прошивок (`--omit-drivers` у виклику dracut у mklive): 245 → 89 МБ в обох ISO. Драйвери завантажуються з кореневої системи; десктоп у QEMU доходить до робочого столу. +- [x] Сервер без `linux-firmware-nvidia`, `sof-firmware`, `alsa-firmware`: `mklive -g` і `/etc/xbps.d/das-server.conf` (`ignorepkg`), тож оновлення їх не повертають. Службовий `mklive-ignore.conf` інсталятори видаляють. +- [x] squashfs з блоком 1 МБ і фільтром BCJ x86: десктоп −5 %; сервер разом із прошивками 1274 → 999 МБ. +- [ ] Перевірити на реальному залізі з AMD/Intel/NVIDIA, що графіка вмикається після `switch_root` (у QEMU — virtio-gpu). - [ ] Переклади `/usr/share/locale` (265 МБ на десктопі, 98 МБ на сервері) лише для uk і en через `noextract` у xbps: ≈ −60…80 МБ на десктопі. Мінус: інші мови інтерфейсу доведеться доставляти перевстановленням пакетів. -- [ ] squashfs з блоком 1 МБ і фільтром BCJ x86 замість 128 КБ: ≈ −5 %. - [ ] Десктоп: зайві варіанти Papirus (`ePapirus*`, `Papirus-Light`) з 294 МБ іконок; з'ясувати, хто тягне gtk4, flite, ghostscript. - Не чіпати: Firefox (299 МБ), ядро й модулі (180 МБ), LLVM для Mesa (141 МБ), мережеві прошивки (442 МБ, Wi-Fi та мережеві карти). -## Етап 7. Профіль розробника +## Етап 7. Консольні помічники (готово) +Пакет `das-helpers` (на десктопі й сервері), спільні функції в `/usr/lib/das/helpers.sh`, українська й англійська, автодоповнення bash, запитання в терміналі й аргументи для скриптів. +- [x] `dsv` переїхав у `das-helpers`; `dsv new` (команда, користувач, каталог, змінні, журнал svlogd, перевірка, що служба не падає) і `dsv rm` (лише не з пакетів). +- [x] `duser`: `list`, `add` (групи як у Calamares, `--admin`, ключ, пароль), `del`, `passwd`, `lock`/`unlock` (і вхід за ключем), `key add|list|rm`, `groups +/-`; захист від видалення чи блокування себе та єдиного адміністратора. +- [x] `dfw`: `status`, `allow`, `deny`, `reload`; набори `tcp_ports`/`udp_ports` у таблиці `inet das`, порти в `/etc/nftables.d/dfw.nft`, перевірка `nft -c`; показує порти Docker і заблоковані fail2ban адреси. +- [x] `dsys`: `info`, `hostname`, `timezone`, `locale`, `keymap`. +- [x] `xtools-minimal` (`xi`, `xq`, `xrs`, `xcheckrestart`) в обох образах. +- [x] Виправлено: коментар у `nftables.conf` радив `dsv restart nftables`, а `finish` служби Void робить `nft flush ruleset` і стирає правила fail2ban та Docker. Тепер `dfw reload`. +- [x] Перевірено на встановленому сервері: `dsv new` з Python-сервером на 8080, доступ з хоста після `dfw allow` і закриття після `dfw deny`; `duser add` з ключем, вхід, `lock` блокує й вхід за ключем; `dsys` (часовий пояс, ім'я, мова, розкладка); `dfw reload` поруч із контейнером nginx. +- [x] Повідомлення наприкінці `das-server-install` згадує `dsv`, `duser`, `dfw`, `dsys`. + +## Етап 8. Профіль розробника - [ ] Метапакет `das-dev`: go, git, docker, podman, vscode. - [ ] Android Studio та JetBrains на вимогу через Flatpak. - [ ] Вибір профілю в інсталяторі (модуль packagechooser) або в `das-welcome`. -## Етап 8. CI +## Етап 9. CI - [ ] Збирання пакетів і обох ISO (десктоп і сервер) в контейнері Void (Gitea Actions на git.das.org.ua або GitHub Actions). ## Репозиторій пакетів diff --git a/VERSION b/VERSION index 1d0ba9e..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.5.0 diff --git a/live/packages.x86_64 b/live/packages.x86_64 index 862e498..448bc2d 100644 --- a/live/packages.x86_64 +++ b/live/packages.x86_64 @@ -66,4 +66,5 @@ das-settings das-snapshots das-theme das-tools -dsv +das-helpers +xtools-minimal diff --git a/srcpkgs/dsv/files/LICENSE b/srcpkgs/das-helpers/files/LICENSE similarity index 100% rename from srcpkgs/dsv/files/LICENSE rename to srcpkgs/das-helpers/files/LICENSE diff --git a/srcpkgs/das-helpers/files/dfw b/srcpkgs/das-helpers/files/dfw new file mode 100755 index 0000000..8b7097d --- /dev/null +++ b/srcpkgs/das-helpers/files/dfw @@ -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 diff --git a/srcpkgs/das-helpers/files/dfw.bash-completion b/srcpkgs/das-helpers/files/dfw.bash-completion new file mode 100644 index 0000000..3e90014 --- /dev/null +++ b/srcpkgs/das-helpers/files/dfw.bash-completion @@ -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 diff --git a/srcpkgs/dsv/files/dsv b/srcpkgs/das-helpers/files/dsv similarity index 51% rename from srcpkgs/dsv/files/dsv rename to srcpkgs/das-helpers/files/dsv index db3116d..f01ce22 100755 --- a/srcpkgs/dsv/files/dsv +++ b/srcpkgs/das-helpers/files/dsv @@ -7,6 +7,9 @@ # 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 @@ -14,22 +17,6 @@ service_dir=/var/service [[ -d $service_dir ]] || service_dir=$runlevel socklog=/var/log/socklog -if [[ ${LANG:-} == uk* || ${LC_ALL:-} == uk* || ${LC_MESSAGES:-} == 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 -} - -die() { - say "dsv: $1" "dsv: $2" >&2 - exit 1 -} - usage() { if [[ -n $uk ]]; then cat <<'EOF' @@ -42,6 +29,16 @@ usage() { start|stop|restart СЛУЖБА... запустити, зупинити, перезапустити logs СЛУЖБА [-f] журнал служби (-f: стежити за новими рядками) + new [СЛУЖБА] [ПАРАМЕТРИ] + створити службу для своєї програми; чого не + вказано, те запитає: + --cmd КОМАНДА що запускати (програма не має йти у фон) + --user КОРИСТУВАЧ від чийого імені + --dir КАТАЛОГ робочий каталог + --env ІМ'Я=ЗНАЧЕННЯ змінна середовища, можна кілька разів + --no-log без власного журналу в /var/log/СЛУЖБА + --enable одразу ввімкнути й запустити + rm СЛУЖБА вимкнути й видалити службу, створену через new Служби — каталоги в /etc/sv. Без команди виконується list. EOF @@ -56,21 +53,23 @@ Usage: dsv COMMAND [SERVICE...] 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 } -# Service states live in root-only supervise directories -need_root() { - if [[ $EUID -ne 0 ]]; then - exec sudo "$0" "$@" - fi -} - check_service() { - [[ -d $sv_dir/$1 ]] || die "немає служби '$1' (див. ls $sv_dir)" "no service '$1' (see ls $sv_dir)" + [[ -d $sv_dir/$1 ]] || die "немає служби '$1' (див. dsv list)" "no service '$1' (see dsv list)" } is_enabled() { @@ -87,14 +86,12 @@ wait_supervised() { return 1 } -# row NAME ENABLED STATE PID UPTIME: one line of the list. printf pads by -# bytes, ${#s} counts characters, so Cyrillic columns stay aligned. +# row NAME ENABLED STATE PID UPTIME: one line of the list row() { - local s widths=(24 9 6 8) i=0 - for s in "$1" "$2" "$3" "$4"; do - printf '%s%*s ' "$s" $((widths[i] - ${#s})) '' - i=$((i + 1)) - done + pad "$1" 24 + pad "$2" 9 + pad "$3" 6 + pad "$4" 8 printf '%s\n' "$5" } @@ -240,6 +237,151 @@ cmd_logs() { 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" <&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" </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 @@ -269,6 +411,14 @@ case $cmd in [[ -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 ;; diff --git a/srcpkgs/dsv/files/dsv.bash-completion b/srcpkgs/das-helpers/files/dsv.bash-completion similarity index 66% rename from srcpkgs/dsv/files/dsv.bash-completion rename to srcpkgs/das-helpers/files/dsv.bash-completion index 9c28059..9bf6d36 100644 --- a/srcpkgs/dsv/files/dsv.bash-completion +++ b/srcpkgs/das-helpers/files/dsv.bash-completion @@ -3,7 +3,7 @@ _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 help" -- "$cur") + mapfile -t COMPREPLY < <(compgen -W "list status enable disable start stop restart logs new rm help" -- "$cur") return fi case $cmd in @@ -20,7 +20,16 @@ _dsv() { disable | start | stop | restart) mapfile -t COMPREPLY < <(compgen -W "$(ls /etc/runit/runsvdir/default 2>/dev/null)" -- "$cur") ;; - status | logs | log) + 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 diff --git a/srcpkgs/das-helpers/files/dsys b/srcpkgs/das-helpers/files/dsys new file mode 100755 index 0000000..ac52df9 --- /dev/null +++ b/srcpkgs/das-helpers/files/dsys @@ -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 _ 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 "; 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 diff --git a/srcpkgs/das-helpers/files/dsys.bash-completion b/srcpkgs/das-helpers/files/dsys.bash-completion new file mode 100644 index 0000000..f6caf55 --- /dev/null +++ b/srcpkgs/das-helpers/files/dsys.bash-completion @@ -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 diff --git a/srcpkgs/das-helpers/files/duser b/srcpkgs/das-helpers/files/duser new file mode 100755 index 0000000..4358544 --- /dev/null +++ b/srcpkgs/das-helpers/files/duser @@ -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 diff --git a/srcpkgs/das-helpers/files/duser.bash-completion b/srcpkgs/das-helpers/files/duser.bash-completion new file mode 100644 index 0000000..ad6e0e5 --- /dev/null +++ b/srcpkgs/das-helpers/files/duser.bash-completion @@ -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 diff --git a/srcpkgs/das-helpers/files/helpers.sh b/srcpkgs/das-helpers/files/helpers.sh new file mode 100644 index 0000000..40e0077 --- /dev/null +++ b/srcpkgs/das-helpers/files/helpers.sh @@ -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 +} diff --git a/srcpkgs/das-helpers/template b/srcpkgs/das-helpers/template new file mode 100644 index 0000000..98d4a71 --- /dev/null +++ b/srcpkgs/das-helpers/template @@ -0,0 +1,21 @@ +# Template file for 'das-helpers' +pkgname=das-helpers +version=0.1.0 +revision=1 +depends="bash runit sudo shadow openssh nftables kbd" +short_desc="Das OS helpers: services (dsv), users (duser), firewall (dfw), system (dsys)" +maintainer="Das " +license="MIT" +homepage="https://git.das.org.ua/das/Das-OS" +# dsv used to be a package of its own +replaces="dsv>=0" +provides="dsv-${version}_${revision}" + +do_install() { + vinstall ${FILESDIR}/helpers.sh 644 usr/lib/das + for tool in dsv duser dfw dsys; do + vbin ${FILESDIR}/${tool} + vcompletion ${FILESDIR}/${tool}.bash-completion bash ${tool} + done + vlicense ${FILESDIR}/LICENSE +} diff --git a/srcpkgs/das-server-installer/files/das-server-install b/srcpkgs/das-server-installer/files/das-server-install index 3b45738..0df9206 100755 --- a/srcpkgs/das-server-installer/files/das-server-install +++ b/srcpkgs/das-server-installer/files/das-server-install @@ -66,7 +66,7 @@ t() { s_config) echo "Налаштування системи..." ;; s_post) echo "Локалі, знімки, initramfs (кілька хвилин)..." ;; s_boot) echo "Встановлення завантажувача..." ;; - finished) echo "Das OS Server встановлено. Вийміть інсталяційний носій і перезавантажте сервер.\n\nВхід: %s, далі sudo. Служби: dsv list. Оновлення: das-update." ;; + finished) echo "Das OS Server встановлено. Вийміть інсталяційний носій і перезавантажте сервер.\n\nВхід: %s, далі sudo. Служби: dsv. Користувачі: duser. Порти: dfw. Налаштування: dsys. Оновлення: das-update." ;; reboot) echo "Перезавантажити зараз?" ;; failed) echo "Помилка встановлення. Журнал: %s" ;; root) echo "Інсталятор треба запускати від root (sudo das-server-install)." ;; @@ -105,7 +105,7 @@ t() { s_config) echo "Configuring the system..." ;; s_post) echo "Locales, snapshots, initramfs (a few minutes)..." ;; s_boot) echo "Installing the boot loader..." ;; - finished) echo "Das OS Server is installed. Remove the installation medium and reboot.\n\nLog in as %s, then use sudo. Services: dsv list. Updates: das-update." ;; + finished) echo "Das OS Server is installed. Remove the installation medium and reboot.\n\nLog in as %s, then use sudo. Services: dsv. Users: duser. Ports: dfw. Settings: dsys. Updates: das-update." ;; reboot) echo "Reboot now?" ;; failed) echo "Installation failed. Log: %s" ;; root) echo "The installer must run as root (sudo das-server-install)." ;; diff --git a/srcpkgs/das-server-installer/template b/srcpkgs/das-server-installer/template index 2522204..13ee31d 100644 --- a/srcpkgs/das-server-installer/template +++ b/srcpkgs/das-server-installer/template @@ -1,6 +1,6 @@ # Template file for 'das-server-installer' pkgname=das-server-installer -version=0.1.0 +version=0.2.0 revision=1 depends="bash dialog das-install-common btrfs-progs dosfstools gptfdisk parted rsync util-linux grub grub-x86_64-efi efibootmgr" diff --git a/srcpkgs/das-server/files/nftables.conf b/srcpkgs/das-server/files/nftables.conf index 123e3cb..1ee0255 100644 --- a/srcpkgs/das-server/files/nftables.conf +++ b/srcpkgs/das-server/files/nftables.conf @@ -1,15 +1,26 @@ #!/usr/sbin/nft -f # Das OS server firewall: incoming connections are closed except SSH. # -# Only the "das" table is replaced, so loading this file again -# (dsv restart nftables) keeps the rules of Docker and fail2ban. -# Ports that Docker containers publish (-p 8080:80) pass through Docker's -# own forwarding rules, not the input chain below. +# Open ports with dfw (dfw allow 80/tcp), which keeps them in +# /etc/nftables.d/dfw.nft. Ports that Docker containers publish (-p 8080:80) +# pass through Docker's own forwarding rules, not the input chain below. +# +# After editing this file: dfw reload. Only the "das" table is replaced, so the +# rules of Docker and fail2ban stay. Do not restart the nftables service for +# that: its finish script flushes the whole ruleset, theirs too. table inet das delete table inet das table inet das { + # Filled by dfw + set tcp_ports { + type inet_service; flags interval; auto-merge; + } + set udp_ports { + type inet_service; flags interval; auto-merge; + } + chain input { type filter hook input priority filter; policy drop; @@ -24,8 +35,13 @@ table inet das { iifname "br-*" accept tcp dport ssh accept + tcp dport @tcp_ports accept + udp dport @udp_ports accept - # Open more ports here, then: dsv restart nftables - # tcp dport { http, https } accept + # Rules of your own go here, e.g. a port for one network only: + # ip saddr 192.168.1.0/24 tcp dport 5432 accept } } + +# dfw's ports and drop-ins of your own +include "/etc/nftables.d/*.nft" diff --git a/srcpkgs/das-server/template b/srcpkgs/das-server/template index dd19d4a..cbd1d79 100644 --- a/srcpkgs/das-server/template +++ b/srcpkgs/das-server/template @@ -4,7 +4,7 @@ version=0.2.0 revision=1 depends="openssh chrony dhcpcd NetworkManager docker docker-compose nftables fail2ban socklog-void python3 curl lazydocker lazygit mc tmux htop - das-snapshots das-update dsv" + das-snapshots das-update das-helpers xtools-minimal" short_desc="Das OS server: packages and secure defaults for SSH, firewall and Docker" maintainer="Das " license="MIT" @@ -18,6 +18,7 @@ conf_files=" do_install() { vinstall ${FILESDIR}/nftables.conf 644 etc + vmkdir etc/nftables.d vinstall ${FILESDIR}/fail2ban-das.conf 644 etc/fail2ban/jail.d das.conf vinstall ${FILESDIR}/sshd-das.conf 644 etc/ssh/sshd_config.d 50-das.conf vinstall ${FILESDIR}/docker-daemon.json 644 etc/docker daemon.json diff --git a/srcpkgs/dsv/template b/srcpkgs/dsv/template deleted file mode 100644 index 853bf50..0000000 --- a/srcpkgs/dsv/template +++ /dev/null @@ -1,15 +0,0 @@ -# Template file for 'dsv' -pkgname=dsv -version=0.1.0 -revision=1 -depends="bash runit sudo" -short_desc="Das OS service manager: enable, start and read logs of runit services" -maintainer="Das " -license="MIT" -homepage="https://git.das.org.ua/das/Das-OS" - -do_install() { - vbin ${FILESDIR}/dsv - vcompletion ${FILESDIR}/dsv.bash-completion bash - vlicense ${FILESDIR}/LICENSE -}