Выключаем компьютер через Wake-on-Lan

bc9ab72e5e883006b4cd685b0c7f2f01

Wake-on-Lan (WoL) — технология, которая используется (как и следует из названия) для включения компьютера посредством отправки специального пакета на адрес требуемого хоста. Но что если при помощи WoL хочется ещё и выключать компьютер?

По сути весь дальнейший текст — это ещё один способ превратить буханку хлеба в троллейбус. Но если очень хочется, то почему нет?

В чём суть?

Я активно использую WoL для включения домашнего компьютера через смартфон и практически не использую физическую кнопку на корпусе ПК. Приложение для отправки WoL-пакета, которое я использую — это первое приложение, которое вылезло в Play Market’е, когда я вообще решил реализовывать эту затею. И что мне особенно в нём нравится, так это то, что у этого приложения ровно одна функция — отправить пакет на ПК. Всё.

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

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

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

Поиск решения

В общем, после ещё некоторого гугления выяснилось, что технология WoL подходит только для включения и на этом его полномочия всё. А менять приложение и что-то устанавливать, как уже говорил, ну совсем не хотелось и тут мелькнула простая, как топор, мысль: «А почему бы не заставить компьютер считать, что в случае, если приходит WoL-пакет, то это знак, что ему пора бы выключиться?».

Для реализации подобных вещей идеально подходит Python, так что открываем IDE и начинаем менять смысл WoL на прямо противоположный.

Проектируем

Для реализации задуманного сначала нужно разобраться, что такое WoL-пакет и из чего он сделан.

WoL-пакет или по-другому magic packet отправляется посредством UDP чаще всего на 7 или 9 порт, «весит» 102 байта и состоит из следующих частей:

  1. Символ 0xFF, повторяющийся 6 раз. Итого 6 байт;

  2. MAC-адрес целевого устройства, повторяющийся 16 раз. Размер MAC-адреса 6 байт, итого 96 байт.

Теперь, зная это, план следующий:

  1. Определить сетевой интерфейс, на который будем отправлять WoL-пакеты и выбрать номер порта. Интерфейс желательно выбрать, т.к. на ПК может быть несколько сетевых интерфейсов, каждый со своим адресом;

  2. Получить IP и MAC-адреса выбранного интерфейса;

  3. Самостоятельно вычислить правильный WoL-пакет для выбранного интерфейса;

  4. Запустить прослушивание UDP-порта, куда собираемся отправлять WoL-пакеты;

  5. Декодировать полученные данные и затем сравнить их с тем, что получилось в п. 3 и если данные совпадают, то запустить команду завершения работы;

  6. Profit!

Реализация

Приступаем к реализации.

С первым пунктом всё просто. Название интерфейса можно посмотреть где-нибудь в настройках операционной системы. В моём случае — это Ethernet 4. Номер порта для WoL-пакетов, как уже говорилось выше, чаще всего 7 и 9. Я буду использовать 9.

WOL_PORT = 9
INTERFACE_NAME = 'Ethernet 4'

Пункт второй. Для получения IP и MAC-адресов понадобится библиотека psutil (больше никаких зависимостей устанавливать не будем).

pip install psutil

Затем пишем код для получения адресов:

import psutil

def get_ip_mac_address(interface_name: str) -> tuple:
    ip_addr = mac_addr = None

    for item in psutil.net_if_addrs()[interface_name]:
        addr = item.address

        # В IPv4-адресах разделители - точки
        if '.' in addr:
            ip_addr = addr
        # В MAC-адресах разделители либо тире, либо одинарное двоеточие.
        # Двойное двоеточие - это разделители для адресов IPv6
        elif ('-' in addr or ':' in item) and '::' not in addr:
            # Приводим MAC-адрес к одному формату. Формат может меняться в зависимости от ОС
            mac_addr = addr.replace(':', '-').upper()

    if not ip_addr or not mac_addr or ip_addr == '127.0.0.1':
        raise 'Не удалось получить IP или MAC-адрес сетевого интерфейса'

    return ip_addr, mac_addr

Мелкие пояснения по коду есть в самом коде, но некоторого описания требует строка 6.
Функция psutil.net_if_addrs()[interface_name] возвращает список именованных кортежей c различными параметрами для каждого адреса сетевого интерфейса. Порядок этих кортежей разный для разных ОС (я тестировал на Windows 10 и Ubuntu 20.04), соответственно на выборку по индексам рассчитывать не стоит, поэтому запускаем цикл по адресам интерфейса и парой условных операторов идентифицируем кто есть кто.

(Уже постфактум я подумал, что можно было для этого использовать значение family, но переписывать было лень).

Со вторым пунктом разобрались, теперь пункт три. Тут всё просто, почти как в первом.
Используем полученный MAC-адрес и собираем магический пакет:

def assemble_wol_packet(mac_address: str) -> str:
    return f'{"FF-" * 6}{(mac_address + "-") * 16}'

Четвёртый пункт не сильно сложнее остальных.
Запускаем процесс прослушивания указанного порта с полученным IP-адресом интерфейса.
На строке 4 используется функция из пункта 2, а на строке 9 — функция из пункта 3

import socket

def run_udp_port_listener(port: int, interface_name: str):
    ip_addr, mac_addr = get_ip_mac_address(interface_name)

    server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    server_socket.bind((ip_addr, port))

    assembled_wol_packet = assemble_wol_packet(mac_addr)

    while True:
        data, _ = server_socket.recvfrom(1024)

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

decoded_packet_data = '-'.join(f'{byte:02x}' for byte in raw_bytes).upper() + '-'

Немного пояснений: полученные данные — это набор байт, так что сначала приводим данные к шестнадцатеричному виду при помощи команды byte:02x . Далее то, что получилось разделяем через знак »-» и в конце приводим к верхнему регистру.
По идее без знака тире, что здесь, что в третьем пункте можно обойтись, но с ним проще проводить отладку, если вдруг что-то идёт не так.

Затем осталось сравнить данные и запустить команду выключения ПК. Для удобства лучше обернуть декодирование и сравнение в одну функцию:

def check_is_wol_packet(raw_bytes: bytes, assembled_wol_packet: str) -> int:
    decoded_packet_data = '-'.join(f'{byte:02x}' for byte in raw_bytes).upper() + '-'

    if decoded_packet_data == assembled_wol_packet:
        return 1

    return 0

В случае совпадения данных инициируем shutdown:

import os

# ... Здесь код запуска прослушивания порта ...

while True:
    data, _ = server_socket.recvfrom(1024)

    is_wol_packet = check_is_wol_packet(data, assembled_wol_packet)

    if is_wol_packet == 1:
        if os.name == 'posix':
            os.system('sudo shutdown -h now')
        elif os.name == 'nt':
            os.system('shutdown -s -t 0 -f')

И теперь соберём весь код вместе

import socket
import os
import logging
import psutil

WOL_PORT = 9
INTERFACE_NAME = 'Ethernet 4'

logging.basicConfig(format='%(levelname)s: %(asctime)s %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)


def get_ip_mac_address(interface_name: str) -> tuple:
    ip_addr = mac_addr = None

    for item in psutil.net_if_addrs()[interface_name]:
        addr = item.address

        # В IPv4-адресах разделители - точки
        if '.' in addr:
            ip_addr = addr
        # В MAC-адресах разделители либо тире, либо одинарное двоеточие.
        # Двойное двоеточие - это разделители для адресов IPv6
        elif ('-' in addr or ':' in item) and '::' not in addr:
            # Приводим MAC-адрес к одному формату. Формат может меняться в зависимости от ОС
            mac_addr = addr.replace(':', '-').upper()

    if not ip_addr or not mac_addr or ip_addr == '127.0.0.1':
        raise 'Не удалось получить IP или MAC-адрес сетевого интерфейса'

    return ip_addr, mac_addr


def assemble_wol_packet(mac_address: str) -> str:
    return f'{"FF-" * 6}{(mac_address + "-") * 16}'


def check_is_wol_packet(raw_bytes: bytes, assembled_wol_packet: str) -> int:
    decoded_packet_data = '-'.join(f'{byte:02x}' for byte in raw_bytes).upper() + '-'

    if decoded_packet_data == assembled_wol_packet:
        return 1

    return 0


def run_udp_port_listener(port: int, interface_name: str):
    ip_addr, mac_addr = get_ip_mac_address(interface_name)

    server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    server_socket.bind((ip_addr, port))
    logger.info(f'Listening on {ip_addr}:{port}')

    assembled_wol_packet = assemble_wol_packet(mac_addr)

    while True:
        data, _ = server_socket.recvfrom(1024)

        is_wol_packet = check_is_wol_packet(data, assembled_wol_packet)

        if is_wol_packet == 1:
            if os.name == 'posix':
                os.system('sudo shutdown -h now')
            elif os.name == 'nt':
                os.system('shutdown -s -t 0 -f')


run_udp_port_listener(WOL_PORT, INTERFACE_NAME)

Теперь если всё сделано верно, то при отправке WoL-пакета на IP-адрес ПК он выключится. Если при этом немного поколдовать с роутером, то это получится делать и через интернет.

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

Итого

Конечно, вышеприведённый код не идеален. Его можно написать лучше, добавить различные проверки для безопасности и сделать много других вещей, но лично для меня это уже ненужное усложнение. То что было нужно мне я реализовал и думаю, что на этом можно и закончить.

© Habrahabr.ru