Нестандартное программирование: создаем крестики-нолики

Нестандартное программирование: как создать крестики-нолики

В инструкции показываем, как разработать игру для командного интерпретатора с помощью iPXE.

На днях меня осенило: загрузка сервера по сети — это прекрасный инструмент, из которого можно сделать что-нибудь необычное. Например, игру. У нас есть минимальный набор: командный интерпретатор, возможность скачивать и выполнять произвольный код.
Количество игр, которые можно быстро создать при ограниченных возможностях, становится все меньше — особенно если не повторяться. Я вспомнил только «Вордли», «Балду» и «Виселицу». Конечно, существуют еще шашки и шахматы, но они показались слишком сложными для такого шуточного проекта. Так что я решил сперва   разобрать имеющиеся инструменты, а потом определиться с выбором. 

Обзор инструментов

Обычно компьютер ищет исполняемый файл на локальных накопителях: в Legacy-режиме это первые 512 байтов диска, а в случае UEFI — специально отмеченный FAT-раздел. Но не всегда есть мотивация загружаться с локального диска — например, если нужно установить операционную систему. Можно, конечно, бегать с флешкой по серверной, но это не очень эффективно. 

Удобное решение — использовать PXE (Pre-boot eXecution Environment), среду для загрузки с помощью сетевой карты. В этом случае сервер ищет исполняемый файл не на локальных накопителях, а загружает с удаленного сервера.

Как все происходит:

  1. Сервер получает IP-адрес с помощью протокола DHCP. В DHCP-пакетах содержатся IP-адрес удаленного сервера и имя исполняемого файла, который необходимо выполнить.
  2. После получения IP-адреса и настройки сетевых интерфейсов сервер скачивает в оперативную память указанный исполняемый файл и передает ему управление. Для скачивания используется протокол TFTP, основанный на UDP.

Здесь нет чего-то особенного. Пусть писать исполняемые файлы, которые работают на «голом» железе, для многих не самая простая задача. И все же это пока нормальное программирование. 

Однако в PXE есть ограничение на размер исполняемого файла: он может быть не более 32 КБ. Этого не всегда достаточно, поэтому используется загрузка в два этапа:

  1. Прошивка сетевой карты загружает промежуточный загрузчик по TFTP.
  2. Промежуточный загрузчик загружает ядро операционной системы.

Среди таких промежуточных загрузчиков мне известны два: iPXE и pxelinux.

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

На официальном сайте iPXE есть достаточная документация, в которой перечислены команды, доступные в командной строке загрузчика. Поверхностное изучение привело к довольно оптимистичным выводам.

Что есть в iPXE:

  • возможность объявлять собственные переменные;
  • команды для пользовательского ввода: read и prompt;
  • команды для построения интерактивного графического интерфейса: item, menu, form;
  • метки и оператор goto;
  • две команды, обеспечивающие ветвление: iseq и isset. Первая проверяет равенство двух аргументов, вторая — наличие переменной.

Получается что-то вроде bash’а, но с меньшим количеством функций. Однако не стоит забывать, что iPXE — загрузчик. То есть загрузка исполняемого файла с удаленного сервера — одна из его основных задач. 

В документации по сборке iPXE из исходного кода есть отдельная страница, посвященная встраиванию в iPXE скрипта, который скачивает другой скрипт по адресу http://bootserver/boot.php. Значит, скрипты могут загружать друг друга сколько угодно. Из этого напрашивается вывод, что можно использовать какой-нибудь внешний сервер, который будет генерировать команды для iPXE в зависимости от входных параметров.

Довольно теории, переходим к практике.

Обзор на практике

Скриншот iPXE-прошивки в VirtualBox.

Прикоснуться к iPXE-прошивке просто: скачайте VirtualBox и запустите виртуальную машину без операционной системы. Однако это «минимальный» вариант, в котором нет некоторых функций, например, загрузки по HTTP. Проблема решается путем загрузки «полноценной» версии iPXE по сети. Устраняем ее в четыре этапа.

Настройка виртуальной машины

Скриншот интерфейса VirtualBox. Раздел «Инструменты».

Сперва нужно сделать интерфейс, который будет «подключен» к виртуальной машине. Все преднастроенные интерфейсы VirtualBox или удобно и быстро предоставляют доступ в интернет от хоста, или создают изолированную сеть между виртуальными машинами. Нам же нужна связь «Хост — ВМ» как между двумя физическими серверами.

Выбираем «Инструменты» и создаем новый виртуальный адаптер хоста. У меня он называется vboxnet0. Обязательно отключаем DHCP-сервер:

Скриншот окна настройки PXe в VirtualBox.

Затем в настройках виртуальной машины в меню «Сеть» выбираем «Виртуальный адаптер хоста» и vboxnet0. 

Готово! Переходим к подготовке хоста.

Сборка исполняемого файла iPXE

Сперва соберем исполняемый файл iPXE. Его можно скачать, но собрать интереснее:


    git clone https://github.com/ipxe/ipxe.git
cd ipxe/src
make bin/undionly.kpxe

Получилось довольно просто. Нужный исполняемый файл можно найти по пути bin/undionly.kpxe.

Настройка TFTP-сервера

Прошивка сетевой карты умеет скачивать только по протоколу TFTP, так что необходимо развернуть TFTP-сервер. Устанавливаем его через пакетный менеджер операционной системы:


    sudo apt install tftpd-hpa

Его можно даже не настраивать, он по умолчанию раздает файлы из каталога /srv/tftp. Копируем туда файл undionly.kpxe.

Настройка DHCP-сервера

Как отмечалось ранее, именно протокол DHCP указывает на удаленный сервер и передает имя файла, который должен быть загружен. У меня после прошлых экспериментов остался легковесный udhcpd, поэтому используем его:


    sudo apt install udhcpd

И затем настраиваем:

  • указываем адрес и интерфейс — эту информацию можно подглядеть в отключенном DHCP-сервере VirtualBox;
  • указываем TFTP-сервер; 
  • в качестве загружаемого файла указываем undionly.kpxe.

Для udhcpd файл конфигурации можно найти по пути /etc/udhcpd.conf. Получается так:


    # The start and end of the IP lease block
start           192.168.56.20
end             192.168.56.254

# The interface that udhcpd will use
interface       vboxnet0

# The following are BOOTP specific options
# next server to use in bootstrap
siaddr          192.168.56.1    # default: 0.0.0.0 (none)
# tftp file to download (e.g. kernel image)
boot_file       undionly.kpxe   # default: none

Если все сделано правильно, при загрузке виртуальной машины после встроенного iPXE откроется «внешний» iPXE.

Предотвращаем циклическую загрузку

Скриншот терминала.

Список функций, поддерживаемых iPXE, значительно вырос. Например, в нем значится HTTP, который облегчит нам творчество. Однако из-за использования легковесного DHCP-сервера произошла любопытная ситуация. Сейчас встроенный iPXE получает от DHCP инструкции для загрузки внешнего iPXE, который получает инструкции от DHCP и снова загружает сам себя.

У этой проблемы есть два решения:

  1. Использовать продвинутый DHCP-сервер, который позволяет гибко конфигурировать опции, например, Kea. В этом случае по опциям 60 и 77 можно отличать прошивку сетевой карты от iPXE и не давать команды iPXE загружать самого себя.
  2. Встроить скрипт в исполняемый файл, чтобы переопределить поведение iPXE.

Я выбрал второй вариант. Составляем скрипт:


    #!ipxe

ifopen
shell

Здесь всего три строчки, но все критически важные. Первая содержит шебанг (октоторп и восклицательный знак) и указание на iPXE. Она позволяет загрузчику понять, что это именно скрипт. Вторая строка поднимает интерфейсы. По умолчанию при загрузке iPXE они настроены, но выключены, поэтому любая сетевая операция будет завершаться ошибкой. Третья строчка запускает командный интерпретатор.

Когда скрипт готов, собираем iPXE из исходников. Имя вшиваемого скрипта передается через аргумент EMBED:


    make bin/undionly.kpxe EMBED=myscript.ipxe

Изучаем возможности командного интерпретатора

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

iPXE поддерживает некоторые управляющие последовательности ANSI, которые позволяют раскрашивать вывод в терминале. В официальной документации можно найти намек на это в команде установки переменной — set. Функциональность не самая очевидная.


    set esc:hex 1b            # ANSI escape character - "^["
set cls ${esc:string}[2J  # ANSI clear screen sequence - "^[[2J"
echo ${cls}

Сперва записываем в переменную esc число 1b, а затем сохраняем в переменную cls символ с кодом esc и строку [2J. Вместе это получается последовательность символов \x1b[2J. Если вывести эту последовательность на экран, то экран будет очищен. Дополнительно можно определить несколько цветов и возвращение к исходному значению:


    set default ${esc:string}[0m
set red ${esc:string}[31m
set green ${esc:string}[32m
set yellow ${esc:string}[33m
Скриншот терминала.

Однако радость быстро сменяется грустью: командный интерпретатор игнорирует ожидаемое экранирование кавычками, а команда echo удаляет лишние пробелы. Но всегда можно придумать «костыли»:


    set space:hex 20
echo spaa${space:string}${space:string}${space:string}${space:string}aace

Команды ввода пользовательских данных тоже довольно ограниченные для игр.

  • read считывает строку в переменную, то есть требуется нажатие кнопки Enter. Это исключает все игры, которые требуют перемещения персонажа кнопками WASD.
  • prompt ждет нажатия или указанной кнопки, или любой. При этом он не возвращает код нажатой кнопки. Можно сделать только что-то вроде механики Quick Time Event (QTE), где ожидается нажатие на кнопку за определенный промежуток времени, но это как-то сомнительно. 

Остается только неторопливый ввод информации — так я пришел к выбору: морской бой, крестики-нолики или игра в слова.

Морской бой уже был, а игры в слова в iPXE ограничены английским языком, так как я не нашел способа «подключить» кириллицу. В pxelinux есть возможность подключать сторонние шрифты, а в iPXE нет. 

Я уже почти бросил затею, чтобы не повторяться, но мне на глаза попались альтернативные крестики-нолики. Идея проста: ты играешь в крестики-нолики, пока играешь в крестики-нолики. Игровое поле здесь состоит из девяти «классических» полей. Каждый ход переводит оппонента на другое поле. А чтобы поставить свой крестик или нолик в «большое» поле, нужно выиграть на маленьком. 

Звучит хорошо, проектируем!

Создание игры

Из-за ограниченных возможностей скриптового языка iPXE часть функций придется вынести во внешний веб-сервер. В частности, здесь нет функций для работы с подстроками и массивов, поэтому хранить игровое поле даже обычных крестиков-ноликов будет затруднительно, а тут их целых девять. Чтобы еще больше оправдать использование внешнего сервера, сделаем игру мультиплеерной: каждый сервер — это отдельный игровой клиент.

Игровой клиент разбивается на три логические части:

  • встроенный скрипт,
  • «главное меню» и основной цикл игры,
  • загружаемый код.

Встроенный скрипт — это тот, что избавляет от циклической загрузки. Я заменил в нем команду shell на chain. Мне показалось разумным, что встроенный скрипт не содержит логики — так будет проще обновлять приложение.

Скриншот терминала.

Главное меню — это первый скрипт, который загружается от веб-сервера по HTTP. Оно содержит статические данные и основной цикл игры:


    #!ipxe

# Создаем константы, которые будут использоваться для раскраски:

# Для текста в игре
set space:hex 20
set esc:hex 1b            # ANSI escape character - "^["
set cls ${esc:string}[2J  # ANSI clear screen sequence - "^[[2J"
echo ${cls}

set def ${esc:string}[0m
set red ${esc:string}[31m
set green ${esc:string}[32m
set yellow ${esc:string}[33m

# Основное меню, которое на скриншоте выше
:main
menu iPXE ultimate tic-tac-toe
item --key l create Create (l)obby
item --key c connect (C)onnect to lobby
item --key a about (A)bout
# Выбор пункта меню = переход к указанной метке
choose main_menu && goto ${main_menu}

:create
form Create lobby
item lobby_name
present || goto main
# next — это переменная, которая используется в основном цикле игры
set next lobby?name=${lobby_name}
goto execute

:connect
set next lobby/list
goto execute

# Статическое меню «О программе»
:about
menu iPXE ultimate tic-tac-toe
item --gap Special for habr
item --gap
item --key b main (B)ack
choose about_menu && goto ${about_menu}

# Основной цикл игры
:execute
# iPXE делает запросы к веб-серверу по указанному адресу и порту
# Обратите внимание, что {{host}} и {{port}} — это переменные шаблонизатора
# Jinja2 и подставляются веб-сервером, который отдает этот файл
#
# Все запросы к веб-серверу содержат MAC-адрес устройства,
# в идеале — уникальный идентификатор оборудования
# 
# Переменная next содержит путь к конкретному эндпоинту и потенциально
# может содержать GET-параметры
chain http://{{host}}:{{port}}/${mac}/${next}

# Каждый выполненный скрипт должен изменить значение переменной next.
# У этой переменной есть два особых значения: 
# — main для возврата в главное меню,
# — exit для выхода из игры
iseq ${next} done && goto exit ||
iseq ${next} main && goto main ||

# Если переменная next не имеет специальных значений, 
# то цикл начинается сначала
goto execute

:exit

Для раздачи этого скрипта я использовал Python, FastAPI и шаблонизатор Jinja2:


    templates = Jinja2Templates(directory='templates')

app = FastAPI()

@app.get("/")
def read_root(request: Request, mac:str):
    print(mac)
    return templates.TemplateResponse(
        name='init.tpl',
        context={
            "request": request,
            "host": "192.168.56.1",
            "port": 8000,
        })

Состояние каждой партии игры содержится в объектах python, здесь ничего сверхъестественного. Для партии есть «лобби»: один игрок создает лобби, второй к нему присоединяется. С точки зрения «велосипедостроения» для iPXE здесь интересны две детали: отображение игрового поля и получение обновлений игровым клиентом во время игры в «лобби». 

Обе задачи решаются одним скриптом: field.tpl:


    #!ipxe

# Очистка экрана обязательна!
echo ${cls}

# Игровое поле рисуется кодом на Python,
# ничего интересного!
{{field}}

{% if is_your_turn %}
# Клиенту, чей ход, отдается команда на чтение пользовательского ввода.
# Команда clean стирает значение переменной turn,
# иначе read позволяет редактировать существующее значение
clear turn
echo -n Your turn:${space:string} && read turn
set next lobby/{{lobby_id}}?turn=${turn}
{% else %}
# Клиент, который ждет оппонента, запрашивает состояние игрового поля
# каждую секунду. Как только сервер передаст код, клиенту достанется шаблон
# с кодом выше, и он будет ходить.
echo Waiting for opponent...
set next lobby/{{lobby_id}}
sleep 1
{% endif %}

Рендер игрового поля, как отмечено в комментариях, делается на веб-сервере. Он конвертирует внутреннее игровое состояние в набор команд echo, которые выглядит совершенно нечитаемо, но корректно отображают игровое поле:


    echo ${space:string}|${space:string}|${space:string}${space:string}${space:string}${yellow}|${def}${space:string}${yellow}|${def}${space:string}${space:string}${space:string}|${space:string}|${space:string}${space:string}${space:string}${space:string}${space:string}|${space:string}|${space:string}
Скриншот игры в терминале.

Желтым выделяется текущее игровое поле, на котором будет совершен ход. Отдельное поле справа — это «глобальное» состояние игры, которое показывает, кто первым выиграл на соответствующем локальном поле. Желаемая цель достигнута!

Я не стал дорабатывать игру до «продакшена» с корректной обработкой всех возможных ошибок — это не кажется эффективным вложением времени. Но вы можете посмотреть код проекта на GitHub.

Подписывайтесь на мой Telegram-канал, там можно увидеть заметки по темам статей, над которыми я работаю, и небольшие познавательные посты.