Заметки · 14.02.2025

Код красный: PXE, API и всякое

Внимание! Длиннопост!

Сегодняшний эпос готовился очень долго и даже пару раз перекраивался, менял дату публикации и объём. Сначала я планировал делать серию постов, но строить перекрёстные ссылки оказалось дольше и сложнее, чем склеить данную простыню из кода и текста. В общем, терпения Вам.

Кстати, если вдруг потребуется вернуться к какой-то определённой части поста, прикрутил простенькую навигацию по всему этому безобразию:

Часть I. Страсти по кикстарту.

Часть II. Тумблер для DHCP.

Часть III. API в перспективе.

Итак, Вашему вниманию (очень) долгий рассказ про мой опыт организации PXE-сервера для быстрого развёртывания операционной системы РЕД ОС 7.3 МУРОМ. Кроме запуска самого PXE-сервера, я поведаю как разместил в загрузчике кое-какие нужные инструменты, создал файл kickstart, а также организовал общий принцип настройки пользовательского пространства.

А ещё продемонстрирую собственную выключалку dhcp-сервера написанную на связке php и js. Немного коснусь создания маленького api-сервиса и его использования с помощью простой программы на python. И расскажу про создание и применение скриптов настройки системы и установки необходимых программ.

Черновики для этого триллера собирались в течении всего 2024 года. В целом, весь представленный здесь материал можно было бы счесть устаревшим ещё на старте подготовки публикаций, так как к тому моменту уже давно состоялся релиз РЕД ОС 8. Однако, в силу ряда причин я проводил все свои эксперименты исключительно с версией РЕД ОС 7.3 МУРОМ.

К прочтению в обязательном порядке: здесь очень подробная инструкция для создания PXE-сервера на основе сервера РЕД ОС, а здесь читаем про kickstart.

Помните — где-то тут я рассказывал про iVentoy и его магию? Можете даже не начинать его использовать для установки РЕД ОС. По-крайней мере, на версии 7.3 волшебство iVentoy отдыхает. Конечно, если разместить iso в папке образов iVentoy система попытается загрузиться. Терминал будет вести свой рассказ, но в какой-то момент сетевое соединение пропадёт и всё. Нет сети — нет pxe. Нет pxe — нет установки.

Поэтому я рекомендую создать отдельный PXE-сервер для установки системы и обращаться для этого исключительно к официальной документации дистрибутива (тык). И даже если все эти прописные истины (так или иначе) Вам уже знакомы…

В общем, как сказал один известный персонаж:

There is a difference between knowing the path and walking the path.

— Morpheus

Часть I. Страсти по кикстарту.

Прежде чем я приступил к созданию виртуального сервера для сетевой загрузки, я развернул установочный образ на флеш-носителе и загрузился с него. В моём случае для создания загрузочного носителя использовался Rufus. Целевой системой для загрузки была выбрана UEFI. Флеш-носитель записывался в режиме DD. Больше про установку как таковую ничего говорить не буду. Ознакомиться с доками можно здесь.

Как только Вы разметите диск, выберите часовой пояс, настроите ntp, зададите пароль для root, выберите имя системы и, наконец, определитесь с набором софта и окружения — можно начать установку. Все манипуляции, что вы проделали перед установкой системы — установщик бережно соберёт и запишет сюда:

/root/anaconda-ks.cfg

Рекомендую сразу после установки системы забрать этот файл и использовать в качестве шаблона для написания полноценного конфига развёртывания системы. Установочный флеш-накопитель больше не понадобиться. Следующая установка будет проходить уже с сервера.

Рассказывать про установку и первичную настройку сервера РЕД ОС 7.3 МУРОМ я не стану. Процесс установки не должен вызвать каких-либо проблем. Со своей стороны упомяну, что я проводил установку северной ОС без графического окружения на Hyper-V. Также опущу процесс создания самого PXE-сервера — в начале поста уже привёл ссылки на официальную документацию по его созданию.

Как только Вы полностью сконфигурировали сервер и запустили dhcp, tftp и httpd, а в качестве конфигов загрузки и меню использовали стандартные шаблоны из документации — начинается самое интересное. Подстройка всей этой красоты под свои нужды.

Передо мной стояла задача упростить установку операционной системы и максимально автоматизировать подготовку рабочих станций с учётом требований пользователей. Кроме того, нужно было и предусмотреть наличие в системе всех дополнительных программных пакетов и необходимых зависимостей для обеспечения работы в корпоративной среде.

Конечно, в идеале нужно провести тихую установку и ОС, и всего необходимого софта, а также обеспечить дальнейшую автоматизацию с помощью скриптов. Но это я слишком забегаю вперёд. Давайте обо всём по порядку.

Для начала предлагаю обратиться к моему варианту файла ../tftpboot/pxelinux.cfg/default:

default menu.c32
PROMPT 0
TIMEOUT 150
MENU TITLE PXE Menu

LABEL REDOS 7.3
MENU LABEL REDOS 7.3
KERNEL images/REDOS/images/pxeboot/vmlinuz
APPEND initrd=images/REDOS/images/pxeboot/initrd.img ramdisk_size=128000 ip=dhcp inst.repo=http://192.168.1.100/images/REDOS inst.ks=http://192.168.1.100/images/CONF/anaconda-ks.cfg

LABEL GPARTED
MENU LABEL GPARTED
KERNEL images/GPARTED/live/vmlinuz
APPEND initrd=images/GPARTED/live/initrd.img username=user boot=live union=overlay config components noswap noeject noprompt vga=788 fetch=http://192.168.1.100/images/GPARTED/live/filesystem.squashfs

LABEL MEMTEST
MENU LABEL MEMTEST
KERNEL images/MEMTEST/memtest64.bin
APPEND

LABEL CLONEZILLA
MENU LABEL CLONEZILLA
KERNEL images/CLONEZILLA/live/vmlinuz
APPEND initrd=images/CLONEZILLA/live/initrd.img boot=live username=user union=overlay config components quiet noswap edd=on nomodeset nodmraid locales= keyboard-layouts= ocs_live_run="ocs-live-general" ocs_live_extra_param="" ocs_live_batch=no net.ifnames=0 nosplash noprompt fetch=http://192.168.1.100/images/CLONEZILLA/live/filesystem.squashfs

Этот файл отвечает за меню и загрузку в режиме legacy. Обратите внимание, что здесь указаны пути к общей иерархии загрузчиков: целевой РЕД ОС, а также добавленных мною GParted (здесь инструкция по размещению образа на pxe), утилиты для диагностики ОЗУ MemTest и драгоценной CloneZilla.

В случае с MemTest предлагаю скачать вот этот пакет. Кстати, обращаю Ваше внимание, что настройки по загрузке MemTest самые простые.

Теперь очередь uefi../tftpboot/uefi/grub.cfg:

function load_video {
    insmod efi_gop
    insmod efi_uga
    insmod video_bochs
    insmod video_cirrus
    insmod all_video
}

load_video
set gfxpayload=keep
insmod gzio

menuentry 'REDOS 7.3' {
    linux images/REDOS/images/pxeboot/vmlinuz ip=dhcp kernel vmlinuz inst.ks=http://192.168.1.100/images/CONF/anaconda-ks.cfg  inst.repo=http://192.168.1.100/images/REDOS
    initrd images/REDOS/images/pxeboot/initrd.img
}

menuentry 'GPARTED' {
    linux images/GPARTED/live/vmlinuz  username=user boot=live union=overlay config components noswap noeject noprompt vga=788 fetch=http://192.168.1.100/images/GPARTED/live/filesystem.squashfs
    initrd images/GPARTED/live/initrd.img
}

menuentry 'MEMTEST' {
    linux images/MEMTEST/memtest64.efi
}

menuentry 'CLONEZILLA' {
    linux images/CLONEZILLA/live/vmlinuz boot=live username=user union=overlay config components quiet noswap edd=on nomodeset nodmraid locales= keyboard-layouts= ocs_live_run="ocs-live-general" ocs_live_extra_param="" ocs_live_batch=no net.ifnames=0 nosplash noprompt fetch=http://192.168.1.100/images/CLONEZILLA/live/filesystem.squashfs
	initrd images/CLONEZILLA/live/initrd.img
}

По сути, это та же менюшка, но уже для загрузки в режиме uefi. Принцип наполнения самих файлов абсолютно идентичен. Отличия только в синтаксисе.

Для наглядности — иерархию конфигураций и образов я построил так:

К любой из данных директорий и её содержимому можно обратиться по протоколу http и выдернуть любой файл с сервера, например, с помощью wget.

Cамое время взглянуть на anaconda-ks.cfg, отвечающий за автоматизацию установки системы:

# Generated by Anaconda 33.25.4
# Generated by pykickstart v3.30
#version=F33

# Графическая среда установщика
graphical

# Установка пакетов графического рабочего окружения
%packages
@^desktop-environment
%end

# Раскладка клавиатуры
keyboard --xlayouts='ru','us' --switch='grp:alt_shift_toggle'

# Язык системы
lang ru_RU.UTF-8

# Установка имени хоста
network  --hostname=redos

# Сетевая установка
url --url="http://192.168.1.100/images/REDOS"

# Отключение первоначальной настройки
firstboot --disable

# Принятие лицензионного соглашения
eula --agreed

# Использование только sda
ignoredisk --only-use=sda

# Удаляем все существующие разделы
clearpart --all --initlabel

# Авторазметка диска
autopart

# NTP сервера
timesource --ntp-server=ntp1.vniiftri.ru
timesource --ntp-server=ntp2.vniiftri.ru
timesource --ntp-server=ntp3.vniiftri.ru
timesource --ntp-server=ntp4.vniiftri.ru
timesource --ntp-server=192.168.1.1
timesource --ntp-server=192.168.1.100

# Часовой пояс
timezone Europe/Astrakhan --utc

# Установка пароля root
rootpw --iscrypted ЗДЕСЬХЭШПАРОЛЯ

# Создание пользователя admin
#user --groups=wheel --name=admin --password=ЗДЕСЬТОЖЕХЭШПАРОЛЯ --iscrypted --gecos="admin"

%anaconda
pwpolicy root --minlen=6 --minquality=1 --notstrict --nochanges --notempty
pwpolicy user --minlen=6 --minquality=1 --notstrict --nochanges --emptyok
pwpolicy luks --minlen=6 --minquality=1 --notstrict --nochanges --notempty
%end

# Послеустановочные скрипты
%post

# Включение SSH
systemctl enable sshd
systemctl start sshd

# Отключение IPv6
echo "net.ipv6.conf.all.disable_ipv6 = 1" >> /etc/sysctl.conf
echo "net.ipv6.conf.default.disable_ipv6 = 1" >> /etc/sysctl.conf

# Настройка DNS-серверов
echo "nameserver 192.168.1.1" >> /etc/resolv.conf
echo "nameserver 192.168.1.100" >> /etc/resolv.conf
echo "nameserver 8.8.8.8" >> /etc/resolv.conf
echo "nameserver 8.8.4.4" >> /etc/resolv.conf

# Применяем настройки
sysctl -p

# Очистка кэша репозиториев
dnf clean all

# Обновление всех пакетов
dnf update -y

# Создание кэша репозиториев
dnf makecache

# Включение автоматических обновлений
dnf install -y dnf-automatic
systemctl enable dnf-automatic.timer

# Установка программы обновления скриптов
updatepath="/scripts/update.py"
updateurl="http://192.168.1.100/update.py"
mkdir -p /scripts
wget -q "$updateurl" -O "$updatepath"
if [ -f "$updatepath" ]; then
    chmod +x "$updatepath"
    "$updatepath"
fi

# Настройка root SSH
CONFIG_FILE="/etc/ssh/sshd_config.d/01-permitrootlogin.conf"
if [ -f "$CONFIG_FILE" ]; then
    rm "$CONFIG_FILE"
fi

# Отключение окна приветсвия
URL="http://192.168.1.100/images/CONF/createnorunflag.py"
DESTINATION="/usr/local/bin/createnorunflag.py"
PROFILE="/etc/profile"
if wget -O "$DESTINATION" "$URL"; then
    chmod +x "$DESTINATION"
    if ! grep -q '/usr/bin/python3 /usr/local/bin/createnorunflag.py' "$PROFILE"; then
        echo "/usr/bin/python3 /usr/local/bin/createnorunflag.py" >> "$PROFILE"
    fi
fi

# Блокировка флэш-накопителей
RULES_FILE="/etc/udev/rules.d/99-usb.rules"
echo 'ENV{ID_USB_DRIVER}=="usb-storage",ENV{UDISKS_IGNORE}="1"' > "$RULES_FILE"
udevadm control --reload-rules

# Настройка панели MATE
panelpath="/usr/share/mate-panel/layouts"
panelurl="http://192.168.1.100/images/CONF/ventana.layout"
wget -q $panelurl -O $panelpath/ventana.layout

# Перевод SELinux в молчаливый режим и его отключение
sed -i "s/SELINUX=enforcing/SELINUX=disabled/" /etc/selinux/config
setenforce 0

# Уменьшение таймаута загрузки grub
if [ -f /etc/default/grub ]; then
    sed -i 's/^GRUB_TIMEOUT=.*/GRUB_TIMEOUT=0/' /etc/default/grub
    if ! grep -q '^GRUB_HIDDEN_TIMEOUT_QUIET=true' /etc/default/grub; then
        echo "GRUB_HIDDEN_TIMEOUT_QUIET=true" >> /etc/default/grub
    fi
    if ! grep -q '^GRUB_HIDDEN_TIMEOUT=0' /etc/default/grub; then
        echo "GRUB_HIDDEN_TIMEOUT=0" >> /etc/default/grub
    fi
    sed -i 's/^\(GRUB_THEME=.*\)/#\1/' /etc/default/grub
    grub2-mkconfig -o /boot/grub2/grub.cfg
fi

%end

Пойду по порядку чтения конфигурационного файла. Обратите внимание на директиву graphical. Эту директиву породила родная анакондушка и она отвечает не только за графическое окружение установщика, как подумалось мне. Она отвечает за наличие графического окружения рабочего стола во всей операционной системе (возможно данное поведение встречается только в РЕД ОС).

Если Вы удалите замените директиву graphical на text, то после установки Вы получите операционку с консольным типом управления. Конечно можно накатить окружение и отдельно, но мы собрались здесь, чтобы упрощать себе работу…

Пропустим блок %packages, минуем настройки раскладки клавиатуры и языка системы. Это всё можно оставить без изменений, а теперь вносим изменения в директиву network:

network  --hostname=redos

Здесь можно задать настройки сети, но в моём случае я ограничился только именем хоста. Следом встречаем директиву url, которая жаждет получить прямой путь к папке с установщиком. Передаем ей ссылку на папку с дистрибутивом:

url --url="http://192.168.1.100/images/REDOS"

Для того чтобы сразу после установки системы мы попадали на экран авторизации, необходимо отключить функцию первоначальной настройки и автоматизировать согласие с EULA:

# Отключение первоначальной настройки
firstboot --disable

# Принятие лицензионного соглашения
eula --agreed

Двигаемся к разметке диска хоста. Мне требовалось настроить установщик таким образом, чтобы он полностью очищал системный диск и проводил авторазметку:

# Использование только sda
ignoredisk --only-use=sda

# Удаляем все существующие разделы
clearpart --all --initlabel

# Авторазметка диска
autopart

Таким образом, диск будет полностью очищен от всех томов и текущих файловых систем.

Далее идут настройки времени системы. Это самая простая настройка. Я думаю, останавливаться на ней особо смысла нет. Всё, что нужно — задать корректные сервера точного времени и указать свой часовой пояс.

В блоке настройки пользователей я ничего не трогал, но на всякий случай добавил шаблон для создания пользователя. К сожалению, я не проводил тестирование методов создания шифрованных паролей без использования установщика. Хеши паролей я получил уже готовыми, когда проводил первичную установку операционной системы с флеш-накопителя.

Для автоматизации получения зашифрованного пароля можно попробовать воспользоваться этим скриптом:

#!/usr/bin/python3

import sys
import crypt

if len(sys.argv) != 2:
    print("Использование: cryptpass.py <your_password>")
    sys.exit(1)

password = sys.argv[1]
salt = crypt.mksalt(crypt.METHOD_SHA512)
hashed_password = crypt.crypt(password, salt)

print(hashed_password)

Модуль crypt устарел и будет удалён из Python версии 3.13. Будьте внимательны! Говорят, что весь функционал можно заменить пакетом passlib. Не проверял.

Общий лейтмотив для написания скрипта я почерпнул здесь. Во вселенной файловой системы моего PXE-сервера этот скрипт именуется cryptpass.py и живёт здесь:

/scripts/cryptpass.py

Разумеется, потребуется дать ему права исполняемого файла:

chmod +x /scripts/cryptpass.py

Работать со скриптом можно напрямую из терминала просто передав ему пароль, который необходимо защифровать:

/scripts/cryptpass.py ЭтоОченьСложныйПароль

Выполнив эту команду, в терминал вернётся хешированный пароль. Теоретически — должно сработать.

Вернёмся к конфигурации кикстарта. У нас остался блок %post. Сюда можно запихнуть любые постустановочные команды на родном и близком bash. Здесь Вас ограничивает только фантазия. К примеру, можно установить необходимые пакеты, настроить записи о dns-серверах, внести изменения в конфигурационные файлы операционной системы, отключить ipv6, поколдовать с системными демонами и многое-многое другое.

У меня %post выглядит так:

# Послеустановочные скрипты
%post

# Включение SSH
systemctl enable sshd
systemctl start sshd

# Отключение IPv6
echo "net.ipv6.conf.all.disable_ipv6 = 1" >> /etc/sysctl.conf
echo "net.ipv6.conf.default.disable_ipv6 = 1" >> /etc/sysctl.conf

# Настройка DNS-серверов
echo "nameserver 192.168.1.1" >> /etc/resolv.conf
echo "nameserver 192.168.1.100" >> /etc/resolv.conf
echo "nameserver 8.8.8.8" >> /etc/resolv.conf
echo "nameserver 8.8.4.4" >> /etc/resolv.conf

# Применяем настройки
sysctl -p

# Очистка кэша репозиториев
dnf clean all

# Обновление всех пакетов
dnf update -y

# Создание кэша репозиториев
dnf makecache

# Включение автоматических обновлений
dnf install -y dnf-automatic
systemctl enable dnf-automatic.timer

# Установка программы обновления скриптов
updatepath="/scripts/update.py"
updateurl="http://192.168.1.100/update.py"
mkdir -p /scripts
wget -q "$updateurl" -O "$updatepath"
if [ -f "$updatepath" ]; then
    chmod +x "$updatepath"
    "$updatepath"
fi

# Настройка root SSH
CONFIG_FILE="/etc/ssh/sshd_config.d/01-permitrootlogin.conf"
if [ -f "$CONFIG_FILE" ]; then
    rm "$CONFIG_FILE"
fi

# Отключение окна приветствия
URL="http://192.168.1.100/images/CONF/createnorunflag.py"
DESTINATION="/usr/local/bin/createnorunflag.py"
PROFILE="/etc/profile"
if wget -O "$DESTINATION" "$URL"; then
    chmod +x "$DESTINATION"
    if ! grep -q '/usr/bin/python3 /usr/local/bin/createnorunflag.py' "$PROFILE"; then
        echo "/usr/bin/python3 /usr/local/bin/createnorunflag.py" >> "$PROFILE"
    fi
fi

# Блокировка флэш-накопителей
RULES_FILE="/etc/udev/rules.d/99-usb.rules"
echo 'ENV{ID_USB_DRIVER}=="usb-storage",ENV{UDISKS_IGNORE}="1"' > "$RULES_FILE"
udevadm control --reload-rules

# Настройка панели MATE
panelpath="/usr/share/mate-panel/layouts"
panelurl="http://192.168.1.100/images/CONF/ventana.layout"
wget -q $panelurl -O $panelpath/ventana.layout

# Перевод SELinux в молчаливый режим и его отключение
sed -i "s/SELINUX=enforcing/SELINUX=disabled/" /etc/selinux/config
setenforce 0

# Уменьшение таймаута загрузки grub
if [ -f /etc/default/grub ]; then
    sed -i 's/^GRUB_TIMEOUT=.*/GRUB_TIMEOUT=0/' /etc/default/grub
    if ! grep -q '^GRUB_HIDDEN_TIMEOUT_QUIET=true' /etc/default/grub; then
        echo "GRUB_HIDDEN_TIMEOUT_QUIET=true" >> /etc/default/grub
    fi
    if ! grep -q '^GRUB_HIDDEN_TIMEOUT=0' /etc/default/grub; then
        echo "GRUB_HIDDEN_TIMEOUT=0" >> /etc/default/grub
    fi
    sed -i 's/^\(GRUB_THEME=.*\)/#\1/' /etc/default/grub
    grub2-mkconfig -o /boot/grub2/grub.cfg
fi

%end

Логически я разбил его на субблоки и снабдил каждый из них соответствующим комментарием. В шапке блока я включаю SSH, отключаю IPv6, настраиваю DNS-сервера, включаю автоматические обновления системы.

Далее начинаются развлечения со скриптами и редактированием общих настроек установленной системы. Прежде всего я размещаю в структуре системы программу для обновления скриптов, конфигурационных файлов и дистрибутивов. Об этой программе поговорим чуть позже.

Следующий этап это разрешение работы пользователя root в ssh-сессии. В дальнейшем это можно отключить, но на данном этапе я счёл данный ход вполне оправданным.

Двигаясь ниже по постустановке, я отключаю окно приветствия операционной системы и блокирую монтирование флэш-накопителей (тык). Предвосхищая вопросы — да, при такой блокировке накопителей, токены ЭЦП продолжают работать.

Думаю, что об отключении окна приветствия нужно рассказать более подробно. Вот этот франгмент:

# Отключение окна приветствия
URL="http://192.168.1.100/images/CONF/createnorunflag.py"
DESTINATION="/usr/local/bin/createnorunflag.py"
PROFILE="/etc/profile"
if wget -O "$DESTINATION" "$URL"; then
    chmod +x "$DESTINATION"
    if ! grep -q '/usr/bin/python3 /usr/local/bin/createnorunflag.py' "$PROFILE"; then
        echo "/usr/bin/python3 /usr/local/bin/createnorunflag.py" >> "$PROFILE"
    fi
fi

Данный «кирпичик» скачивает с сервера скрипт createnorunflag.py, размещает среди системных команд и добавляет директиву для его выполнения в файл /etc/profile.

Так я обеспечил, чтобы каждый раз при входе пользователя выполнялся код указанного py-скрипта. Содержимое файла createnorunflag.py:

import os
flag_path = os.path.expanduser("~/.redoswelcome/norun.flag")
flag_dir = os.path.dirname(flag_path)
if not os.path.exists(flag_dir):
    os.makedirs(flag_dir)
if not os.path.exists(flag_path):
    with open(flag_path, 'w') as f:
        f.write('This flag file was created on first run.\n')

Этот скрипт создаёт «файл-флажок«, наличие которого проверяет утилита redoswelcome перед тем, как запуститься. Иными словами, если файл ~/.redoswelcome/norun.flag нашёлся — значит окна настроек и конфигурирования системы Вы не увидите.

В файле кикстарта я предусмотрел также и отключение SELinux, но это уже вопрос Вашего подхода к обеспечению безопасности системы и контроля доступа. Возможно, что Вам этот субблок и не понадобится.

Что касается настройки панели задач графического окружения MATE, то здесь я тоже приложил руку. Например, удалил с панели виджет (апплет?) управления рабочими пространствами, чтобы пользователи не заплутали, а также разместил ярлыки для запуска файлового менеджера Caja и браузера Chromium. Остальные апплеты расставил в привычном порядке.

Содержимое файла ventana.layout, который отвечает за конфигурацию панели задач:

[Toplevel bottom]
expand=true
orientation=bottom
size=32

[Object show-desktop]
object-type=applet
applet-iid=WnckletFactory::ShowDesktopApplet
toplevel-id=bottom
position=0
panel-right-stick=true
locked=true

[Object menu-bar]
object-type=applet
applet-iid=BriskMenuFactory::BriskMenu
toplevel-id=bottom
position=1
locked=true

[Object clock]
object-type=applet
applet-iid=ClockAppletFactory::ClockApplet
toplevel-id=bottom
position=2
panel-right-stick=true
locked=true

[Object menu-separator]
object-type=separator
toplevel-id=bottom
position=3
locked=true

[Object file-browser]
object-type=launcher
launcher-location=/usr/share/applications/caja-browser.desktop
toplevel-id=bottom
position=4
locked=true

[Object web-browser]
object-type=launcher
launcher-location=/usr/share/applications/chromium-browser.desktop
toplevel-id=bottom
position=5
locked=true

[Object window-list]
object-type=applet
applet-iid=WnckletFactory::WindowListApplet
toplevel-id=bottom
position=6
locked=true

[Object notification-area]
object-type=applet
applet-iid=NotificationAreaAppletFactory::NotificationArea
toplevel-id=bottom
position=7
panel-right-stick=true
locked=true

[Object st-separator]
object-type=separator
toplevel-id=bottom
position=8
panel-right-stick=true
locked=true

Именно этот файл будет размещён в системе с помощью вот этого фрагмента в кикстарте:

# Настройка панели MATE
panelpath="/usr/share/mate-panel/layouts"
panelurl="http://192.168.1.100/images/CONF/ventana.layout"
wget -q $panelurl -O $panelpath/ventana.layout

Закрывая тему постустановочного кордебалета, скажу, что я совсем скрыл меню загрузчика системы GRUB:

# Уменьшение таймаута загрузки grub
if [ -f /etc/default/grub ]; then
    sed -i 's/^GRUB_TIMEOUT=.*/GRUB_TIMEOUT=0/' /etc/default/grub
    if ! grep -q '^GRUB_HIDDEN_TIMEOUT_QUIET=true' /etc/default/grub; then
        echo "GRUB_HIDDEN_TIMEOUT_QUIET=true" >> /etc/default/grub
    fi
    if ! grep -q '^GRUB_HIDDEN_TIMEOUT=0' /etc/default/grub; then
        echo "GRUB_HIDDEN_TIMEOUT=0" >> /etc/default/grub
    fi
    sed -i 's/^\(GRUB_THEME=.*\)/#\1/' /etc/default/grub
    grub2-mkconfig -o /boot/grub2/grub.cfg
fi

Данная мера была направлена на ускорение и упрощение загрузки операционной системы.

На этом с файлом anaconda-ks.cfg всё. Теперь можно спокойно загружаться с сервера, выбрав сетевой адаптер основным загрузочным устройством и любоваться полностью автоматизированным процессом установки операционной системы. Как только система будет установлена перед Вами предстанет экран авторизации.

Очень важный момент: убедитесь, что в меню загрузки PXE-сервера были заданы корректные пути, как к установочным файлам, так и к файлу anaconda-ks.cfg. Иначе чуда не произойдёт.

Думали, что это конец истории? Извините, но нет. Предлагаю двигаться дальше…

Часть II. Тумблер для DHCP.

Изначально в инфраструктуре планировалось использовать два независимых PXE-сервера: один с нашим старым знакомым, увесистой пачкой iso-образов и коллекцией расшаренных папок с софтом, а второй должен отвечать исключительно за развёртывание РЕД ОС и администрирование рабочих станций с ней.

И вот, чтобы на этапе загрузки компьютера с сетевого адаптера не допустить получения ip-адреса «не с того PXE«, я решил написать простейший тумблер для управления dhcp прямо с индексной веб-странички. Веб-сервер так и так уже установлен в системе, а для полного комплекта не хватает только php.

Напоминаю, что в РЕД ОС служба веб-сервера Apache называется httpd (в отличии от ubuntu, где она же называется apache2).

Забегая вперёд — покажу внешний вид творения:

На страничке всего одна кнопка, которая отвечает за запуск и остановку демона dhcp, а также навигационные ссылки для прыжков по ресурсам сервера. Нечто размытое второй строкой — это данные об имени сервера и его ip-адрес. Вся страничка обновляется «на лету» в динамическом режиме.

Эта красота логически состоит из 7 файлов, считая favicon (можно использовать любой png-файл), js-скрипт и файл css. Начну с файла index.php:

<?php 
    $title = "Управление DHCP-сервером";
    $serverip = $_SERVER['SERVER_ADDR'];
    $webminport = 10000;
?>
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="icon" href="favicon.png" type="image/png">
    <link type="text/css" rel="stylesheet" href="style.css">
    <title><?php echo $title; ?></title>
    <script src="script.js" defer></script>
</head>
<body>
    <h1><?php echo $title; ?></h1>
    <div id="serverInfo" class="status"></div>
    <div id="status" class="status"></div>
    <div id="message" class="status"></div>
    <button id="toggleButton" title="Переключить состояние DHCP-сервера">Переключить состояние</button>
    <div id="server">
		<a href="../images/" target="_blank" title="Открыть страницу сервера">http</a>
		| 
		<a href="../images/CONF/anaconda-ks.cfg" target="_blank" title="Файл кикстарта" download>kickstart</a>
		| 
		<a href="../api.php" target="_blank" title="API для вывода перечня скриптов">api</a>
		| 
		<a href="../update.py" target="_blank" title="Обновление скриптов конфигурирования" download>update</a>
		| 
		<a href="https://<?php echo $serverip; ?>:<?php echo $webminport; ?>" target="_blank" title="Webmin">webmin</a>
	</div>
    <div id="author">Автор: <a href="https://ngdream.ru" target="_blank" title="СерЁжкин код">ngdream.ru</a></div>
</body>
</html>

Оформление страницы в структуре style.css выглядит так:

body 
{
  font-family:Arial,sans-serif;
  background-color:#f0f0f0;
  color:#333;
  text-align:center;
  padding:50px;
}

.status 
{
  margin:20px 0;
  font-size:24px;
}

button 
{
  background-color:#4CAF50;
  border:none;
  color:white;
  padding:15px 32px;
  text-align:center;
  text-decoration:none;
  display:inline-block;
  font-size:16px;
  margin:4px 2px;
  cursor:pointer;
  border-radius:10px;
  transition:background-color 0.3s ease;
}

button:hover 
{
  background-color:#CD5C5C;
}

#author 
{
  margin-top:auto;
  padding:20px 0;
  font-size:10pt;
  color: #999;
}

#author a 
{
  color:#999;
  text-decoration:none;
}

#author a:hover 
{
  color:#CD5C5C;
}

#server 
{
  margin-top:auto;
  padding:20px 0;
  font-size:10pt;
  color: #333;
}

#server a 
{
  color:#333;
  text-decoration:none;
}

#server a:hover 
{
  color:#CD5C5C;
}

#serverInfo 
{
  margin-bottom:30px;
  font-size:11pt;
  color:#333;
}

И, наконец, основной элемент — script.js, который отвечает за автоматизацию работы всего этого хулиганства:

// Переменные
   let isDhcpActive;
   let serverName = '';
   let serverIP = '';

// Функция для обновления статуса службы DHCP-сервера
function updateStatus() 
{
    fetch('get_dhcp_status.php')
    .then(response => response.text())
    .then(status => {
	// Сравнение полученного статуса с 'active' для определения состояния
        isDhcpActive = (status.trim() === 'active');
		
	// Установка текста статуса на странице в зависимости от состояния
        const statusText = isDhcpActive ?
        'Статус DHCP-сервера: <strong style="color:#4CAF50;">Active</strong>' :
        'Статус DHCP-сервера: <strong style="color:#CD5C5C;">Inactive</strong>';
        document.getElementById('status').innerHTML = statusText;
		
	// Обновление текста и стиля кнопки в зависимости от статуса DHCP
        const toggleButton = document.getElementById('toggleButton');
        if (isDhcpActive) 
	{
	    // Текст кнопки
            toggleButton.innerText = 'Остановить сервер';
	    // Цвет кнопки для остановки
            toggleButton.style.backgroundColor = '#CD5C5C'; 
	    // Цвет при наведении
	    toggleButton.onmouseover = function() 
	    { toggleButton.style.backgroundColor = '#4f0014'; };
	    // Возвращение к исходному
            toggleButton.onmouseout = function() 
	    { toggleButton.style.backgroundColor = '#CD5C5C'; };
        } 
	else
	{
	    // Текст кнопки
            toggleButton.innerText = 'Запустить сервер';
	    // Цвет кнопки для запуска
            toggleButton.style.backgroundColor = '#4CAF50'; 
	    // Цвет при наведении
            toggleButton.onmouseover = function() 
	    { toggleButton.style.backgroundColor = '#004524'; };
	    // Возвращение к исходному
            toggleButton.onmouseout = function() 
	    { toggleButton.style.backgroundColor = '#4CAF50'; };
        }
    });
}

// Функция для получения информации о сервере
function updateServerInfo() 
{
    // Отправка запроса для получения информации о сервере
    fetch('get_server_info.php')
    // Обработка ответа как JSON
    .then(response => response.json())
    .then(data => {
	// Сохранение имени сервера
        serverName = data.name;
	// Сохранение IP-адреса сервера
        serverIP = data.ip;
	// Обновление заголовка страницы и отображение информации о сервере
        document.title = `Управление DHCP-сервером на ${serverName} (${serverIP})`;
        document.getElementById('serverInfo').innerHTML = `${serverName} (${serverIP})`;
    });
}

// Функция для переключения состояния DHCP-сервиса
function toggleDHCP() 
{
    // Отправка POST-запроса для переключения состояния DHCP
    fetch('toggle_dhcp.php', 
	{
	  method: 'POST',
	  // Передача параметра для переключения
         body: new URLSearchParams('toggle=true')
        })
    // Обработка ответа как текст
    .then(response => response.text())
    .then(message => {
	// Отображение сообщения
        document.getElementById('message').innerHTML = message;
	// Обновление статуса после переключения
        updateStatus();
    });
}

// Очистка действий при загрузке страницы
window.onload = function() 
{
    // Информации о сервере
    updateServerInfo();
    // Статус DHCP-сервера
    updateStatus();
    document.getElementById('toggleButton').addEventListener('click', function(event)
    {
	// Предотвращение стандартного поведения кнопки
        event.preventDefault();
	// Вызов функции для переключения службы DHCP-сервера
        toggleDHCP();
    });
};

За получение текущего статуса демона dhcp отвечает скрипт get_dhcp_status.php с самым кратким содержанием:

<?php
   echo trim(shell_exec("systemctl is-active dhcpd"));
?>

За информацию о сервере ответственен скрипт get_server_info.php:

<?php
  $serverName = gethostname();
  $serverIP = $_SERVER['SERVER_ADDR'];
  echo json_encode(['name' => $serverName, 'ip' => $serverIP]);
?>

Непосредственным управлением dhcpd занимается — toggle_dhcp.php:

<?php
$dhcp_service_status = shell_exec("systemctl is-active dhcpd");

if ($_SERVER['REQUEST_METHOD'] === 'POST') 
{
    if (isset($_POST['toggle'])) {
        if (trim($dhcp_service_status) === 'active') 
        {
            shell_exec("sudo systemctl stop dhcpd");
            #echo "DHCP-сервис отключен.";
        } 
        else 
        {
            shell_exec("sudo systemctl start dhcpd");
            #echo "DHCP-сервис включен.";
        }
    }
}
?>

В скрипте toggle_dhcp.php мною закомментированы отладочные команды echo. Пусть полежат здесь на всякий случай.

Базовый функционал управления демоном есть, но в таком виде он не более чем набор скриптов, так как чтобы управлять службами в Linux нужно иметь права для выполнения команд от имени суперпользователя. В том числе с использованием команды sudo.

Чтобы магия заработала нужно узнать от чьего имени запускается веб-сервер разрешить этому пользователю управлять демоном dhcpd.

Узнать имя пользователя от которого запущен веб-сервер можно так:

ps aux | grep httpd

В моём случае веб-сервер запущен от имени пользователя apache. Теперь можно отредактировать файл sudoers так, чтобы пользователь мог выполнять только определённый набор команд.

Открываем файл в nano:

sudo nano /etc/sudoers

Добавляем туда строку:

apache ALL=(ALL) NOPASSWD: /bin/systemctl stop dhcpd, /bin/systemctl start dhcpd

Сохраняемся и покидаем редактор.

Теперь пользователь apache сможет управлять состоянием dhcpd и веб-мордашка будет выполнять предполагаемый функционал. В перечень дополнительных ссылок можно добавить что-то своё, поколдовав в div id=“server”.

Часть III. API в перспективе.

Когда я только начинал создавать загрузочный сервер и формировать рабочие станции, я не мог даже примерно представить количество скриптов, которые мне потребуются. Изначально планировался лишь один скрипт — settings.sh, но в последствии метод моноскриптинга себя не зарекомендовал. Пришлось создавать целый каскад скриптов и конфигураций.

Следующая проблема возникла в процессе тестирования и отладки скриптов. Я не продумал метод обновления файлов, в которые приходилось вносить изменения, а каждый раз вызывать wget в мои планы не входило.

Прежде чем начинать разработку программы обновления, я решил создать скрипт api.php, который бы мог считывать на сервере указанные мною папки с исходными файлами и формировать простой JSON:

<?php
header('Content-Type: application/json');

// Определяем IP-адрес сервера
$serverip = $_SERVER['SERVER_ADDR'];

// Пути к папкам
// Для расширения JSON - указать путь к новой папке
$directories = [
    'SCRIPTS' => '/var/lib/tftpboot/images/SCRIPTS',
    'SOFT' => '/var/lib/tftpboot/images/SOFT',
    '1C' => '/var/lib/tftpboot/images/1C',
    'DESKTOP' => '/var/lib/tftpboot/images/DESKTOP'
];

$baseUrl = 'http://' . $serverip . '/images/';

// Инициализируем массив для хранения списка файлов
$fileList = [];

// Проходим по всем директориям
foreach ($directories as $label => $directory) {
    // Проверяем, существует ли директория
    if (!is_dir($directory)) {
        echo json_encode(['error' => "Директория $label не найдена"]);
        exit;
    }

    // Получаем список файлов
    $files = array_diff(scandir($directory), array('..', '.'));

    // Массив для файла этой директории
    $fileList[$label] = [];
    
    foreach ($files as $file) {
        // Полный путь к файлу
        $filePath = $directory . '/' . $file;

        // Проверяем, является ли файлом (а не директорией)
        if (is_file($filePath)) {
            // Добавляем информацию о файле в массив
            $fileList[$label][] = [
                'name' => $file,
                'size' => filesize($filePath),
                'modified' => date("Y-m-d H:i:s", filemtime($filePath)),
                'checksum' => hash_file('sha256', $filePath),
                'url' => $baseUrl . $label . '/' . urlencode($file)
            ];
        }
    }
}

// Возвращаем список файлов в формате JSON
echo json_encode($fileList);
?>

В дальнейшем в этот скрипт можно с лёгкостью добавить необходимые папки или убрать те, что уже утратили свою актуальность. Для этого достаточно подстроить под свои нужды массив в переменной $directories. Чуть выше я приводил пример иерархии моего PXE-сервера. Можете ознакомиться с перечнем директорий, которые попали в карусель api, а какие нет.

Обратившись по http к этому скрипту можно получить файл с вот такой структурой и, примерно, вот такого вида:

{
  "SCRIPTS": [
    {
      "name": "settings.sh",
      "size": 3034,
      "modified": "2024-11-14 10:24:44",
      "checksum": "1355a7389c7d03c2283ad953c3e7b0e2f189762025e67875bc4f97609b097327",
      "url": "http://192.168.1.100/images/SCRIPTS/settings.sh"
    }
  ],
  "SOFT": [
    {
      "name": "librtpkcs11ecp.rpm",
      "size": 2388828,
      "modified": "2024-11-12 09:40:51",
      "checksum": "18967652980a16d0e8633ab74f95601bcfa2f7b9152c814690689f03883b552a",
      "url": "http://192.168.1.100/images/SOFT/librtpkcs11ecp.rpm"
    }
  ],
  "1C": [
    {
      "name": "1cestart.cfg",
      "size": 59,
      "modified": "2024-11-13 11:16:54",
      "checksum": "48c4c627cfda40cabdf948c8a7b2860b9c822677f325ee762a6d122274a076ac",
      "url": "http://192.168.1.100/images/1C/1cestart.cfg"
    },
    {
      "name": "base.v8i",
      "size": 240,
      "modified": "2024-11-13 10:20:57",
      "checksum": "4b4ef9991af995e904d7ab4374d99364bb2ce2670b4454210e4bb4f1eb26033c",
      "url": "http://192.168.1.100/images/1C/hospital_pr1.v8i"
    }
  ],
  "DESKTOP": [
    {
      "name": "chromium.desktop",
      "size": 13065,
      "modified": "2024-11-14 10:25:49",
      "checksum": "374a2df53ef3060ed9ce8c344cb6c3cd40b692c5a7556cc2f47634d79622b663",
      "url": "http://192.168.1.100/images/DESKTOP/chromium.desktop"
    },
    {
      "name": "share.desktop",
      "size": 128,
      "modified": "2024-11-14 11:13:44",
      "checksum": "3035de924d83798929cf019a718636ea8905bc1554ce06b210309e5e9d4c9955",
      "url": "http://192.168.1.100/images/DESKTOP/share.desktop"
    }
  ]
}

Наполнение файла зависит исключительно от содержимого папок, объявленных в файле api.php. Думаю, по форматам файлов понятно для каких целей служат те или иные файлы.

Логически я поделил данные в JSON на те, с которыми в дальнейшем будет работать программа обновления и те, которые были бы полезны для сбора статистических данных. Обновление файлов можно построить на имени, url-адресе и расчете контрольной суммы, а вот размер и дата модификации файлов — пригодятся для формирования статлиста.

Код моей программы update.py для обновления скриптов и конфигов:

#!/usr/bin/python3

import os
import glob
import requests
import hashlib
import stat
import shutil
import subprocess

# URL API для получения JSON
api_url = "http://192.168.1.100/api.php"

# Локальные директории для сохранения файлов
local_directory_scripts = "/scripts"
local_directory_soft = "/distrib"
local_directory_1c = "/1c_config"
local_directory_desktop = "/desktop"

# Функция удаления файлов и директорий, не упомянутых в массиве
def remove_unlisted_files(directory: str, existing_files: set) -> None:
    all_items = glob.glob(os.path.join(directory, '*'))

    for item_path in all_items:
        item_name = os.path.basename(item_path)
        if item_name not in existing_files:
            try:
                if item_name == "update.py":
                    print(f'Файл {item_name} нельзя удалять.')
                    continue

                if os.path.isdir(item_path):
                    shutil.rmtree(item_path)
                    print(f'Удалена директория: {item_path}')
                else:
                    os.remove(item_path)
                    print(f'Удален файл: {item_path}')
            except Exception as e:
                print(f'Ошибка при удалении {item_path}: {e}')
        else:
            print(f'Элемент сохранен: {item_path}')

# Функция для вычисления контрольной суммы файла
def calculate_checksum(file_path):
    sha256 = hashlib.sha256()
    try:
        with open(file_path, 'rb') as f:
            while chunk := f.read(8192):
                sha256.update(chunk)
    except Exception as e:
        print(f'Ошибка при чтении файла {file_path}: {e}')
        return None
    return sha256.hexdigest()

# Создание локальных директорий, если не существуют
os.makedirs(local_directory_scripts, exist_ok=True)
os.makedirs(local_directory_soft, exist_ok=True)
os.makedirs(local_directory_1c, exist_ok=True)
os.makedirs(local_directory_desktop, exist_ok=True)

# Получение данных из API
try:
    response = requests.get(api_url)
    response.raise_for_status()
except requests.RequestException as e:
    print(f"Не удалось получить данные с API: {e}")
    exit()

files_data = response.json()

# Множества для хранения имен файлов, которые будут сохранены
existing_scripts = set()
existing_soft = set()
existing_1c = set()
existing_desktop = set()

# Функция для обработки файлов
def process_files(file_data, local_directory, existing_set, set_permissions=False):
    for file_info in file_data:
        file_name = file_info['name']
        file_url = file_info['url']
        file_checksum = file_info['checksum']
        local_file_path = os.path.join(local_directory, file_name)

        existing_set.add(file_name)

        if os.path.exists(local_file_path):
            local_checksum = calculate_checksum(local_file_path)
            if local_checksum is None or local_checksum != file_checksum:
                print(f"Файл '{file_name}' существует и контрольная сумма не совпадает. Загружаем заново.")
            else:
                print(f"Файл '{file_name}' уже существует и контрольная сумма совпадает. Пропускаем загрузку.")
                continue
        else:
            print(f"Файл '{file_name}' не существует. Загружаем.")

        print(f"Скачивание '{file_name}'...")
        try:
            file_response = requests.get(file_url)
            file_response.raise_for_status()
            with open(local_file_path, 'wb') as f:
                f.write(file_response.content)
            print(f"Файл '{file_name}' успешно скачан.")
            if set_permissions:
                os.chmod(local_file_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR |
                                 stat.S_IRGRP | stat.S_IXGRP | 
                                 stat.S_IROTH | stat.S_IXOTH)
                print(f"Права на выполнение установлены для '{file_name}'.")
        except requests.RequestException as e:
            print(f"Ошибка при скачивании файла '{file_name}': {e}")
        except Exception as e:
            print(f"Ошибка при сохранении файла '{file_name}': {e}")
            
# Обработка всех файлов из блока SCRIPTS
process_files(files_data['SCRIPTS'], local_directory_scripts, existing_scripts, set_permissions=True)

# Обработка всех файлов из блока SOFT
process_files(files_data['SOFT'], local_directory_soft, existing_soft, set_permissions=False)

# Обработка всех файлов из блока 1C
process_files(files_data['1C'], local_directory_1c, existing_1c, set_permissions=False)

# Обработка всех файлов из блока DESKTOP
process_files(files_data['DESKTOP'], local_directory_desktop, existing_desktop, set_permissions=False)

# Удаление файлов, которые не перечислены в JSON
remove_unlisted_files(local_directory_scripts, existing_scripts)
remove_unlisted_files(local_directory_soft, existing_soft)
remove_unlisted_files(local_directory_1c, existing_1c)
remove_unlisted_files(local_directory_desktop, existing_desktop)

Проводить разбор кода я не буду. Все блоки и функции снабжены комментариями.

Конечно, код программы очень далёк от всех основных принципов масштабирования. Но такой метод распространения файлов очень помог мне избежать рутинной синхронизации и ускорил процесс отладки и передачи скриптов на (пока ещё) тестовые машины прямо в процессе установки системы.