Все привет! В продолжение статьи о возможностях PowerShell, хочу поделиться несложной реализацией создания REST API и простого Web-сервера, используя только PowerShell на базе класса .NET HttpListener. Такая реализация позволяет настроить endpoint-ы (конечные точки) для обработки GET и POST запросов, которые принимают параметры в заголовке запроса, использовать Basic-авторизацию (на основе Base64), обрабатывать любые коды возврата, а так же без лишнего кода, отдавать информацию в разных форматах: json, xml, html, csv. Хочу акцентировать, что данное решение, возможно, не является самым правильным, тем не менее успешно помогло мне покрыть потребности нескольких задач и хотел лишний раз продемонстрировать возможности языка.
Для начала расскажу, кому и зачем данная реализация может понадобиться, далее приведу пример работы с готовым решение и разберем основные моменты для создания своего сервера. Стоит сразу упомянуть, что существует кросс-платформенный Web Framework Pode, и это не единственное решение, успел попробовать как минимум три, но, пожалуй, самое интересное, поддерживаемое и задокументированное. Мне же хотелось иметь свою реализацию, где будут отсутствовать сторонние зависимости, и немного больше понимать, что происходит на стороне сервера во время его работы, в частности, для отладки.
Кому и зачем данная реализация может понадобиться? Приведу свой пример, у меня была задача удаленно реализовать доступ к десктопному приложению, у которого для специфического взаимодействия с ним была возможность выполнять только локальные команды через консоль. Из условий, не было возможности настроить и использовать на машинах WinRM и OpenSSH, ввиду ограничений в компании со стороны ИБ, в то же самое время HTTP был валидным и стандартизированным (после HTTPS) решением. В результате, выполнение и вывод команд получилось автоматизировать, а в дополнение к этому, добавить настройки реестра, чистку temp и логов, что расширило возможности и позволило инженеру DevOps внедрить их в свой Pipeline, используя привычный интерфейс.
Во-вторых, работая системным администратором, в качестве интерфейса для автоматизации задач я использовал WinForms, редко Telegram. Тогда мне очень хотелось попробовать реализовать свой Web-интерфейс для визуализации подобных задач. Важно заметить, что не обладаю сильными познаниями в области систем CI/CD, и конечно, рациональнее использовать, например, интерфейс Jenkins для подобных целей. Тем не менее подобное решение имеет место, т.к. Jenkins все таки не содержит такой кастомизации, как собственный интерфейс.
В-третьих. У меня есть небольшой проект, целью которого является поиск и доставка контента из конкретного torrent-трекера Кинозал до телевизора с Plex (на эту тему у меня есть отдельная статья на Habr). Так сложилось, что за основу я выбрать язык Bash, т.к. планировал запускать бота удаленно и использовать только REST API интерфейс для взаимодействия со всеми сервисами. В течении всего времени эксплуатации, мне не хватало такого функционала, как остановка и повторный запустить torrent-клиента (qBittorrent), или просматривать свободное место на диске, узнать размер конкретных директорий и файлов, а так же возможности их удаления. По аналогии с другими сервисами, мне хотелось использовать единый интерфейс (REST API), в попытках найти готовое решение в виде десктопного приложения для Windows, вспоминая, что уже взаимодействовал с Open Hardware Monitor, используя его как клиент (в режиме HTTP), уже писал модуль для получения метрик через REST API (с возможностью отправки их в InfluxDB и визуализацией в Grafana). Но этого было мало, например для просмотра и удаления файлов можно настроить сервер Everything (который тоже имеет HTTP-сервер). Тут я понял, для покрытия нескольких специфических и не сложных задачи устанавливать дополнительно 2-3 сервиса нерационально, по этому решил написать отдельное решение.
Далее, речь пойдет про WinAPI, решение, с помощью которого у меня получилось покрыть все мои потребности, а конкретно: удаленная остановка и запуск служб и процессов, вывод метрик, максимально приближенных к диспетчеру задач (список физических и логических дисков, показатели IOps, потребление RAM, нагрузка CPU, количество запущенных процессов, потоков, дескрипторов и т.п.), а так же просматривать список директорий и файлов, их размер и количество, с возможностью удаления.
Как это выглядит на практике. Для установки и запуска данного сервиса я попробовал реализовать два решения. Запуск в виде исполняемого файла (используя модуль ps2exe), в таком варианте можно запускается отдельная консоль, где можно наблюдать весь лог и завершать процесс при закрытии консоли. Второй вариант, это запуск фонового процесса, в таком случае для чтения лога используется файл. Но такое решение не самое удачное, т.к. модуль имеет ограничение, которое позволяет запускать любой скрипт только в PowerShell 5.1 и командлеты из версии Core попросту не буду работать.
Второй вариант, это запуск в качестве службы, процесс установки получилось так же автоматизировать, достаточно на любой машине, где есть доступ в интернет запустить этот скрипт. Настроить свои данные для авторизации и задать свой номер порта (предварительно открыть его в firewall) в конфигурационном файле, после чего, начать взаимодействовать на любой системе, используя Invoke-RestMethod или Curl:
lifailon@hv-devops-01:~$ user="rest"
lifailon@hv-devops-01:~$ pass="api"
lifailon@hv-devops-01:~$ curl -s -X GET -u $user:$pass http://192.168.3.100:8443/api/service/winrm # запрашиваем статус службы WinRM
{
"Name": "WinRM",
"DisplayName": "Служба удаленного управления Windows (WS-Management)",
"Status": "Stopped",
"StartType": "Automatic"
}
lifailon@hv-devops-01:~$ curl -s -X POST -u $user:$pass --data '' http://192.168.3.100:8443/api/service/winrm -H "Status: Start" # запускаем службу
{
"Name": "winrm",
"DisplayName": "Служба удаленного управления Windows (WS-Management)",
"Status": "Running",
"StartType": "Automatic"
}
Пример простого Web-сервера был скорее эксперимент, чем необходимость (очень уж хотелось закрыть старый гештальт). Тем не менее выглядит решение так:
Естественно, для обработки кнопок в браузере не обошлось без JavaScript, особых познаний языка тут не требуется, нашел буквально первый пример в интернете как создать кнопки и обработать их действие при нажатии, ознакомившись с основами HTML-синтаксиса и додумав логику, все получилось сделать достаточно просто. Вот пример с комментариями:
# Типовое условие для проверки вхождения на соответствие метода (GET) и конечной точки (/service)
elseif ($context.Request.HttpMethod -eq "GET" -and $context.Request.RawUrl -eq "/service") {
# Получаем массив из списока служб, используя кастомную функцию для вывода с подробной информацией
$Services = Get-ServiceDescription *
# Формируем текст HTML-документа, задаем заголовок страницы и открываем тело страницы
$GetService = "<html><head><title>Service</title></head><body>"
# Добавляем заготовленные кнопки, которые перенаправляет на другие url
$GetService += $BodyButtons
# Указываем на создание таблицы и задаем имена столбцов
$GetService += "<table border='1'>"
$GetService += "<tr><th>Name</th><th>Status</th><th>Action</th><th>Start Type</th></tr>"
# Передаем в цикл список служб и забираем значения
foreach ($Service in $Services) {
$name = "<b>$($Service.Name)</b>"
$status = $Service.Status
# Проверяем статус службы, если работает, красим в зеленый цвет
if ($status -eq "Running") {
$status = "<font color='green'><b>$status</b></font>"
} else {
$status = "<font color='red'><b>$status</b></font>"
}
$StartType = $Service.StartType
# Заполняем значения столбцов, по анологии с наименованием столбцов (в блоке <tr>)
$GetService += "<tr><td>$name</td><td>$status</td>"
# Создаем кпноки, которые при нажатии ссылаются на функции startService и stopService, которые в качестве параметра передают наименование службы
$GetService += "<td><button onclick='startService(""$($Service.Name)"")'>Start</button> "
$GetService += "<button onclick='stopService(""$($Service.Name)"")'>Stop</button></td>"
$GetService += "<td>$StartType</td></tr>"
}
$GetService += "</table>"
$GetService += '
# Формируем в блоке <script> функции, для обработки нажатия на кнопки
<script>
function startService(serviceName) {
sendServiceAction("Start", serviceName);
}
function stopService(serviceName) {
sendServiceAction("Stop", serviceName);
}
# Данная функция принимает действие и отправляет соответствующий POST-запрос, для его обработки другой конечной точкой
function sendServiceAction(action, serviceName) {
var request = new XMLHttpRequest();
request.open("POST", "/api/service/" + serviceName, true);
# В заголовок запроса передаем статус с содержимым действия (Status: <Stop/Start>) и обновляем страницу (reload)
request.setRequestHeader("Status", action);
request.onreadystatechange = function () {
if (request.readyState === 4 && request.status === 200) {
console.log("True");
location.reload();
}
};
request.send();
}
</script>
</body></html>
'
# Передаем сформированные данные и код ответа в функцию, для отправки ответва клиенту
Send-Response -Data $GetService -Code 200 -v2
}
Для сравнения интерфейса, приведу пример управления службами, используя простой Jenkins Pipeline. Из явных преимуществ, такой интерфейс универсален для обеих систем (Windows и Linux), логика преимущественно на PowerShell и Bash (в моем случае), а доступ настраивается централизованно через Ansible, где в свою очередь используя ssh и winrm. Такой доступ можно заменить на REST-запросы, при наличии подобного сервера на каждой удаленной машине (например, в виде установленной службы). Безусловно, это более современное и правильное решение, но не взаимозаменяемое, речь только про интерфейс взаимодействия, где мы можем в одном интерфейсе управлять сразу с несколькими машинами.
По аналогии со службами, обработал остановку и запуск процессов.
Из интересного на мой взгляд, написал простую функцию для поиска исполняемого файла в системе, который отвечает за запуск процесса конкретного приложения. Если такой процесс не получается найти, то мы получим в ответ код 400: Bad Request. Process <$ProcessName> could not be found. В таком случае, можно воспользоваться заголовком Path, который принимает путь до исполняемого файла.
function Find-Process {
param (
$ProcessName
)
$ProcessPath = (Get-ChildItem "C:\Program Files" | Where-Object Name -match $ProcessName).FullName
if ($null -eq $ProcessPath) {
$ProcessPath = (Get-ChildItem "C:\Program Files (x86)" | Where-Object Name -match $ProcessName).FullName
}
if ($null -eq $ProcessPath) {
$ProcessPath = (Get-ChildItem "$home\AppData\Roaming" | Where-Object Name -match $ProcessName).FullName
}
$ProcessNameExec = "$ProcessName"+".exe"
(Get-ChildItem $ProcessPath -Recurse | Where-Object Name -eq $ProcessNameExec).FullName
}
> Find-Process qbittorrent
C:\Program Files\qBittorrent\qbittorrent.exe
> Find-Process nmap
C:\Program Files (x86)\Nmap\nmap.exe
Find-Process telegram
C:\Users\lifailon\AppData\Roaming\Telegram Desktop\Telegram.exe
Для сбора метрик используется CIM (Common Information Model). Сам скрипт сервера, описание с примерами, как и набор функций опубликованы на GitHub.
Так как PowerShell Core является кросс-платформенным решением, class System.Net.HttpListener работает и в системе Linux, используя такую же логику и возможности сразу нескольких языков (например, Bash), можно управлять службами на платформе Windows через systemctl используя REST API.
Что важно, при возникновении ошибки, мне хотелось, что бы данный сервер только логировал ее, но при этом продолжал функционировать (фактически, перезапускался). Для это достаточно вынести слушателя с циклом в отдельную функцию и запускать ее внутри еще одного бесконечного цикла, где присутствует дополнительная обработка ошибок в блоках try-catch-finally.
Вот базовый пример, без лишнего кода с описанием:
# Заполняем переменны с номером порта и данными для авторизации
$port = 8443
$user = "rest"
$pass = "api"
# Формируем строку Base64 из данных логина и пароля
$cred = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("${user}:${pass}"))
# Функция для логирования запросов
function Get-Log {
### Debug (Get all Request, Headers and Response parameters):
# Используя содержимое запросов (Request), чтение передаваемых заголовков и ответа (Response), можно расширить возможности логирования для отладки процесса
# $context.Request | Out-Default
# foreach ($header in $context.Request.Headers) {
# Write-Host "$header = $($context.Request.Headers[$header])"
# }
# $context.Response | Out-Default
# Забираем содержимое из запроса: адрес клиента, наименование агента, метод и url конечной точки
$remote_host = $context.Request.RemoteEndPoint
$client_agent = $context.Request.UserAgent
$method = $context.Request.HttpMethod
$endpoint = $context.Request.RawUrl
$response_code = $context.Response.StatusCode
$date = Get-Date -Format "dd.MM.yyyy hh:mm:ss"
# Выводим в консоль или в файл
"$date $remote_host $client_agent => $method $endpoint => $response_code"
# "$date $remote_host $client_agent => $method $endpoint => $response_code" | Out-File $Log_Path -Encoding utf8 -Append
}
# Функция для ответа клиенту
function Send-Response {
param (
$Data,
[int]$Code
)
# Проверяем код ответа, если он равен 200 (успех), то конвертируем данные перед отправкой клиенту
if ($Code -eq 200) {
# Дополнительно можем проверить название агента на клиентской стороне, который может выступать в роли браузера или явно задан тип данных HTML
if (($context.Request.UserAgent -match "Chrome") -or ($context.Request.ContentType -match "html")) {
# Конвертируем полученные данные в HTML и указываем тип контента в ответе
$Data = $Data | ConvertTo-Html
$context.Response.ContentType = "text/html; charset=utf-8"
}
# Далее проверяем только тип контента из заголовка (если он задан явным образом), и конвертируем вывод в соответствующий тип данных
elseif ($context.Request.ContentType -match "xml") {
$Data = ($Data | ConvertTo-Xml).OuterXml
$context.Response.ContentType = "text/xml; charset=utf-8"
}
elseif ($context.Request.ContentType -match "csv") {
$Data = $Data | ConvertTo-Csv
$context.Response.ContentType = "text/csv; charset=utf-8"
}
# По умолчанию, конвертируем в JSON
else {
$Data = $Data | ConvertTo-Json
$context.Response.ContentType = "text/json; charset=utf-8"
}
}
# Указываем код статуса для ответа
$context.Response.StatusCode = $Code
# Преобразуем данные в массив байтов, используя кодировку UTF-8 (особенно важно, при передачи в формате HTML)
$buffer = [System.Text.Encoding]::UTF8.GetBytes($Data)
# Выполняем функцию логирования
Get-Log
# Забираем число количества байт буффера для записи в поток, который передается в параметр ответа. Это является важным условием, что все даныне были переданы и прочитаны на стороне клиента.
$context.Response.ContentLength64 = $buffer.Length
# Передаем массив байтов (наш буффер ответа с данными) в поток ответа, обязательно нужно передать параметры смешения (если бы нужно было начать запись с определенного места в массиве) и длинны буффера
$context.Response.OutputStream.Write($buffer, 0, $buffer.Length)
# Данный метод обновляет буфер вывода, убеждаясь, что все данные из буфера отправлены клиенту
$context.Response.OutputStream.Flush()
# Закрываем поток ответа
$context.Response.OutputStream.Close()
}
# Создаем сокет слушателя
Add-Type -AssemblyName System.Net.Http
$http = New-Object System.Net.HttpListener
# Указываем адрес слушателя (+ что бы слушать на всех интерфейсах) и порт
$http.Prefixes.Add("http://+:$port/")
# Указываем использование базового метода аутентификации
$http.AuthenticationSchemes = [System.Net.AuthenticationSchemes]::Basic
# Запускаем сокет (начинаем слушать запросы на указанном порту)
$http.Start()
# Обработчик try-finally нужен для закрытия сокета в случае его непредвиденного завершения
try {
# Отправляем в бесконечный цикл прослушивание входящих запросов, пока свойство IsListening объекта $http равно true
while ($http.IsListening) {
# Используем асинхронный режим, для ожидания новых запросов
$contextTask = $http.GetContextAsync()
# Синхронно ожидает завершения асинхронной задачи, чтобы дождаться завершения асинхронной операции, прежде чем продолжить выполнение кода
while (-not $contextTask.AsyncWaitHandle.WaitOne(200)) { }
# Получение результата асинхронной задачи
$context = $contextTask.GetAwaiter().GetResult()
# Проверяем полученные данные авторизации (в формате Base64) из заголовка запроса на соответветствие переменной $cred
$CredRequest = $context.Request.Headers["Authorization"]
# Write-Host $CredRequest
$CredRequest = $CredRequest -replace "Basic\s"
if ( $CredRequest -ne $cred ) {
# Если авторизационные данные не прошли проверку (неверно передан логин или пароль), передаем в функцию ответа параметры с текстом ошибки и кодом возравата 401
$Data = "Unauthorized (login or password is invalid)"
Send-Response -Data $Data -Code 401
}
else {
# Если авторизация прошла, проверяем метод и url конечной точки на соответветствие, что бы его обработать
if ($context.Request.HttpMethod -eq "GET" -and $context.Request.RawUrl -eq "/api/service") {
$GetService = Get-Service -ErrorAction Ignore
Send-Response -Data $GetService -Code 200
}
# Дальше по аналогии дополнительными условиями (elseif) добавляем обработку других конечных точек
elseif ($context.Request.HttpMethod -eq "GET" -and $context.Request.RawUrl -eq "/api/process") {
$GetService = Get-Process
Send-Response -Data $GetService -Code 200
}
# Если не одно из методов не прошел соответветствие, отправляем ответ с кодом 405
elseif ($context.Request.HttpMethod -ne "GET") {
$Data = "Method not allowed"
Send-Response -Data $Data -Code 405
}
# Если не одно из условий не подошло, отправляем ответ с кодом 404
else {
$Data = "Not found endpoint"
Send-Response -Data $Data -Code 404
}
}
}
}
finally {
# Освобождаем сокет
$http.Stop()
}
Итог. Взяв за основу такой скелет и доработав под себя логику, можно автоматизировать процессы конкретного приложения, у которого не предусмотрен внешний интерфейс для подобных задач. В свою очередь, на стороне клиента взаимодействовать с ним, используя любой удобный интерфейс с REST-клиентом.