Всем привет! Ранее рассказывал о том, как создать REST API и Web-сервер на PowerShell для Windows, а также упоминал, что подобный сервер будет работать и в системе Linux, благодаря кроссплатформенной версии PowerShell Core. Пример такой реализации вы можете найти в репозитории dotNET-Systemd-API на GitHub, с помощью данного подхода возможно настроить управление службами Linux управляемые системой systemd (используя в системе команды systemctl) на удаленной машине через любой REST-клиент, например, curl. Из явных преимуществ, такой сервер поддерживает обработку нескольких одновременных подключений и видов авторизации благодаря встроенным методам класса .NET HttpListener, где по мимо прочего, используя его в системе Linux возможно комбинировать сразу два языка, а если точнее, все встроенные утилиты операционной системы, такие как grep, sed, awk и т.п. в паре с PowerShell. Безусловно, для подобных целей лучше используются специализированные серверные фреймворки или библиотеки, такие как Flask или Django в Python, но меня не покидала идея реализации похожего сервера, где описание логики будет производиться на языке одного только Bash. Используя любой инструмент, который дает возможность сетевого взаимодействия между сервером и клиентом может послужить отправной точкой в решение поставленной задачи. Из очевидных минусов, придется не только описать обработку входящих HTTP-запросов и соответствующих на них ответов, а так же придумать логику этой обработки, например, проверку авторотационных данных передаваемых в заголовке запроса. Приведу примеры, с помощью которых можно создать такой сервер используя сетевые сокеты netcat , socat и ncat, а также веб-сервера Apache с использованием встроенных модулей.
Netcat
Практически в каждом дистрибутиве Linux присутствует встроенная утилита netcat (или nc), которая позволяет устанавливать TCP/IP и UDP соединения, а также может использоваться для чтения и записи данных через сетевые сокеты, прослушивания портов, отправки файлов и многого другого. Создать и запустить сокет в режиме прослушивания (listen) можно одной командой:
nc -l -w 1 -p 8081
Отправляем запрос подключения на удаленном клиенте, используя curl:
curl http://192.168.3.101:8081/api
Или, используя PowerShell:
Invoke-RestMethod http://192.168.3.101:8081/api
На стороне сервера netcat получаем запросы с следующим содержимым:
GET /api HTTP/1.1 Host: 192.168.3.101:8081 User-Agent: curl/8.4.0 Accept: */* GET /api HTTP/1.1 Host: 192.168.3.101:8081 User-Agent: Mozilla/5.0 (Windows NT 10.0; Microsoft Windows 10.0.19045; ru-RU) PowerShell/7.3.7
Первая строка содержит метод запроса GET (используется клиентом по умолчанию, чаще применяется для получения информации, если на стороне сервера не декларируется иначе), конечную точку /api и протокол HTTP версии 1.1. Вторая, адрес сервера, где запущен netcat. Третья строка содержит наименование и версию агента, который совершил подключение. Этих данных достаточно, что бы реализовать базовую обработку запроса, например так:
port=8081 while true; do # Принимаем запрос и читаем первую строку с методом и конечной точкой request_in=$(nc -l -w 1 -p $port) request=$(echo "$request_in" | head -n 1) # Забираем метод и конечную точку из запроса клиента method=$(echo "$request" | awk '{print $1}') endpoint=$(echo "$request" | awk '{print $2}') # Проверяем, что запрошенный метод поддерживается и конечная точка подлежит обработке if [[ $method == "GET" ]]; then if [[ $endpoint == "/api/disk" ]]; then # Получаем данные состояни дисков из утилиты операционной системы в формате JSON response=$(lsblk -e7 -f --json) header="HTTP/1.1 200 OK\nContent-Type: application/json\n\n" else response="404 Not Found: Endpoint unavailable" header="HTTP/1.1 404 Not Found\n\n" fi else response="405 Method Not Allowed: Only supports GET requests" header="HTTP/1.1 405 Method Not Allowed\n\n" fi # Формируем вывод и отправляем ответ клиенту echo -e "$header$response" | nc -l -N -p $port -w 1 done
Запускаем код прямо в консоли или из файла (например, server.sh ), после чего отправляем запрос от клиента к серверу на конечную точкой /api/disk и ожидаем ответ в формате json, вывод которого в PowerShell будет обработан как объект по умолчанию.
Invoke-RestMethod http://192.168.3.101:8081 $(Invoke-RestMethod http://192.168.3.101:8081/api/disk).blockdevices $(Invoke-RestMethod http://192.168.3.101:8081/api/disk).blockdevices.children
На стороне сервера читаем запрос целиком, вытаскиваем из него нужные данные предоставленные клиентом, проверяем их на наличие доступных методов и конечных точек, на основании полученной информации формируем и отправляем ответ. По мимо этих данных, мы также можем передавать дополнительные параметры в заголовке или теле запроса.
Авторизация
На скриншоте выше можно заметить строку Authorization: Basic cmVzdDphcGk= , это заголовок запроса, в котором содержится тип авторизации (Basic) и сами авторизационные данные зашифрованные в формате Base64 - это стандарт кодирования двоичных данных при помощи 64 символов ASCII. Используя PowerShell или curl есть как минимум два способа, с помощью которых можно передать эти данные. Напрямую через заголовок запроса, предварительно сконвертировав логин и пароль разделенные символом двоеточия (:), или использовать соответствующий параметр:
$EncodingCred = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("${user}:${pass}")) $Headers = @{"Authorization" = "Basic ${EncodingCred}"} Invoke-RestMethod -Headers $Headers -Uri http://192.168.3.101:8081/api/service/cron curl http://192.168.3.101:8081/api/service/cron -u rest:api
Что бы обработать данные на сервере, нужно считать соответствующий заголовок переданный от клиента, после чего в дополнительном условие сопоставить полученные данные с теми, которые заданы на самом сервер (предварительно также закодированные в формат Base64). Если полученные данные равны, пропускаем по остальным условиям, если нет, возвращаем соответствующую ошибку.
port=8081 # Добавляем параметр проверки, что авторизация должна или не должна использоваться auth="true" # Задаем логин и пароль user="rest" pass="api" while true; do request_in=$(nc -l -w 1 -p $port) request=$(echo "$request_in" | head -n 1) method=$(echo "$request" | awk '{print $1}') endpoint=$(echo "$request" | awk '{print $2}') # Проверяем, что авторизация используется if [[ $auth == "true" ]]; then # Формируем авторизационные данные на сервере в формате Base64 cred_server=$(echo -n "$user:$pass" | base64 | tr -d '[:space:]') # Забираем авторизационные данные клиента cred_client=$(echo "$request_in" | grep "Authorization: Basic" | awk '{print $3}' | tr -d '[:space:]') # Проверяем, что данные клиента и сервера равны if [[ $cred_server == $cred_client ]]; then auth_status="true" else auth_status="false" fi fi if [[ $auth == "false" ]] || [[ $auth == "true" ]] && [[ $auth_status == "true" ]]; then if [[ $method == "GET" ]]; then if [[ $endpoint == "/api/disk" ]]; then response=$(lsblk -e7 -f --json) header="HTTP/1.1 200 OK\nContent-Type: application/json\n\n" else response="404 Not Found: Endpoint unavailable" header="HTTP/1.1 404 Not Found\n\n" fi else response="405 Method Not Allowed: Only supports GET requests" header="HTTP/1.1 405 Method Not Allowed\n\n" fi echo -e "$header$response" | nc -l -N -p $port -w 1 # Если авторизация используется и данные невалидные, отвечает ошибкой else echo -e "HTTP/1.1 401 Unauthorized\n\n401 Unauthorized" | nc -l -N -p $port -w 1 fi done
Точно таким же образом возможно настроить фильтрацию по ip-адресу или подсети клиента. Пример обработки нескольких конечных точек для остановки и запуска службы (unit systemd), а так же чтения и обработки заголовков запроса можно найти в полной версии скрипта, который опубликован на GitHub. При использовании метода GET через PowerShell версии Core проблем не возникает, сервер netcat обрабатывает каждый запрос корректно, но если использовать метод POST или клиент curl, то при каждом новом обращении к серверу происходит разрыв соединения, и при последующем повторным обращении возвращается ответ на предыдущий запрос. Для решения этой проблемы, может помочь увеличение времени ожидания ответа на стороне клиента, например так: curl --connect-timeout 10 --max-time 30 http://192.168.3.101:8081/api/disk и/или сервера: nc -l -w 10 -p $port, а также использование фоновых потоков. Это не является стабильным решением, иногда ответ мог вернуться сразу, но чаще в ходе экспериментов данная проблема сохранялась.
Socat и Ncat
Так как у большинства современных дистрибутивов Linux по соображениям безопасности в утилите netcat отсутствует опция (-e), которая позволяла бы напрямую передавать исполняемый файл для обработки входящих соединений, это подтолкнуло меня на использование других инструментов. С помощью socat можно настроить TCP-сервер так, чтобы он слушал входящие соединения на указанном порту и для каждого отдельного установленного соединения запускал указанный скрипт в новом процессе (используя параметр fork), тем самым позволяя обрабатывать одновременно несколько запросов от разных клиентов. Процесс socat перенаправляет предоставленные HTTP-клиентом данные на входе (stdin) скрипту, которые могут быть прочитаны с помощью команды read с сохранением содержимого в переменные. Вот простой пример (предварительно создаем файл со скриптом touch /tmp/socat-api-server.sh и открываем его на редактирование):
#!/usr/bin/bash # Получаем запрос от клиента и читаем содержиое запроса из переменных окружения: read -r REQUEST_METHOD REQUEST_URI REQUEST_HTTP_VERSION # Проверяем, что метод запроса GET if [ "$REQUEST_METHOD" = "GET" ]; then # Отправляем ответ клиенту с содержимым endpoint echo -e "HTTP/1.1 200 OK\nContent-Type: text/plain\n\n" echo -e "uri: $REQUEST_URI\n" else echo -e "HTTP/1.1 200 OK\nContent-Type: text/plain\n\n" echo -e "method $REQUEST_METHOD not supported\n" fi
Устанавливаем инструмент в системе (в примере для Ubuntu):
sudo apt install socat
Предоставляем скрипту права на выполнение, и запускаем сервер одной командой:
sudo chmod +x /tmp/socat-api-server.sh socat TCP4-LISTEN:8081,fork EXEC:/tmp/socat-api-server.sh
Можно добиться точно такого же результата, используя синтаксис текущего скрипта с инструментом ncat (часть пакета Nmap):
sudo apt install ncat ncat -l -p 8081 --keep-open --exec /tmp/socat-api-server.sh
Такой подход работает уже стабильно с использованием различных методов, а его конфигурация становится куда проще. Но, так как socat и ncat не поставляются в составе дистрибутива, то их придется устанавливать как зависимость, в таком случае целесообразнее воспользоваться полноценным серверным решением, например, Apache.
Apache
В отличии от предыдущих вариантов, Apache это полноценный веб-сервер, который содержит широкий набор встроенных модулей, в частности CGI (Common Gateway Interface), который позволяет использовать любые исполняемые файлы и скрипты, в том числе написанные на языке командной оболочки Bash. Устанавливаем сам сервер (для удобства, в данном примере дальнейшая настройка производится из под пользователя с правами root) и jqlang, который пригодится для обработки JSON в дальнейшем:
apt install apache2 jq
Меняем порт по умолчанию на 8443 (для примера, можно указать любой другой) в конфигурационном файле /etc/apache2/ports.conf одной командой:
cat /etc/apache2/ports.conf | sed -r "s/^Listen.+/Listen 8443/" > /etc/apache2/ports.conf
Активируем встроенный модуль базовой HTTP-аутентификации (Base64) и создаем пользователя (в примере, rest с паролем api):
a2enmod auth_basic htpasswd -b -c /etc/apache2/.htpasswd rest api
Создаем файл Bash-скрипта по пути /var/www/api/api.sh и предоставляем ему права на выполнение:
mkdir /var/www/api && touch /var/www/api/api.sh && chmod +x /var/www/api/api.sh
Открываем файла с помощью любого редактора (например, nano /var/www/api/api.sh) и вставляем следующее содержимое:
#!/bin/bash # Формируем базовый HTTP-ответ echo "Content-type: application/json" echo # Читаем содержимое Body запроса из стандартного ввода (stdin): read -n $CONTENT_LENGTH POST_DATA # Читаем содержимое встроенных переменных request=$(echo {\"method\": \"$REQUEST_METHOD\", \"url\": \"$REQUEST_URI\"}) client=$(echo {\"address\": \"$REMOTE_ADDR\", \"port\": \"$REMOTE_PORT\", \"agent\": \"$HTTP_USER_AGENT\", \"type_auth\": \"$AUTH_TYPE\", \"user\": \"$REMOTE_USER\"}) server=$(echo {\"address\": \"$SERVER_NAME\", \"port\": \"$SERVER_PORT\", \"version\": \"$SERVER_SOFTWARE\", \"protocol\": \"$SERVER_PROTOCOL\"}) # Читаем содержимое тела запроса и переданых заголовков: content=$(echo {\"type\": \"$CONTENT_TYPE\", \"length\": \"$CONTENT_LENGTH\", \"body\": \"$POST_DATA\", \"status\": \"$HTTP_STATUS\"}) response=$(echo {\"request\": [$request], \"client\": [$client], \"server\": [$server], \"content\": [$content]}) echo $response | jq .
Теперь необходимо создать и настроить VirtualHost для нового сайта, который будет отвечать за обработку запросов нашего будущего API-сервера:
<VirtualHost *:8081> DocumentRoot /var/www/html # Связать endpoint (включая все дочернии в пути) с исполняемым файлом ScriptAlias /api /var/www/api/api.sh # Все опции, вложенные внутрь секции Directory, применяются к указанной директории <Directory "/var/www/api"> # Разрешить выполнение CGI-скриптов Options +ExecCGI # Обрабатывать все файлы с расширение sh как CGI-скрипт AddHandler cgi-script .sh AllowOverride None Require all granted </Directory> # Добавить авторизацию для endpoint <Location "/api"> AuthType Basic AuthName "Restricted Area" AuthUserFile /etc/apache2/.htpasswd Require valid-user SetHandler cgi-script Options +ExecCGI </Location> ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined </VirtualHost>
Активируем модуль для работы cgi-скриптов и созданный VirtualHost, после чего запускаем сервер:
a2enmod cgi a2ensite api.confs systemctl restart apache2
Если все сделано правильно, то можем отправить свой первый запрос (с указанием данных для авторизации) на сервер:
curl http://192.168.3.101:8443/api -u rest:api { "request": [ { "method": "GET", "url": "/api" } ], "client": [ { "address": "192.168.3.100", "port": "25757", "agent": "curl/8.4.0", "type_auth": "Basic", "user": "rest" } ], "server": [ { "address": "192.168.3.101", "port": "8443", "version": "Apache/2.4.52 (Ubuntu)", "protocol": "HTTP/1.1" } ], "content": [ { "type": "", "length": "", "body": "", "status": "" } ] }
В выводе ответа мы получаем содержимое параметров нашего запроса, а именно метод, конечную точку, данные клиента и сервера. Доступ к этим данным мы получаем через переменные окружения, которые создаются и заполняются сервером Apache на основе информации, содержащейся в HTTP-запросе. Для пример, передадим заголовок Status с содержимым text и произвольный текст (test) в теле запроса, что бы прочитать их содержимое на сервере:
curl -s http://192.168.3.101:8443/api -u rest:api -H "Status: text" --data "test" | jq .content[] { "type": "application/x-www-form-urlencoded", "length": "5", "body": "test", "status": "text" }
Вот список всех переменных, которые могут быть полезны:
REQUEST_METHOD - метод HTTP-запроса (GET, POST, HEAD и т.д.)
REQUEST_URI - оригинальный URI запроса
QUERY_STRING - строка запроса URL
CONTENT_TYPE - тип содержимого запроса в заголовке клиента (например, application/json)
CONTENT_LENGTH - длина тела запроса в байтах (чаще, для POST-запросов)
read -n $CONTENT_LENGTH POST_DATA - прочитать содержимое Body из стандартного ввода (stdin).
HTTP_STATUS - читаем содержимое переданного заголовка (например, "Status: text"), которое определяется заранее и регламентируется в дальнейшем
HTTP_USER_AGENT - название агента клиента из заголовка (например, curl/8.4.0)
REMOTE_ADDR - адрес клиента
REMOTE_PORT - порт клиента
SERVER_NAME - адрес сервера
SERVER_PORT - порт сервера
SCRIPT_NAME - путь и имя CGI-скрипта
SERVER_SOFTWARE - имя и версия сервера
SERVER_PROTOCOL - версия протокола HTTP (например, HTTP/1.1)
HTTPS - если установлено, то запрос был сделан с использованием HTTPS
AUTH_TYPE - тип аутентификации, если он был предоставлен
REMOTE_USER - имя пользователя, если была использована аутентификация
DOCUMENT_ROOT - корневой каталог веб-сервера
Имея доступ к содержимому перечисленных переменных и используя возможности Bash , можно сконфигурировать простой, но при этом полноценный REST API сервер. Приведу пример обработки нескольких конечных точек для управления службами Linux:
#!/bin/bash # Функция для получения списка всех служб, а также статус автозапуска и времени работы для каждой службы отдельно function list-units { service_name=$1 service_list=$(systemctl list-units --all --type=service --plain --no-legend --no-pager --output=json | jq --arg service_name "$service_name" ' .[] | select(.unit | test($service_name)) ') for unit in $(echo $service_list | jq -r .unit); do uptime=$(systemctl status $unit 2>/dev/null | grep -P "Active:.+;" | sed -r "s/.+; | ago//g") startup=$(systemctl status $unit 2>/dev/null } | grep -oP "enabled|disabled" | head -n 1) echo $service_list | jq --arg unit "$unit" --arg uptime "$uptime" --arg startup "$startup" ' . | select (.unit == $unit) + {uptime: $uptime, startup: $startup} ' done } # Первое условие для проверки запрошенного метода и конечной точки if [ "$REQUEST_METHOD" == "GET" -a "$REQUEST_URI" == "/api/uptime" ]; then # Возвращаем время работы в формате обычного текста echo "Content-type: text/plain" echo uptime -p | sed "s/up //" | awk -F "," '{print $1,$2,$3}' # Формируем ответ на вторую конечную точку elif [ "$REQUEST_METHOD" == "GET" -a "$REQUEST_URI" == "/api/disk" ]; then # Декларируем, что тип ответа сервера будет в формате json echo "Content-type: application/json" echo # Получаем информацию о блочных устройствах из утилиты lsblk в формате json lsblk -e7 -f --json | jq . # Проверяем наименование клиента, если браузер (Chrome), возвращаем ответ в формате HTML elif [ "$REQUEST_METHOD" == "GET" ] && [ "$REQUEST_URI" == "/api/service" ] && echo "$HTTP_USER_AGENT" | grep -q "Chrome"; then # Получаем список всех служб в формате json response=$(systemctl list-units --all --type=service --plain --no-legend --no-pager --output=json) # Формируем ответ в формате HTML echo "Content-type: text/html" echo echo "<html>" echo "<head>" echo "<title>Service list</title>" echo "</head>" echo "<body>" echo "<table border=\"1\">" echo "<tr>" echo "<th>Unit</th>" echo "<th>Load</th>" echo "<th>Active</th>" echo "<th>Sub</th>" echo "<th>Description</th>" echo "</tr>" # Забираем содержимое массива json в формате текста и оборачиваем все получаемые значения в тэги HTML таблицы echo "$response" | jq -r '.[] | "<tr><td>\(.unit)</td><td>\(.load)</td><td>\(.active)</td><td>\(.sub)</td><td>\(.description)</td></tr>"' echo "</table>" echo "</body>" echo "</html>" # В остальных случаях возвращает ответ в формате json elif [ "$REQUEST_METHOD" == "GET" -a "$REQUEST_URI" == "/api/service" ]; then response=$(systemctl list-units --all --type=service --plain --no-legend --no-pager --output=json) echo "Content-type: application/json" echo echo $response | jq . # Если в родительском пути uri /api/service/ содержится текст, забираем его elif [ "$REQUEST_METHOD" == "GET" ] && echo "$REQUEST_URI" | grep -qP "/api/service/.+"; then echo "Content-type: application/json" echo service_name=$(echo $REQUEST_URI | sed -r "s/.+\///g") # Проверяем, что службы с переданным наименованием существуют service_list=$(list-units "$service_name") # Если список служб пуст, то возвращаем ответ с ошибкой. if [ $(echo $service_list | wc -w) -eq 0 ]; then echo Service $service_name not found else echo $service_list | jq . fi # Обрабатывает POST запросы для остановки или запуска службы elif [ "$REQUEST_METHOD" == "POST" ] && echo "$REQUEST_URI" | grep -qP "/api/service/.+"; then echo "Content-type: application/json" echo service_name=$(echo $REQUEST_URI | sed -r "s/.+\///g") service_list=$(list-units "$service_name") # Проверяем, что была передана только одна служба if [ $(echo $service_list | jq .unit | wc -l) -ne 1 ]; then echo "Bad request. Only one service can be transferred." else # Проверяем заголовок статуса на одно из трех доступных значений if [ "$HTTP_STATUS" = "stop" ] || [ "$HTTP_STATUS" = "start" ] || [ "$HTTP_STATUS" = "restart" ]; then sudo systemctl $HTTP_STATUS $(echo $service_list | jq -r .unit) list-units "$service_name" else echo "Bad request. You need to pass the service Status in the request header: stop, start or restart." fi fi # Если ничего не попадо под условия, возвращаем ответ с указанием причины elif [ "$REQUEST_METHOD" != "GET" ]; then echo "Content-type: text/plain" echo echo "Method not supported" else echo "Content-type: text/plain" echo echo "Endpoint $REQUEST_URI not found" fi
Перезагрузка сервер не требуется, все изменения в файле api.sh действующего сайта применяются сразу же после сохранения файла. Теперь необходимо пользователю (чаще всего это www-data) из под которого запускается сервер Apache предоставить права sudo к командам для управления службами, сделать это можно одной командой:
echo "www-data ALL=(ALL) NOPASSWD: /bin/systemctl start *, /bin/systemctl stop *, /bin/systemctl restart *" >> /etc/sudoers
Теперь проверяем вывод с помощью curl и PowerShell:
# Проверяем статус службы cron curl -s http://192.168.3.101:8443/api/service/cron -u rest:api # Останавливаем службу curl -s -X POST http://192.168.3.101:8443/api/service/cron -u rest:api -H "Status: stop" # Запускаем службу curl -s -X POST http://192.168.3.101:8443/api/service/cron -u rest:api -H "Status: start" # Заполняем заголовок запроса авторизации для PowerShell $user = "rest" $pass = "api" $EncodingCred = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("${user}:${pass}")) $Headers = @{"Authorization" = "Basic ${EncodingCred}"} Invoke-RestMethod -Headers $Headers -Uri http://192.168.3.101:8443/api/service/cron
Как видно из примера, с использованием инструмента jq мы можем достаточно просто обрабатывать полученные данные и отвечать клиенту в формате JSON (как это делают большинство подобных серверов), так и в формате HTML (например, но основании наименования клиента Chrome), добавив соответствующие тэги:
Исходный скрипт также опубликован на GitHub, вы можете наполнить его десятками конечных точек используя дополнительные условия для управления или предоставления информации из операционный системы с помощью Bash для любого REST-клиента, в том числе работающего в системе Windows. Также, если добавить немного JavaScript, совместно с Bash возможно создать простой Web-сервер, как это реализовано в проекте WinAPI на PowerShell. На мой взгляд, такой подход будет больше полезен системным администраторам которые хорошо ориентируются в командной оболочке и хотели бы применить свои знания для создания своего сервера с возможностью удаленного управления через протокол HTTP.