Сегодня мы научимся создавать веб интерфейсы для администрирования. Будем запускать Powershell код по клику на сайте или при обращении к API. Для наглядности напишем три сервиса, взаимодействующих со службами, Active Directory и файловой системой.

Введение

Оглядываюсь в прошлое. В 2014 написал на Хабр свои первые статьи про то, как подступиться к Powershell и начал с предупреждения, что Powershell вызывает привыкание. Не наврал, правда вызывает привыкание. С тех времён я сменил три места работы, с крупного московского автодилера, через ИТ-интегратор, до дилера антивирусов. В каждой из компаний приходилось писать на Powershell каждый день.

Скриптов еще ненаписанных, сколько? Скажи, кукушка, пропой.

Мой основной интерес к Powershell в создании решений, упрощающих администрирование инфраструктуры, улучшающих работу команд и обслуживание сервисов. Сюда входят GUI интерфейсы для первой и следующих линий поддержки, интеграции, боты, парсеры, системы скриптов, например, находящие блокировки пользователей и тому подобное. Написание таких решений, без преувеличения, доставляет радость.

Знакомство с возможностью создавать веб сервисы с встроенным исполнением Powershell открыло новый взгляд. Это ведь можно легко и просто делать такие штуки, как админки у Microsoft, как ECP в Exchange! Сразу вспомнились задачи, про которые думали с коллегами, что так нельзя, реализовывали как-то по-другому или откладывали в бэклог.

Сегодня я попробую познакомить с этой возможностью и вас. Как минимум, показать, что так можно.

А нужны ли вообще кому-нибудь GUI интерфейсы администрирования?

Считаю, что нужны. Как и 9 лет назад были нужны, так и сейчас.

Буквально в начале года, когда пробовал себя в роли «Системного инженера на фрилансе» (Upwork), ко мне приходили с заказом на создание Powershell с GUI для изменения атрибутов пользователей.

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

Цель статьи

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

Сразу покажу пример сервисов, которые мы разработаем в этой статье:

Перезапуск службы на сервере по клику на сайте
Перезапуск службы на сервере по клику на сайте
Изменение атрибутов в Active Directory через сайт
Изменение атрибутов в Active Directory через сайт

Помимо этих двух сервисов, будет ещё реализация API для работы с директорией на сервере. И всё это на Powershell!

Работать мы будем с Powershell и фреймворком Pode (GitHub, документация), отлично подходящем для этой цели. С Pode я познакомился чуть больше года назад. Буквально в течение дня реализовал MVP необходимого сервиса и спустя неделю полноценный сервис с аутентификацией, логированием и обработкой ошибок.

Pode — кросс-платформенный PowerShell веб-фреймворк для создания REST API, веб-сайтов и TCP/SMTP-серверов. Поддерживает middleware, сеансы, аутентификацию и логирование, а также функции ограничения доступа и rate limiting. Есть также поддержка Azure Functions и AWS Lambda.

Основным источником информации по Pode является документация. Есть ещё канал в Discord, где сам разработчик фреймворка, Matthew Kelly, отвечает на вопросы и помогает разобраться в ошибках.

Несколько слов про порог входа. Powershell базово, знание структур данных и алгоритмов не обязательно. Понимание методов HTTP-запросов нужно, но если что, почитав документацию разберётесь. Минимально HTML для написания шаблонов.

Код демо сервисов, которые я написал для этой статьи, доступен в репозитории.

Hello World

Для начала работы c Pode нам необходимо произвести его установку:

Install-Module -Name Pode

Прикладываю ссылку на PowerShell Gallery. Далее создаём в рабочем каталоге файл server.ps1 и сохраняем простейшую конфигурацию, которая поднимет веб на порту 8080 и будет отдавать JSON по пути /ping.

# Запуск работы сервера
Start-PodeServer {

    # Cоздаём конечную точку для принятия входящих запросов
    # https://badgerati.github.io/Pode/Functions/Core/Add-PodeEndpoint/
    Add-PodeEndpoint -Address localhost -Port 8080 -Protocol Http

    # Добавляем маршрут для определённого метода 
    # https://badgerati.github.io/Pode/Functions/Routes/Add-PodeRoute/
    Add-PodeRoute -Method Get -Path '/ping' -ScriptBlock {

        # Записываем JSON в ответ
        Write-PodeJsonResponse -Value @{ 'value' = 'pong' }
    }
}

В результате, при обращении на http://localhost:8080/ping получим в ответ значение pong:

Ответ Powershell фреймворка Pode в JSON формате
Ответ Powershell фреймворка Pode в JSON формате

Мы запустили веб на Powershell, ура!

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

Добьёмся минимально рабочей версии каждого сервиса и на одном из примеров познакомимся с некоторыми важными фичами фреймворка: аутентификацией, логированием, лимитами на обращения и middleware.

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

Ну, приступим!

Перезапуск службы на сервере по клику на сайте

Наш первый сервис должен выдавать таблицу со списком из нескольких выбранных нами служб на сервере с их статусом и предоставлять возможность их остановить, запустить и перезапустить.

Из Hello World примера выше мы увидели, что работа фреймворка построена на создании маршрутов (Add-PodeRoute), которые принимают запросы каким-либо методом (на самом деле могут принимать даже несколько методов). Для сервиса работающего со службами мы с лёгкостью можем описать необходимые маршруты:

  • Главная страница со статусом служб и кнопками, заполняемая Get-Service

  • Для запуска службы, командлет Start-Service

  • Для остановки службы, командлет Stop-Service

  • Для перезапуска службы, командлет Restart-Service

Напишем каркас сервиса:

Start-PodeServer {
    # Запускаем сервер на http://localhost:8080
    Add-PodeEndpoint -Address localhost -Port 8080 -Protocol Http

    # Маршрут для главной страницы с таблицей
    Add-PodeRoute -Method Get -Path '/restart-services' -ScriptBlock {
        # TODO: Получать информацию о запущенных службых и передавать на страницу
        pass
    }

    # Маршрут для запуска службы
    Add-PodeRoute -Method Get -Path '/start' -ScriptBlock {
        # TODO: Запустить переданную в параметре службу
        pass
    }

    # Маршрут для остановки службы
    Add-PodeRoute -Method Get -Path '/stop' -ScriptBlock {
        # TODO: Остановить переданную в параметре службу
        pass
    }

    # Маршрут для запуска службы
    Add-PodeRoute -Method Get -Path '/restart' -ScriptBlock {
        # TODO: Перезапустить переданную в параметре службу
        pass
    }
}

Чтобы отрисовать главной страницу с таблицей нам необходимо сделать шаблон. В Pode такой шаблон называется view (документация). Процесс похож на то, как это реализовано в других фреймворках, на тот же Django.

В шаблоне мы используем HTML разметку, можем подключать CSS стили и JavaScript. Основное для нас то, что мы можем прямо в шаблон делать вставки на Powershell. Весь код Powershell должен быть обрамлён в конструкцию $(...) . При многострочном Powershell коде просят ставить в конце строки знак ;. Шаблоны необходимо сохранять в файлы с расширением .pode в директории views, которую надо разместить рядом с основным скриптом для запуска сервера.

Создадим шаблон и сохраним его в views/restart-services.pode:

<html>
    <head>
        <title>Перезапуск службы на сервере по клику на сайте</title>
        <!--- Стили, чтобы у таблицы отображались границы --->
		<style>
			table, th, td {
			  border: 1px solid black;
			  border-collapse: collapse;
			  padding: 7px;
			}
		</style>
    </head>
    <body>
        <table>
            <!--- Первая строка с заголовками столбцов таблицы --->
			<tr>
				<th>Display Name</th>
				<th>Name</th>
				<th>Status</th>
				<th>Actions</th>
			</tr>
            <!--- Формируем остальные строки таблицы --->
            <!--- Здесь начинается Powershell --->
			$(foreach ($service in Get-Service) {
				"<tr>
					<td>$($service.DisplayName)</td>
					<td>$($service.Name)</td>
					<td>$($service.Status)</td>
					<td>
                        <!--- Отправляем на маршруты с действиями имя службы --->
						<a href=`"/start?name=$($service.name)`">Start</a>
						<a href=`"/stop?name=$($service.name)`">Stop</a>
						<a href=`"/restart?name=$($service.name)`">Restart</a>
					</td>
				</tr>"
			})
            <!--- Здесь заканчивается Powershell --->
		</table>
    </body>
</html>

Мы назвали шаблон restart-services.pode и сохранили в правильную директорию, значит теперь мы можем объявить его в коде:

...
    # Включаем возможность рендеринга и использования .pode файлов
    Set-PodeViewEngine -Type Pode

    # Маршрут для главной страницы с таблицей
    Add-PodeRoute -Method Get -Path '/restart-services' -ScriptBlock {
        # Указываем, что мы обращаемся к шаблону restart-services.pode
        # в директории /views
        Write-PodeViewResponse -Path 'restart-services'
    }
...

Уже сейчас можно запустить наш сервер и увидеть результат рендера шаблона:

Мы сразу в шаблоне обработали результат выполнения Get-Services  (строка 23 шаблона) и вывели каждую службу построчно в таблицу
Мы сразу в шаблоне обработали результат выполнения Get-Services (строка 23 шаблона) и вывели каждую службу построчно в таблицу

Теперь добавим логику для кнопок с действиями (Start, Stop, Restart) и ограничим службы, на которые они будут срабатывать. Пусть это будут службы AdobeARMservice, bits, spooler и wuauserv.

На этом шаге предлагаю изменить шаблон и передавать в него уже готовый список служб из скрипта. Заменяем foreach ($service in Get-Service) на foreach ($service in $data.services) . А в код сервера для маршрута с таблицей добавляем список служб (переменная $services), получение информации о службах (в переменную $data) и передачу их в шаблон (ключ -Data у Write-PodeViewResponse):

Start-PodeServer {

    # Ограничиваем список служб
    $services = "AdobeARMservice", "bits", "spooler", "wuauserv"

    # Запускаем сервер на http://localhost:8080
    Add-PodeEndpoint -Address localhost -Port 8080 -Protocol Http

    # Включаем возможность рендеринга и использования .pode файлов
    Set-PodeViewEngine -Type Pode

    # Маршрут для главной страницы с таблицей
    Add-PodeRoute -Method Get -Path '/restart-services' -ScriptBlock {

        # Получаем информацию о службах
        $data = Get-Service $($using:services)

        # Указываем, что мы обращаемся к шаблону restart-services.pode
        # в директории /views и передаём в шаблон хэш-таблицу
        # с результатом выполнения Get-Service
        Write-PodeViewResponse -Path 'restart-services' -Data @{ "services" = $data }
    }
...

Для кнопки старта службы необходимо сделать так, чтобы выполнялся командлет Start-Service -name имя_службы при поступлении запроса с именем службы в параметре name на маршрут /start .

Как нам получить параметр name в коде сервера? Для чтения переменной в скрипте на серверной стороне надо использовать WebEvent (документация). Это хеш-таблица в которой содержится информация о запросах и ответах.

Когда нам надо получить параметры, переданные методом GET, мы обращаемся к $WebEvent.Query["название_параметра"]. Параметр переданный методом POST берём из $WebEvent.Data.название_параметра.

...
    # Маршрут для запуска службы
    Add-PodeRoute -Method Get -Path '/start' -ScriptBlock {
        
        # Получаем значение параметра name
        # В коде шаблона это ?name=$($service.name)
        # При переходе в браузере, например, http://localhost:8080/start?name=spooler
        $service_name = $WebEvent.Query["name"]

        # Проверяем, есть ли служба в списке
        if ($($using:services).Contains($service_name)) {

            # Если есть, выполняем старт службы
            Start-Service -name $service_name
        }

        # Перенаправляем пользователя на страницу с таблицей
        Move-PodeResponseUrl -Url '/restart-services'
    }
...

Для того, чтобы получить доступ к переменной объявленной в файле скрипта за пределами маршрута, используется конструкция $using:название_переменной (документация). Мы воспользовались $using:services для получения списка сервисов из переменной $services, объявленной сразу после старта сервера.

Название служб мы тут захардкодили. Если вы передаёте какие-то изменяемые данные, то лучше делать по-другому. Можно из кода сервера обращаться к файлу конфигурации через Get-Content, импортировать csv файл или может быть вы вообще подключите базу данных.

Аналогичным образом добавляем логику для остальных кнопок:

...
    # Маршрут для остановки службы
    Add-PodeRoute -Method Get -Path '/stop' -ScriptBlock {
        $service_name = $WebEvent.Query.name
        if ($($using:services).Contains($service_name)) {
            Stop-Service -name $service_name
        }
        Move-PodeResponseUrl -Url '/restart-services'
    }

    # Маршрут для перезапуска службы
    Add-PodeRoute -Method Get -Path '/restart' -ScriptBlock {
        $service_name = $WebEvent.Query.name
        if ($($using:services).Contains($service_name)) {
            Restart-Service -name $service_name
        }
        Move-PodeResponseUrl -Url '/restart-services'
    }
...

Запускаем сервер, переходим по ссылке http://localhost:8080/restart-service и проверяем работоспособность.

Клик на сайте превращается в выполнение Powershell команды на сервере
Клик на сайте превращается в выполнение Powershell команды на сервере

При клике на «Stop» на нашей машине останавливается служба, при клике на «Start» запускается, а при «Restart» перезапускается. Программа минимум сделана, сервис работает.

Ссылка на это демо в GitHub.

Итоговый код server.ps1
Start-PodeServer {

    # Ограничиваем список служб
    $services = "AdobeARMservice", "bits", "spooler", "wuauserv"

    # Запускаем сервер на http://localhost:8080
    Add-PodeEndpoint -Address localhost -Port 8080 -Protocol Http

    # Включаем возможность рендеринга и использования .pode файлов
    Set-PodeViewEngine -Type Pode

    # Маршрут для главной страницы с таблицей
    Add-PodeRoute -Method Get -Path '/restart-services' -ScriptBlock {

        # Получаем информацию о службах
        $data = Get-Service $($using:services)

        # Указываем, что мы обращаемся к шаблону restart-services.pode
        # в директории /views и передаём в шаблон хэш-таблицу
        # с результатом выполнения Get-Service
        Write-PodeViewResponse -Path 'restart-services' -Data @{ "services" = $data }
    }

    # Маршрут для запуска службы
    Add-PodeRoute -Method Get -Path '/start' -ScriptBlock {
        
        # Получаем значение параметра name
        # В коде шаблона это ?name=$($service.name)
        # При переходе в браузере, например, http://localhost:8080/start?name=spooler
        $service_name = $WebEvent.Query["name"]

        # Проверяем, есть ли служба в списке
        if ($($using:services).Contains($service_name)) {

            # Если есть, выполняем старт службы
            Start-Service -name $service_name
        }

        # Перенаправляем пользователя на страницу с таблицей
        Move-PodeResponseUrl -Url '/restart-services'
    }

    # Маршрут для остановки службы
    Add-PodeRoute -Method Get -Path '/stop' -ScriptBlock {
        $service_name = $WebEvent.Query.name
        if ($($using:services).Contains($service_name)) {
            Stop-Service -name $service_name
        }
        Move-PodeResponseUrl -Url '/restart-services'
    }

    # Маршрут для перезапуска службы
    Add-PodeRoute -Method Get -Path '/restart' -ScriptBlock {
        $service_name = $WebEvent.Query.name
        if ($($using:services).Contains($service_name)) {
            Restart-Service -name $service_name
        }
        Move-PodeResponseUrl -Url '/restart-services'
    }
}

Итоговый код views/restart-services.pode
<html>
    <head>
        <title>Перезапуск службы на сервере по клику на сайте</title>
        <!--- Стили, чтобы у таблицы отображались границы --->
		<style>
			table, th, td {
			  border: 1px solid black;
			  border-collapse: collapse;
			  padding: 7px;
			}
		</style>
    </head>
    <body>
        <table>
            <!--- Первая строка с заголовками столбцов таблицы --->
			<tr>
				<th>Display Name</th>
				<th>Name</th>
				<th>Status</th>
				<th>Actions</th>
			</tr>
            <!--- Формируем остальные строки таблицы --->
            <!--- Здесь начинается Powershell --->
			$(foreach ($service in $data.services) {
				"<tr>
					<td>$($service.DisplayName)</td>
					<td>$($service.Name)</td>
					<td>$($service.Status)</td>
					<td>
                        <!--- Отправляем на маршруты с действиями имя службы --->
						<a href=`"/start?name=$($service.name)`">Start</a>
						<a href=`"/stop?name=$($service.name)`">Stop</a>
						<a href=`"/restart?name=$($service.name)`">Restart</a>
					</td>
				</tr>"
			})
            <!--- Здесь заканчивается Powershell --->
		</table>
    </body>
</html>

API для работы с директорией

Наш второй сервис будет выполнять определённые действия в директории при обращении по API. Попробуем запускать удаление файлов *.log и получать информацию о всех файлах в этой директории в формате JSON. Здесь же попробуем реализовать аутентификацию по API-ключу, добавим логирование и ограничение по количеству обращений.

Какие маршруты сделаем?

  • Аутентификация

  • Запуск удаления файлов, Remove-Item

  • Получение информации о файлах, Get-ChildItem

Пишем каркас будущего сервиса:

Start-PodeServer {
    # Запускаем сервер на http://localhost:8080
    Add-PodeEndpoint -Address localhost -Port 8080 -Protocol Http

    # TODO: Аутентификация

    # TODO: Логирование

    # TODO: Ограничение по количеству обращений

    # Запуск удаления файлов
    Add-PodeRoute -Method Delete -Path '/api/remove-logs' -ScriptBlock {
        # TODO: Получить файлы в директории для удаления, удалить
        # и вернуть список удалённых файлов
        pass
    }

    # Получение информации о файлах
    Add-PodeRoute -Method Get -Path '/api/get-childitem' -ScriptBlock {
        # TODO: Получить список файлов в директории и вернуть список в JSON
    }

}

HTML шаблонов тут не будет, здесь нет никаких взаимодействий с пользователем в браузере. Приступаем к написанию серверной части.

Аутентификация

То что мы перед этим сделали сервис без аутентификации игрушки и баловство. В корпоративных сервисах она должна быть. Фреймворк Pode даёт богатый выбор (документация): Azure AD, Twitter, User File, Windows AD, Windows Local Users, API Key, Basic, Bearer, Client Certificate, Digest, Form, JWT, OAuth 2.0 & OIDC.

В этом сервисе реализуем аутентификацию по API-ключу (документация). При обращении к маршруту пользователь должен будет передать ключ в заголовке X-API-KEY. Посмотрим на пример реализации из документации:

Start-PodeServer {
    New-PodeAuthScheme -ApiKey | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock {
        param($key)

        # check if the key is valid, and get user

        return @{ User = $user }
    }
}

Ага, то есть нам необходимо придумать проверку ключа. Попробуем реализовать что-нибудь по-быстрому:

...
    # Аутентификация
    New-PodeAuthScheme -ApiKey | Add-PodeAuth -Name 'ApiKeyAuthenticate' -Sessionless -ScriptBlock {
        param($key)
        # В переменной $key будет API-ключ с которым пришёл пользователь

        # Реализуем проверку API ключей
        # Для примера будем проверять ключи обращаясь к csv файлу
        $file_with_keys = "$PSScriptRoot\api_keys.csv"
        # Ищем совпадение входящего ключа с ключом в файле
        $user = Import-Csv $file_with_keys -Delimiter ";" | where key -eq "$key"

        # Если нашли, записываем пользователя в кастомный лог возвращаем пользователя 
        if ($user) {
            Write-CustomAccessLog
            return @{ User = $user.user }
        } else {
            # Если не нашли, вернём сообщение
            return @{ Message = "Access denied!" } 
        }
    }
...

Рядом с файлом сервера создадим файл api_keys.csv с ключами, указанный нами в коде и сгенерируем рандомные значения и имена пользователей:

key;user
rU6LCqwcYcvPJnYNuK2yxgMwMALU2f7qpp5DrTcvxWf3aKFZmb;john.doe
Y2wAkaUhY6uQT9Z5DrbxfGZka3kfdCFvPx5TE1os64YYUwvvvR;oleg
RBX8LuwM9tv53jaRrdN3DbinZicjASRcR9NXuUBYXGTcB7SiTx;habr

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

Я писал выше, что теперь, при отправке запросов к серверу, необходимо будет отправлять ключ в заголовке X-API-KEY. Это так, но важно ещё указать имя только что созданной аутентификации (Add-PodeAuth) в маршрутах в ключ -Authentication . В коде это будет выглядеть так:

...
Add-PodeRoute -Method Get -Path '/api/get-childitem' -Authentication 'ApiKeyAuthenticate' -ScriptBlock {
...

Логирование

По умолчанию доступны request и error логи (документация). Включим оба и создадим ещё свой собственный, access лог.

Логи можно писать в файл, Event Viewer, прямо в консоль, в S3 Bucket или можно самостоятельно реализовать запись, например, в LogStash или Splunk.

Мы будем писать логи в файлы (документация). Для этого в New-PodeLoggingMethod укажем ключ -File, пропишем путь сохранения в папку logs в корне нашего сервиса, а так же сразу укажем сколько хранить логи и в файлах какого размера. Для создания собственного access лога воспользуемся Add-PodeLogger.

...
    # Логирование
    # Включаем requests лог
    New-PodeLoggingMethod -File -Name 'requests' -Path "$PSScriptRoot\logs" -MaxDays 30 -MaxSize 10MB | Enable-PodeRequestLogging
    # Включаем error лог
    New-PodeLoggingMethod -File -Name 'error' -Path "$PSScriptRoot\logs" -MaxDays 30 -MaxSize 10MB | Enable-PodeErrorLogging
    
    # Создаём собственный access лог
    New-PodeLoggingMethod -File -Name 'access' -Path "$PSScriptRoot\logs" -MaxDays 30 -MaxSize 10MB | Add-PodeLogger -Name 'access' -ScriptBlock {
    param($item)
        # Формат вывода параметров в файл
        return "$($item.DateTime), $($item.user)"

        # Для вывода в консоль пишем:
        # $item | out-default 
    }

    # Пишем функцию для записи в собственный лога
    function Write-CustomAccessLog($user, $message)
    {
        Write-PodeLog -Name 'access' -InputObject @{
        DateTime = $(Get-Date -Format "dd-MM-yyyy HH:mm:ss");
        user = $user.user;
        }
    }
...

Ограничение по количеству обращений

При создании сервисов с Pode обязательно стоит подробнее посмотреть на работу Middleware (документация). Под middleware подразумевается то же самое, что и во многих других фреймворках, некий код или программное обеспечение, которое можем обработать запросы и ответы. Можно написать собственные middleware (Add-PodeMiddleware), а можно использовать уже встроенные (access rules, body parsing, CSRF, rate limiting, security headers, sessions).

В этом сервисе реализуем ограничение по количеству обращений с одного IP через rate limiting. Зачем это надо? Во-первых, это защитит от быстрого перебора ключей. Во-вторых, даже если пользователи наши коллеги, никто не может гарантировать, что что-то не сломается и не начнёт спамить сервис.

Для настройки органичений воспользуемся Add-PodeLimitRule (документация). Ограничим, что с одного IP будет приниматься не более 6 обращений за минуту:

...
# Ограничение по количеству обращений
Add-PodeLimitRule -Type IP -Values all -Limit 6 -Seconds 60
...

Перейдём к написанию основных функций нашего API. Для удаления рекомендуется использовать метод DELETE. Получать информацию будем методом GET.

Так как мы хотим использовать аутентификацию, добавляем ключ -Authentication к каждому из маршрутов и указываем имя нашего метода, заданное в Add-PodeAuth ранее.

По логике всё просто, будем делать Get-ChildItem и Remove-Item. В начало ещё добавим рабочую директорию $work_directory, с которой будет работать наше API.

Start-PodeServer {

    # Рабочая директория для действий API
    $work_directory = "C:\TestFolder"
...
    # Запуск удаления файлов
    Add-PodeRoute -Method Delete -Path '/api/remove-logs' -Authentication 'ApiKeyAuthenticate' -ScriptBlock {
        
        # Получаем список файлов для удаления
        $files_for_delete = Get-ChildItem $using:work_directory -Recurse -Include "*.log"
        
        # Удаляем файлы
        $files_for_delete | Remove-Item -Force

        # Возвращаем список удалённых файлов
        Write-PodeJsonResponse -Value $($files_for_delete | select FullName | ConvertTo-Json)
    }

    # Получение информации о файлах
    Add-PodeRoute -Method Get -Path '/api/get-childitem' -Authentication 'ApiKeyAuthenticate' -ScriptBlock {

        # Получаем список файлов в директории
        $result = Get-ChildItem $using:work_directory -Recurse -File | select FullName | ConvertTo-Json

        # Возвращаем список в JSON
        Write-PodeJsonResponse -Value $result
    }
...

Ура, собираем код воедино, запускаем сервис и тестируем API:

Тестирование API для работы с директорией в Postman
Тестирование API для работы с директорией в Postman

Мне было удобно отправлять запросы через Postman, потому что он установлен и я уже был залогинен в него (ха-ха). Можно было отправлять запросы через Powershell, используя Invoke-WebRequest (код под 7 версию ):

$api_key = 'Y2wAkaUhY6uQT9Z5DrbxfGZka3kfdCFvPx5TE1os64YYUwvvvR'
$url = 'http://localhost:8080/api/get-childitem'
Invoke-WebRequest -H @{'X-API-KEY' = $api_key} -Method Get $url -SkipCertificateCheck
Тестирование API для работы с директорией через Invoke-WebRequest
Тестирование API для работы с директорией через Invoke-WebRequest

Так же посмотрим на логи. Видим, что рядом с файлов server.ps1 создалась папка logs и в ней дефолтный request лог и кастомный, созданный нами, access лог:

Логи обращений к сервису
Логи обращений к сервису

На этом мы закончим работу над API и попробуем реализовать ещё один сервис с пользовательским интерфейсом, где попробуем отправлять данные на сервер.

Ссылка на это демо в GitHub.

Итоговый код server.ps1
Start-PodeServer {

    # Рабочая директория для действий API
    $work_directory = "C:\TestFolder"
    
    # Запускаем сервер на http://localhost:8080
    Add-PodeEndpoint -Address localhost -Port 8080 -Protocol Http

    # Аутентификация
    New-PodeAuthScheme -ApiKey | Add-PodeAuth -Name 'ApiKeyAuthenticate' -Sessionless -ScriptBlock {
        param($key)
        # В переменной $key будет API-ключ с которым пришёл пользователь

        # Реализуем проверку API ключей
        # Для примера будем проверять ключи обращаясь к csv файлу
        $file_with_keys = "$PSScriptRoot\api_keys.csv"
        # Ищем совпадение входящего ключа с ключом в файле
        $user = Import-Csv $file_with_keys -Delimiter ";" | where key -eq "$key"

        # Если нашли, записываем пользователя в кастомный лог возвращаем пользователя 
        if ($user) {
            Write-CustomAccessLog
            return @{ User = $user.user }
        } else {
            # Если не нашли, вернём сообщение
            return @{ Message = "Access denied!" } 
        }
    }


    # Логирование
    # Включаем requests лог
    New-PodeLoggingMethod -File -Name 'requests' -Path "$PSScriptRoot\logs" -MaxDays 30 -MaxSize 10MB | Enable-PodeRequestLogging
    # Включаем error лог
    New-PodeLoggingMethod -File -Name 'error' -Path "$PSScriptRoot\logs" -MaxDays 30 -MaxSize 10MB | Enable-PodeErrorLogging
    
    # Создаём собственный access лог
    New-PodeLoggingMethod -File -Name 'access' -Path "$PSScriptRoot\logs" -MaxDays 30 -MaxSize 10MB | Add-PodeLogger -Name 'access' -ScriptBlock {
    param($item)
        # Формат вывода параметров в файл
        return "$($item.DateTime), $($item.user)"

        # Для вывода в консоль пишем:
        # $item | out-default 
    }

    # Пишем функцию для записи в собственный лога
    function Write-CustomAccessLog($user, $message)
    {
        Write-PodeLog -Name 'access' -InputObject @{
        DateTime = $(Get-Date -Format "dd-MM-yyyy HH:mm:ss");
        user = $user.user;
        }
    }


    # Ограничение по количеству обращений
    Add-PodeLimitRule -Type IP -Values all -Limit 6 -Seconds 60


    # Запуск удаления файлов
    Add-PodeRoute -Method Delete -Path '/api/remove-logs' -Authentication 'ApiKeyAuthenticate' -ScriptBlock {
        
        # Получаем список файлов для удаления
        $files_for_delete = Get-ChildItem $using:work_directory -Recurse -Include "*.log"
        
        # Удаляем файлы
        $files_for_delete | Remove-Item -Force

        # Возвращаем список удалённых файлов
        Write-PodeJsonResponse -Value $($files_for_delete | select FullName | ConvertTo-Json)
    }

    # Получение информации о файлах
    Add-PodeRoute -Method Get -Path '/api/get-childitem' -Authentication 'ApiKeyAuthenticate' -ScriptBlock {

        # Получаем список файлов в директории
        $result = Get-ChildItem $using:work_directory -Recurse -File | select FullName | ConvertTo-Json

        # Возвращаем список в JSON
        Write-PodeJsonResponse -Value $result
    }

}

Итоговый код api_keys.csv
key;user
rU6LCqwcYcvPJnYNuK2yxgMwMALU2f7qpp5DrTcvxWf3aKFZmb;john.doe
Y2wAkaUhY6uQT9Z5DrbxfGZka3kfdCFvPx5TE1os64YYUwvvvR;oleg
RBX8LuwM9tv53jaRrdN3DbinZicjASRcR9NXuUBYXGTcB7SiTx;habr

Изменение атрибутов в Active Directory через сайт

Наш третий сервис будет получать и изменять атрибут пользователя в Active Directory. Изменяемым атрибутом будет номер телефона.

На стороне веб для этого нам понадобится пара полей для ввода, под логин пользователя и для номера телефона. Так же ограничимся двумя кнопками, для получения текущего номера телефона и для записи нового номера в атрибут.

Какие маршруты нам понадобятся:

  • Основная страница с полями для ввода, кнопками и получением информации через Get-ADUser

  • Запись атрибута пользователя, Set-ADUser

Следовательно, получаем такую структуру приложения:

Start-PodeServer {

    # Запускаем сервер на http://localhost:8080
    Add-PodeEndpoint -Address localhost -Port 8080 -Protocol Http

    # Включаем возможность рендеринга и использования .pode файлов
    Set-PodeViewEngine -Type Pode

    # Маршрут для главной страницы
    Add-PodeRoute -Method get -Path '/change-attribute' -ScriptBlock {

        # TODO: Получать информацию о пользователе и отдавать номер телефона
        # Если пользователь не существует, написать ошибку

        # Указываем, что мы обращаемся к шаблону change-attribute.pode в директории /views
        Write-PodeViewResponse -Path 'change-attribute'
    }

    # Запись атрибута пользователя
    Add-PodeRoute -Method post -Path '/set-phone' -ScriptBlock {

        # TODO: Изменение номера телефона

        # Перенаправляем пользователя на главную страницу
        Move-PodeResponseUrl -Url '/change-attribute'
    }
}

Шаблон делаем максимально простым.

Шаблон сервиса для изменения номера телефона в Active Directory
Шаблон сервиса для изменения номера телефона в Active Directory

При клике на кнопку «Получить» будем отправлять логин параметром на эту же страницу методом GET. При запросе с параметром login, на нашей главной странице будет производиться поиск пользователя с помощью Get-ADUser. Если пользователь существует, достаём его номер телефона и отправляем на страницу в phone. Если пользователя не существует, отправляет на страницу информацию об этом в message.

При клике на кнопку «Изменить» отправим POST запрос на /set-phone. Дополнительных проверок делать не будем, просто сделаем действие перезаписи атрибута (replace) через Set-ADUser.

<html>
    <head>
        <title>Изменение атрибутов в Active Directory через сайт</title>
    </head>
    <body>
		<!--- Поле для вывода сообщений --->
	    <p style="color:red">$($data.message)</p>
	    <!--- Форма получения номера телефона. Используем метод GET --->
		<form action="/change-attribute"  method="get">
			<div>
				<label for="login">Логин: </label>
				<!--- Поле для логина --->
				<!--- В value мы записываем значение из Powershell --->
				<input type="text" name="login" value="$($data.login)" placeholder="Введите логин" />
				<!--- Кнопка отправки формы для получения номера телефона --->
				<input type="submit" value="Получить" />
			</div>
		</form>
	    <!--- Форма изменения номера телефона. Используем метод POST --->
		<form action="/set-phone"  method="post">
			<div>
				<label for="phone">Телефон: </label>
				<!--- Поле для телефона --->
				<!--- В value мы записываем значение из Powershell --->
				<input type="number" name="phone" value="$($data.phone)" placeholder="Введите телефон" />
				<!--- Скрытое поле для логина, чтобы он отправлялся в запросе этой форме --->
				<input name="login" type="hidden" value="$($data.login)">
				<!--- Кнопка отправки формы для изменения номера телефона --->
				<input type="submit" value="Изменить" />
			</div>
		</form>
    </body>
</html>

Приступим к реализации основного функционала. Для работы с Active Directory, сразу же после старта сервера (Start-PodeServer) импортируем модуль ActiveDirectory. В маршруте /change-attribute делаем проверку, пришёл ли логин пользователя через GET в параметр login. Если пришёл, обрабатываем и получаем либо номер телефона, либо ошибку, если такого логина в Active Directory не существует:

Start-PodeServer {
    # Импортируем модуль для работы с AD
    import-module ActiveDirectory

    # Запускаем сервер на http://localhost:8080
    Add-PodeEndpoint -Address localhost -Port 8080 -Protocol Http

    # Включаем возможность рендеринга и использования .pode файлов
    Set-PodeViewEngine -Type Pode

    # Маршрут для главной страницы
    Add-PodeRoute -Method get -Path '/change-attribute' -ScriptBlock {

        # Получаем информацию о пользователе
        if ($login = $WebEvent.Query["login"]) {
            try {
                $phone_number = get-aduser $login -Properties telephoneNumber | select telephoneNumber -ExpandProperty telephoneNumber
            }
            catch [Microsoft.ActiveDirectory.Management.ADIdentityResolutionException] {
                $message = "Пользователь $login не найден"
            }
        }
        # Указываем, что мы обращаемся к шаблону change-attribute.pode в директории /views
        # и передаём в шаблон данные
        Write-PodeViewResponse -Path 'change-attribute' -Data @{ "login" = $login; "phone" = $phone_number; "message" = $message }
    }
...

Теперь второй маршрут, для изменения атрибута. Проверяем, что в POST нам передали необходимые параметры (login и phone) и выполняем Set-ADUser.

...
    # Запись атрибута пользователя
    Add-PodeRoute -Method post -Path '/set-phone' -ScriptBlock {
        if (($login = $WebEvent.Data.login) -and ($phone_number = $WebEvent.Data.phone)) {
            # Изменяем номер телефона пользователя
            Get-ADUser -Identity $login | Set-ADUser -replace @{telephonenumber=$phone_number}
        }
        # Перенаправляем пользователя на главную страницу
        Move-PodeResponseUrl -Url '/change-attribute'
    }
...

Кажется, ничего не забыли. Запускаем сервер и проверяем.

Ссылка на GitHub

Работает! Мы получили номер телефона пользователя john.doe 444666 и изменили его на 555444. При повторном получении номера телефона видим, что он изменился. На пользователя john.doe333, которого у нас нет в Active Directory, получаем ошибку «Пользователь john.doe333 не найден». Всё так, как и задумывалось.

Ссылка на это демо в GitHub.

Итоговый код server.ps1
Start-PodeServer {
    # Импортируем модуль для работы с AD
    import-module ActiveDirectory

    # Запускаем сервер на http://localhost:8080
    Add-PodeEndpoint -Address localhost -Port 8080 -Protocol Http

    # Включаем возможность рендеринга и использования .pode файлов
    Set-PodeViewEngine -Type Pode

    # Маршрут для главной страницы
    Add-PodeRoute -Method get -Path '/change-attribute' -ScriptBlock {

        # Получаем информацию о пользователе
        if ($login = $WebEvent.Query["login"]) {
            try {
                $phone_number = get-aduser $login -Properties telephoneNumber | select telephoneNumber -ExpandProperty telephoneNumber
            }
            catch [Microsoft.ActiveDirectory.Management.ADIdentityResolutionException] {
                $message = "Пользователь $login не найден"
            }
        }
        # Указываем, что мы обращаемся к шаблону change-attribute.pode в директории /views
        # и передаём в шаблон данные
        Write-PodeViewResponse -Path 'change-attribute' -Data @{ "login" = $login; "phone" = $phone_number; "message" = $message }
    }

    # Запись атрибута пользователя
    Add-PodeRoute -Method post -Path '/set-phone' -ScriptBlock {
        if (($login = $WebEvent.Data.login) -and ($phone_number = $WebEvent.Data.phone)) {
            # Изменяем номер телефона пользователя
            Get-ADUser -Identity $login | Set-ADUser -replace @{telephonenumber=$phone_number}
        }
        # Перенаправляем пользователя на главную страницу
        Move-PodeResponseUrl -Url '/change-attribute'
    }
}

Итоговый код views/change-attribute.pode
<html>
    <head>
        <title>Изменение атрибутов в Active Directory через сайт</title>
    </head>
    <body>
		<!--- Поле для вывода сообщений --->
	    <p style="color:red">$($data.message)</p>
	    <!--- Форма получения номера телефона. Используем метод GET --->
		<form action="/change-attribute"  method="get">
			<div>
				<label for="login">Логин: </label>
				<!--- Поле для логина --->
				<!--- В value мы записываем значение из Powershell --->
				<input type="text" name="login" value="$($data.login)" placeholder="Введите логин" />
				<!--- Кнопка отправки формы для получения номера телефона --->
				<input type="submit" value="Получить" />
			</div>
		</form>
	    <!--- Форма изменения номера телефона. Используем метод POST --->
		<form action="/set-phone"  method="post">
			<div>
				<label for="phone">Телефон: </label>
				<!--- Поле для телефона --->
				<!--- В value мы записываем значение из Powershell --->
				<input type="number" name="phone" value="$($data.phone)" placeholder="Введите телефон" />
				<!--- Скрытое поле для логина, чтобы он отправлялся в запросе этой форме --->
				<input name="login" type="hidden" value="$($data.login)">
				<!--- Кнопка отправки формы для изменения номера телефона --->
				<input type="submit" value="Изменить" />
			</div>
		</form>
    </body>
</html>

На этом мы заканчиваем реализацию трёх сервисов в минимально рабочей версии.

Из интересного и важного ещё расскажу про возможность запуска ваших Powershell сервисов с Pode в Docker, на IIS или как службу. Рекомендую к прочтению вот этот раздел документации.

Заключение

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

Спасибо за внимание!

Олег Белов

Системный инженер (ex. Kaspersky, ex. КРОК)

P.S. На данный момент я открыт к предложениям о работе. Remote позиции системного инженера. Ссылки для рекомендаций: LinkedIn, Хабр Карьера.

Комментарии (0)