Как отправлять и обрабатывать графические уведомления на bash
Всем привет! Сегодня будет разбор интересной задачи: как рисовать красивые графические уведомления и взаимодействовать с ними из скриптов bash.
Демонстрация будет осуществляться не на абстрактных примерах, а на вполне реальной задаче — необходимо уведомить пользователя о скором истечении пароля и дать возможность его сменить. Ситуация не надуманная — компьютер в домене, sssd даёт возможность авторизоваться/аутентифицироваться пользователю, но вот демонстрировать ему информацию о необходимости смены пароля не может — не его уровень. Вроде как gdm готов этим заняться, только весьма специфически — при удачном логине быстро проскакивает малозаметная строчка с информацией о последнем входе и количестве дней до смены. Раньше, когда все пользователи Linux в большинстве своём были сисадминами/гиками, это никого особо не напрягало. А вот сейчас, из-за активного импортозамещения, появилось большое количество «начинающих» пользователей Linux и, как следствие, достаточно глупые заявки — учётка заблочилась, потому что кто-то не поменял пароль вовремя.
▍ Архитектура проекта
Мы люди серьёзные — сначала думаем, а потом делаем (или не делаем). Набросаем нечто вроде ТЗ: Уведомление должно быть ненавязчивым, а также всё должно быть красиво и удобно!
В качестве среды исполнения будет bash, coreutils — в общем всё, что есть в фундаментальных трудах предков, с учётом современных реалий.
Скрипт будет исполняться при старте графической сессии пользователя и производить следующие действия:
- Определять дату истечения пароля данного пользователя.
- В случае, если до истечения пароля осталось менее 7 дней, выводить уведомление пользователю с предложением изменить его.
- Если пользователь согласился изменить пароль — вызвать штатную утилиту изменения пароля.
- Если пользователь бездействует, уведомление исчезает.
Имеются удобные для нас факторы — т. к. скрипт выполняется в графической сессии пользователя, то выполняется он с его правами и установленной переменной DISPLAY.
Операционка отечественная — RED OS.
▍ Определяем дату истечения пароля
У меня домен виндовый, поэтому навскидку пробуем rpcclient:
Отлично! Необходимая дата имеется, сейчас мы её отгрепаем, потом вычтем из неё текущую дату и переведём всё это в дни. Сравним со сроком предупреждения. Это по-человечески.
Для компьютера немного иначе — приводим все значения времени к общему знаменателю (секунды с начала epoch — 1970–01–01 UTC), далее из даты/времени истечения пароля вычитаем текущую отметку времени и количество дней предупреждений. В итоге на руках у нас остаётся цифра, где нам важен только знак — при отрицательном значении уведомляем о необходимости смены пароля и предлагаем это сделать, в остальных случаях ничего не делаем.
Первым делом обработаем дату истечения пароля. Помнится, дату в человеческом формате умеет обрабатывать команда date — вот ей и поручим это дело, предварительно убрав всё лишнее:
$ date -d "$(rpcclient -N -c "queryuser $USER" dc0.corporation.ru | grep 'Password must change Time' | cut -d , -f 2)" +%s
Ожидали получить отметку времени в секундах с начала epoch, а получаем ошибку:
И выходит она не из-за того, что там закралась табуляция. Истинная причина в том, что date может преобразовать только представление времени на английском языке. Это довольно легко исправляется кратковременной сменой локали. Также немного оптимизируем — мне захотелось, чтобы дату выделял sed.
$ date -d "$(LC_TIME=C rpcclient -N -c "queryuser $USER" dc0.corporation.ru | sed -n 's/Password must change Time.*,\(.*\)/\1/p')" +%s
Теперь всё работает как положено и ожидаемо.
Однако вот ещё один способ, который мне жутко нравится :) Для разбивки по полям будем использовать массивы bash:
$ EXPDATE=($(LC_TIME=C rpcclient -N -c "queryuser $USER" dc0.corporation.ru | grep 'Password must change Time'))
Пожалуй, оставлю его. Обратите внимание, что вывод $(…) окружён ещё одной парой скобок, которые распарсят строку по пробелам в элементы массива EXPDATE. Ну и примеры, как пользоваться результатом:
${#EXPDATE[@]} возвращает нам размер массива. По этой цифре мы будем ориентироваться, вернули нам результат, или что-то пошло не по плану.
${EXPDATE[*]:5:5} представит дату в виде строки (собирает 5 элементов массива, начиная с элемента номер 5). Эти значения понадобятся нам позже.
Следующий вопрос — как получить текущее время? Можно аналогично привлечь к этому date, а можно почитать man bash и, если он у вас достаточно свежий, воспользоваться штатной переменной $EPOCHSECONDS.
Осталось перевести в секунды количество дней для предупреждения об истечении пароля. Здесь простейшая арифметика:
7 дней * 24 часа * 60 минут * 60 секунд = 604800 секунд
Цифру 7 мы, вероятно, загоним в переменную, а остальное посчитаем калькулятором на листочке в клеточку. Осталось собрать всё это вместе:
ALARMDAYS=7
ADSRV="dc0.corporation.ru"
EXPDATE=($(LC_TIME=C rpcclient -N -c "queryuser $USER" $ADSRV | grep 'Password must change Time'))
[ ${#EXPDATE[@]} -eq 0 ] && exit
ALARMTTL=$(($(date -d "${EXPDATE[*]:5:5}" +%s) - $EPOCHSECONDS - $ALARMDAYS*86400))
[ $ALARMTTL -gt 0 ] && exit
Здесь появились два условия: первое позволит нам увернуться от ситуации, когда rpcclient ничего не вернёт (например ругнётся, если мы подсунем ему локального пользователя), второе продолжит выполнение скрипта в случае близости просрочки пароля.
Также, если у вас домен не на винде, а скажем FreeIPA или просто LDAP, то rpcclient можно заменить на соответствующие утилитки, тот же ldapsearch, ну, а дальше парсить/конвертить даты в зависимости от их формата вывода.
▍ Нотификация пользователю
Будем делать как на КДПВ. Вернее использовать штатную нотификацию ДЕ, только содержимое будет совсем другое:) Есть несколько гайдов, где и когда уместно это применять. Вот один из них — «Уведомления».
На первый взгляд, всё довольно просто — читаем man zenity и пробуем параметр --notify. Тупик: дополнительных параметров нет, хоть и выводит сообщение в нужное место. Также полностью отсутствует возможность отработать реакцию пользователя.
Второй вариант — утилита notify-send. Тут опций побогаче, но также нет важного функционала — обработки реакции пользователя. Даже когда вы начнёте разбираться с параметрами опции --hint, то также ничего полезного там не обнаружите.
Не стоит отчаиваться и отказываться от мечты (всплывающей плашки нотификации) в пользу окошка в центре экрана (zenity). Если немного углубиться в концепцию современного десктопа, то технически процесс нотификации реализован примерно следующим образом:
- notify-send (или его заместитель, например zenity) отправляет сообщение по шине dbus на адрес org.freedesktop.Notifications с методом org.freedesktop.Notifications.Notify и различными параметрами данного уведомления (текст/иконки/кнопки/тайм-аут и т. д.).
- dbus-daemon принимает это сообщение и передаёт его mate-notification-daemon (у меня на рабочих столах RED OS со штатной Mate, в других DE демон уведомлений может быть совсем другим) для отрисовки плашки уведомления на рабочем столе.
- mate-notification-daemon отображает уведомление, следит за временем отображения и выводит связанные с этим уведомлением сообщения обратно в шину dbus.
- Кто-нибудь отлавливает событие от уведомления и производит необходимые действия. Этот шаг опциональный.
Вариантов отправить сообщение в dbus из командной строки несколько. Прямо «из коробки»: dbus-send, gdbus, busctl.
Формат сообщения приведён в качестве примера в man gdbus, а более подробное описание находится здесь.
Рассмотрим параметры уведомления:
- app_name (string) — имя приложения, можно оставить пустым. Вроде как по этому имени определяют иконку для отображения в плашке нотификации.
- replace_id (uint32) — идентификатор нашей плашки с нотификацией. Пока плашка отображается, можно по этому идентификатору менять её содержимое, например, запустить обратный отсчёт. Ставим 0, и тогда сервер назначит автоматом, и модифицировать уведомление мы уже не сможем.
- app_icon (string) — иконка приложения. Можно оставить пустую строку либо указать иконку из определённого набора.
- summary (string) — кратенький текст уведомления. Заголовок.
- body (string) — тело уведомления. Можно оставить пустым. Может быть оформлено с простейшим форматированием, гиперссылками и картинками.
- actions (as) — тип as означает «массив строк». В данном массиве строки задаются попарно: первый элемент идентификатор, второй — надпись на кнопке. Есть возможность задать действие, которое будет выполняться просто по клику на уведомление, для этого actions должен иметь идентификатор «default». С помощью хинта «action-icons» их назначение меняются: первый элемент — иконка и одновременно идентификатор, второй — поясняющий текст.
- hints (a{sv}) — массив из парных элементов: строка и вариант (элемент любого типа). Все дополнительные параметры. Расписывать не буду, почитайте на досуге.
- expire_timeout (int32) — тайм-аут, время отображения уведомления в миллисекундах.
Это обязательные параметры, всё дополнительное задаётся через параметр hints. Если параметры actions или hints нам не нужны, то соответственно задаются пустые массивы.
Не нашёл первоисточника, но в памяти отложилось, что в руководстве gnome написано об ограничении количества action — не более трёх. Вероятно это эстетическое требование, т. к. технически можно делать больше, я проверил.
Вооружившись этими знаниями сначала затестим dbus-send. Пробежались по ману, сходу поставили плюсик за внятную систему указания типов переменных, пробуем:
dbus-send --session --dest=org.freedesktop.Notifications \
/org/freedesktop/Notifications org.freedesktop.Notifications.Notify \
string:'my_app_name' int32:42 string:'gtk-dialog-info' \
string:'The Summary' string:'Here the body' array:string:'123456','change password' \
array:string:variant: int32:10000 objpath:/org/freedesktop/Notifications
… и ничего не получается. Всё из-за примечания «bus-send does not permit empty containers or nested containers (e.g. arrays of variants)». А у нас как раз имеется обязательный параметр hints, представляющий собой контейнер (array), содержащий контейнер (variant). Этот инструмент откладываем.
Пробуем gdbus:
gdbus call --session --dest org.freedesktop.Notifications \
--object-path /org/freedesktop/Notifications \
--method org.freedesktop.Notifications.Notify \
'appname' 0 dialog-information "Title" "Message" "['123456','change password']" "{}" 10000
Сообщение вышло, кнопка на месте. Что не нравится, так это непонятки с передачей и форматом параметров. Разбираться дальше не хочется.
Далее у нас штатная утилита из комплекта systemd под названием busctl. Всё просто и понятно, бонусом вывод в json:
busctl --json=short --user call org.freedesktop.Notifications \
/org/freedesktop/Notifications org.freedesktop.Notifications \
Notify susssasa{sv}i \
my_app_name 42 password "Ваш пароль истекает" \
"11 ноября 2025" 2 "123456" "изменить пароль" 0 10000
Интересно, как задаются параметры сообщения в шину — сначала их декларируем строчкой susssasa{sv}i, которая описывает типы и количество параметров. Далее сами параметры. Если встречается массив, то первым аргументом указываем количество его элементов. Для параметров типа 'variant' указывается его тип и значение.
Как видно, здесь и во всех примерах выше, параметр hint нулевой.
busctl --verbose --user call org.freedesktop.Notifications \
/org/freedesktop/Notifications \
org.freedesktop.Notifications Notify \
susssasa{sv}i '' 0 '' 'SAVE THE EARTH' ' KILL ALL HUMANS!!!!' \
14 'face-cool' '' 'face-angry' '' 'face-monkey' '' 'face-uncertain' '' 'face-wink' '' 'face-devilish' '' 'face-worried' '' \
2 image-path s 'file:///tmp/bender.png' \
action-icons b true \
10000
busctl после себя оставит строчку вида:
{"type":"u","data":[2]}
Эта строчка даст нам информацию об идентификаторе нашего уведомления. Не выбрасываем, пригодится.
▍ Обработка пользовательского ввода
Сообщение мы отправили, оно ещё будет отображаться на экране 10 секунд, а скрипт уже завершил работу. Совсем непонятно, как обработать нажатие кнопки «изменить пароль».
Тут оказывается тоже всё просто — надо запустить процесс, который будет считывать с шины dbus события org.freedesktop.Notification и соответствующим образом на них реагировать. Утилит для мониторинга dbus также несколько: dbus-monitor, gdbus, busctl. Так как я уже задействовал busctl, то распыляться на остальные не будем. Для начала просто запустим и посмотрим, что творится на шине во время нашего уведомления. Уведомления будем запускать несколько раз, чтобы увидеть, что произойдёт, если мы нажмём кнопку «изменить пароль», если мы закроем уведомление, если уведомление погаснет по тайм-ауту.
Я уже подобрал подходящие параметры, и поэтому привожу итоговую картинку:
Первые две строчки в формате json — это нажатие на кнопку. Видно два события, одно из которых имеет значение member=ActionInvoked, второе member=NotificationClosed.
Третья и четвёртая строчки это принудительное закрытие и закрытие по тайм-ауту — у них также member=NotificationClosed и разницы между ними не вижу.
Также в сообщении есть полезная нагрузка (payload), где интересно содержимое data. У этого поля первое значение — идентификатор нашего сообщения. Т. к. мы прослушиваем шину и нам валятся все события Notify нашей сессии, то нужно реагировать только на события, связанные с нашим экземпляром уведомления. Для этого мы и запомнили идентификатор нотификации, когда запускали busctl. У сообщения ActionInvoked в data также светится идентификатор события (action), связанный с кнопкой.
Если обратили внимание, то я отдельно отметил важность вывода результатов в кодировке json в одну строку. Это значительно упрощает считывание событий из цикла while и дальнейший разбор полей. Конечно, парсить всё это безобразие мы можем с помощью всей мощи coreutils, но не хотим — просто привлечём для этого jq, специальную маленькую утилиту для работы с json из командной строки.
Вот пример, как получить значение member и data[1] из строчки, содержащей вывод busctl:
Согласитесь, получилось намного проще, чем вырезать всё это, например, с помощью регулярок sed-а.
Теперь можно приступать к сборке основного цикла:
busctl --user --json=short --match="type='signal',interface='org.freedesktop.Notifications'" monitor | while read
do
case $(echo $REPLY | jq -r .member) in
'ActionInvoked')
[ "x$(echo $REPLY | jq -r .payload.data[1])" == "x22066" ] && userpasswd
;;
'NotificationClosed')
pkill -P $$
;;
esac
done
Убийство всех потомков по приходу сигнала NotificationClosed выглядит нелепо, вдобавок цикл слегка смахивает на бесконечный, но у этого есть причины.
Можно просто поставить break и, теоретически, следует выход из цикла и завершение нашего скрипта. На самом деле цикл завершается, вот только busctl остаётся работать и реально выйдет по тайм-ауту (штатное значение 25 секунд). Лично мне не нравится, что скрипт, хоть и незаметно для пользователя, будет болтаться в памяти больше положенного.
Второй вариант — отловить PID процесса busctl и гасить его точечным ударом. Будет большая и некрасивая обвязка.
Ну и финальный вариант, как указал выше — убить сразу всех потомков нашего процесса, их всё равно не более одного ;) Цикл чтения завершится автоматически по причине закрытия stdin. Просто и эффективно.
▍ Пререлиз
Полный рабочий вариант привожу ниже. Здесь добавлен только контроль своих событий, чтобы случайно не завершиться, поймав сторонний сигнал.
#!/usr/bin/bash
ALARMDAYS=7
ADSRV="dc0.corporation.ru"
EXPDATE=($(LC_TIME=C rpcclient -N -c "queryuser $USER" $ADSRV | grep 'Password must change Time'))
[ ${#EXPDATE[@]} -eq 0 ] && exit
ALARMTTL=$(($(date -d "${EXPDATE[*]:5:5}" +%s) - $EPOCHSECONDS - $ALARMDAYS*86400))
[ $ALARMTTL -gt 0 ] && exit
MSGID=$(busctl --user --json=short call org.freedesktop.Notifications \
/org/freedesktop/Notifications org.freedesktop.Notifications Notify \
susssasa{sv}i "" 0 password "Ваш пароль истекает" "$(date -d "${EXPDATE[*]:5:5}" +%c)" \
2 "chpw" "изменить пароль" 0 10000 | jq -r .data[0])
busctl --user --json=short --match="type='signal',interface='org.freedesktop.Notifications'" monitor | while read
do
[ "x$(echo $REPLY | jq -r .payload.data[0])" != "x$MSGID" ] && continue
case $(echo $REPLY | jq -r .member) in
'ActionInvoked')
[ "x$(echo $REPLY | jq -r .payload.data[1])" == "xchpw" ] && userpasswd
;;
'NotificationClosed')
pkill -P $$
;;
esac
done
Скрипт размещаем, к примеру, в /usr/bin/xppwck.sh. В каталог /etc/xdg/autostart/ кладём файл pwchk.desktop с таким содержимым:
[Desktop Entry]
Type=Application
Name=User password expiration check
NoDisplay=true
Exec=/usr/bin/xppwck.sh
Чтобы навести окончательный лоск, в начале скрипта вместо двух переменных пишем source /etc/sysconfig/xppwck
, а эти переменные присваиваем именно в этом файле. Ну и, как полагается, собираем всё это благолепие в RPM-пакет и дарим самым хорошим пользователям.
▍ Результат
Контроль сроков истечения пароля реализован — нотификация ненавязчивая и удобная. Мне нравится.
Telegram-канал с розыгрышами призов, новостями IT и постами о ретроиграх